Skip to main content

Try-Catch and Error Handling

Important Guidelines

This document outlines our team's standards and best practices for implementing error handling and using try-catch blocks effectively.

Introduction

Proper error handling is crucial for building robust and maintainable applications. This guide establishes our team's standards for implementing error handling and using try-catch blocks effectively.

Core Principles

1. No Nested Try-Catch Blocks

Strictly Prohibited: Nested try-catch blocks are forbidden under all circumstances. This is a major red line that should never be crossed.

If you find yourself needing nested try-catch blocks, the architecture must be redesigned.

// ❌ Prohibited: Nested try-catch blocks
try {
// Some operation
try {
// Another operation
} catch (SomeException $e) {
// Handle inner exception
}
} catch (AnotherException $e) {
// Handle outer exception
}

// ✅ Better: Separate methods with their own try-catch
public function performOperation(): Result
{
try {
$data = $this->fetchData();
return $this->processData($data);
} catch (CustomException $e) {
$this->logger->error('Operation failed', ['exception' => $e]);
return new Result(false, $e->getMessage());
}
}

private function fetchData(): Data
{
try {
// Fetch data logic
return new Data($result);
} catch (CustomException $e) {
$this->logger->error('Data fetch failed', ['exception' => $e]);
throw $e;
}
}

2. Avoid Catching General Exceptions

❌ Prohibited

  • PHP's base Exception
  • Throwable
  • Default PDO exceptions
  • DatabaseException
  • FileNotFoundException

✅ Recommended

  • Custom domain exceptions
  • Specific exception types
  • Exceptions with clear context
  • Exceptions that can be handled meaningfully

Catching general exceptions is prohibited in 99% of cases. The rare exceptions to this rule are very specific and limited.

// ❌ Avoid: Catching general exceptions
try {
// Some operation
} catch (Exception $e) {
// Handle all exceptions the same way
}

// ✅ Better: Catch specific custom exceptions
try {
// Some operation
} catch (UserNotFoundException $e) {
// Handle user not found
} catch (InvalidInputException $e) {
// Handle invalid input
}

3. Use Custom Exceptions

Exceptions that should be caught are custom exceptions defined by the team. Custom exceptions should inherit from base exceptions defined in the project.

info

For more information about custom exceptions, refer to the project README or consult with Mohammad and Ali.

// ✅ Recommended: Custom exception hierarchy
class AppException extends Exception {}

class DomainException extends AppException {}
class InfrastructureException extends AppException {}

class UserNotFoundException extends DomainException {}
class InvalidInputException extends DomainException {}
class DatabaseConnectionException extends InfrastructureException {}

4. When to Use Try-Catch

Use try-catch blocks only when interacting with external systems or anything outside the boundaries of your application:

  • Third-party APIs
  • File operations
  • Database connections
  • Network requests
  • External services
// ✅ Appropriate use of try-catch
public function fetchUserDataFromExternalApi(string $userId): UserData
{
try {
$response = $this->apiClient->get("/users/{$userId}");
return new UserData($response['data']);
} catch (ApiConnectionException $e) {
$this->logger->error('API connection failed', ['exception' => $e, 'userId' => $userId]);
throw new UserDataFetchException("Failed to fetch user data: {$e->getMessage()}", 0, $e);
} catch (ApiResponseException $e) {
$this->logger->error('API returned error', ['exception' => $e, 'userId' => $userId]);
throw new UserDataFetchException("Invalid API response: {$e->getMessage()}", 0, $e);
}
}

5. When Not to Use Try-Catch

Do not use try-catch in places where business rules are applied (such as Domain services in DDD). Instead, use "Safe Field" approach — check conditions and report issues in the service response to the upper layer.

// ❌ Avoid: Using try-catch for business rules
public function transferMoney(Account $from, Account $to, float $amount): void
{
try {
if ($from->getBalance() < $amount) {
throw new InsufficientFundsException();
}
$from->withdraw($amount);
$to->deposit($amount);
} catch (InsufficientFundsException $e) {
// Handle exception
}
}

// ✅ Better: Use "Safe Field" approach
public function transferMoney(Account $from, Account $to, float $amount): TransferResult
{
if ($from->getBalance() < $amount) {
return new TransferResult(false, 'Insufficient funds');
}

$from->withdraw($amount);
$to->deposit($amount);

return new TransferResult(true, 'Transfer completed');
}

6. Performance Considerations

Performance Impact

Using try-catch blocks is not optimal from a performance perspective and has had bugs in PHP itself in the past.

Consider the Go language's error handling pattern, which has nearly eliminated the use of exceptions in favor of explicit error return values.

7. One Action Per Try Block

Each try block should perform only one action. Performing multiple actions in a single try block is an anti-pattern. The state of that single action should be checked.

// ❌ Avoid: Multiple actions in one try block
try {
$user = $this->userRepository->find($userId);
$order = $this->orderRepository->create($user, $orderData);
$this->emailService->sendOrderConfirmation($order);
} catch (Exception $e) {
// Which operation failed?
}

// ✅ Better: One action per try block
public function processOrder(string $userId, array $orderData): OrderResult
{
try {
$user = $this->userRepository->find($userId);
} catch (UserNotFoundException $e) {
$this->logger->error('User not found', ['exception' => $e, 'userId' => $userId]);
return new OrderResult(false, 'User not found');
}

try {
$order = $this->orderRepository->create($user, $orderData);
} catch (OrderCreationException $e) {
$this->logger->error('Order creation failed', ['exception' => $e, 'userId' => $userId]);
return new OrderResult(false, 'Failed to create order');
}

try {
$this->emailService->sendOrderConfirmation($order);
} catch (EmailSendingException $e) {
$this->logger->warning('Order confirmation email failed', ['exception' => $e, 'orderId' => $order->getId()]);
// Continue despite email failure
}

return new OrderResult(true, 'Order processed successfully', $order->getId());
}

8. Always Log Caught Exceptions

Every caught exception must be logged with appropriate context.

// ✅ Required: Log caught exceptions
try {
// Some operation
} catch (CustomException $e) {
$this->logger->error('Operation failed', [
'exception' => $e,
'stack' => $e->getTraceAsString(),
'context' => $contextData
]);

// Handle exception
}

Exception: If you catch an exception, perform an action (like a rollback), and then rethrow the same exception to be logged in an upper layer (like an error handler), logging in the catch block is not mandatory.

// ✅ Valid exception to logging rule
try {
$this->db->beginTransaction();
// Database operations
$this->db->commit();
} catch (DatabaseException $e) {
$this->db->rollback();
throw $e; // Will be logged in upper layer
}

9. Include Context in Logs

Logs must always include context (such as stack trace) to be useful for debugging.

// ✅ Required: Include context in logs
try {
// Some operation
} catch (CustomException $e) {
$this->logger->error('Operation failed', [
'exception' => $e,
'stack' => $e->getTraceAsString(),
'userId' => $userId,
'requestId' => $requestId,
'additionalData' => $data
]);

// Handle exception
}

Real-World Example

class UserApiService
{
private ApiClient $apiClient;
private LoggerInterface $logger;

public function __construct(ApiClient $apiClient, LoggerInterface $logger)
{
$this->apiClient = $apiClient;
$this->logger = $logger;
}

public function getUserProfile(string $userId): UserProfileResult
{
try {
$response = $this->apiClient->get("/users/{$userId}/profile");

return new UserProfileResult(
true,
new UserProfile(
$response['name'],
$response['email'],
$response['avatar']
)
);
} catch (ApiConnectionException $e) {
$this->logger->error('API connection failed', [
'exception' => $e,
'userId' => $userId,
'stack' => $e->getTraceAsString()
]);

return new UserProfileResult(
false,
null,
'Could not connect to user service'
);
} catch (ApiResponseException $e) {
$this->logger->error('API returned error response', [
'exception' => $e,
'userId' => $userId,
'stack' => $e->getTraceAsString(),
'response' => $e->getResponse()
]);

return new UserProfileResult(
false,
null,
'User service returned an error'
);
} catch (UserNotFoundException $e) {
// This is a custom exception we expect and handle specifically
$this->logger->info('User not found in API', [
'userId' => $userId
]);

return new UserProfileResult(
false,
null,
'User not found'
);
}
}
}

class UserProfileResult
{
public readonly bool $success;
public readonly ?UserProfile $profile;
public readonly ?string $errorMessage;

public function __construct(
bool $success,
?UserProfile $profile = null,
?string $errorMessage = null
) {
$this->success = $success;
$this->profile = $profile;
$this->errorMessage = $errorMessage;
}
}

Summary

Key Guidelines

  1. Never use nested try-catch blocks
  2. Avoid catching general exceptions
  3. Use custom exceptions that inherit from base project exceptions
  4. Use try-catch only for external system interactions
  5. Use "Safe Field" approach for business rules instead of try-catch
  6. Remember that try-catch has performance implications
  7. Include only one action per try block
  8. Always log caught exceptions with context
  9. Include stack traces and relevant data in exception logs
info

Team CTO Guidance:

"Proper error handling is not just about catching exceptions, but about designing systems that are resilient and maintainable. Our approach should focus on preventing errors through good design rather than catching them after they occur."

Actionable Rule:

  • Review error handling during code reviews to ensure it follows these standards
  • Consider using static analysis tools to detect prohibited patterns