Try-Catch and Error Handling
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
DatabaseExceptionFileNotFoundException
✅ 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.
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
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
- External API Integration
- Business Logic
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;
}
}
class TransferMoneyService
{
private AccountRepository $accountRepository;
private TransactionRepository $transactionRepository;
private LoggerInterface $logger;
public function __construct(
AccountRepository $accountRepository,
TransactionRepository $transactionRepository,
LoggerInterface $logger
) {
$this->accountRepository = $accountRepository;
$this->transactionRepository = $transactionRepository;
$this->logger = $logger;
}
public function transfer(string $fromAccountId, string $toAccountId, float $amount): TransferResult
{
// Safe field approach for business logic - no try/catch
if ($amount <= 0) {
return new TransferResult(false, 'Amount must be positive');
}
$fromAccount = $this->accountRepository->findById($fromAccountId);
if (!$fromAccount) {
return new TransferResult(false, 'Source account not found');
}
$toAccount = $this->accountRepository->findById($toAccountId);
if (!$toAccount) {
return new TransferResult(false, 'Destination account not found');
}
if ($fromAccount->getBalance() < $amount) {
return new TransferResult(false, 'Insufficient funds');
}
// Only use try/catch for external system interaction (database in this case)
try {
$this->accountRepository->beginTransaction();
$fromAccount->withdraw($amount);
$toAccount->deposit($amount);
$this->accountRepository->save($fromAccount);
$this->accountRepository->save($toAccount);
$transaction = new Transaction(
$fromAccountId,
$toAccountId,
$amount,
new DateTime()
);
$this->transactionRepository->save($transaction);
$this->accountRepository->commitTransaction();
return new TransferResult(
true,
'Transfer completed successfully',
$transaction->getId()
);
} catch (DatabaseException $e) {
$this->accountRepository->rollbackTransaction();
$this->logger->error('Database error during transfer', [
'exception' => $e,
'fromAccountId' => $fromAccountId,
'toAccountId' => $toAccountId,
'amount' => $amount,
'stack' => $e->getTraceAsString()
]);
return new TransferResult(
false,
'An error occurred while processing the transfer'
);
}
}
}
class TransferResult
{
public readonly bool $success;
public readonly string $message;
public readonly ?string $transactionId;
public function __construct(
bool $success,
string $message,
?string $transactionId = null
) {
$this->success = $success;
$this->message = $message;
$this->transactionId = $transactionId;
}
}
Summary
Key Guidelines
- Never use nested try-catch blocks
- Avoid catching general exceptions
- Use custom exceptions that inherit from base project exceptions
- Use try-catch only for external system interactions
- Use "Safe Field" approach for business rules instead of try-catch
- Remember that try-catch has performance implications
- Include only one action per try block
- Always log caught exceptions with context
- Include stack traces and relevant data in exception logs
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