07: Error Handling

Chapter 7: Error Handling
Section titled “Chapter 7: Error Handling”Overview
Section titled “Overview”PHP’s exception handling works nearly identically to Java’s—you use try-catch-finally blocks, throw exceptions, and can create custom exception classes. However, PHP historically had both errors and exceptions, though modern PHP (7+) converts most errors to exceptions. In this chapter, you’ll learn PHP’s exception system and best practices coming from Java.
Prerequisites
Section titled “Prerequisites”::: info Time Estimate ⏱️ 60-75 minutes to complete this chapter :::
What you need:
- Completed Chapter 6: Namespaces & Autoloading
- Understanding of Java exception handling
- Familiarity with try-catch-finally blocks
What You’ll Build
Section titled “What You’ll Build”In this chapter, you’ll create:
- Custom exception classes (UserNotFoundException, ValidationException, DatabaseException)
- A Result object pattern implementation for expected failures
- An API error handling system with global exception handler
- Structured logging with context-rich exceptions
- Exception handling tests using PHPUnit
Learning Objectives
Section titled “Learning Objectives”By the end of this chapter, you’ll be able to:
- Use try-catch-finally blocks in PHP
- Throw and catch exceptions
- Create custom exception classes
- Understand PHP’s exception hierarchy
- Handle multiple exception types
- Use modern error handling best practices
Section 1: Basic Exception Handling
Section titled “Section 1: Basic Exception Handling”Master try-catch-finally blocks in PHP.
Try-Catch-Finally
Section titled “Try-Catch-Finally”::: code-group
<?php
declare(strict_types=1);
function divide(int $a, int $b): float{ if ($b === 0) { throw new InvalidArgumentException("Division by zero"); } return $a / $b;}
try { $result = divide(10, 0); echo "Result: $result\n";} catch (InvalidArgumentException $e) { echo "Error: {$e->getMessage()}\n";} finally { echo "Cleanup code runs regardless\n";}public class Example { public static double divide(int a, int b) { if (b == 0) { throw new IllegalArgumentException("Division by zero"); } return (double) a / b; }
public static void main(String[] args) { try { double result = divide(10, 0); System.out.println("Result: " + result); } catch (IllegalArgumentException e) { System.out.println("Error: " + e.getMessage()); } finally { System.out.println("Cleanup code runs regardless"); } }}:::
Expected Result:
Error: Division by zeroCleanup code runs regardlessWhy It Works:
PHP’s exception handling mirrors Java’s try-catch-finally structure. When divide() throws an InvalidArgumentException, execution jumps to the matching catch block. The finally block always executes, regardless of whether an exception was thrown or caught. This ensures cleanup code runs even if errors occur.
Exception Methods
Section titled “Exception Methods”<?php
declare(strict_types=1);
try { throw new Exception("Something went wrong", 500);} catch (Exception $e) { echo "Message: " . $e->getMessage() . "\n"; // "Something went wrong" echo "Code: " . $e->getCode() . "\n"; // 500 echo "File: " . $e->getFile() . "\n"; // Current file path echo "Line: " . $e->getLine() . "\n"; // Line number echo "Trace:\n" . $e->getTraceAsString() . "\n"; // Stack trace}Expected Result:
Message: Something went wrongCode: 500File: /path/to/exception-methods.phpLine: 120Trace:#0 /path/to/exception-methods.php(120): Exception->__construct('Something went wrong', 500)...Why It Works:
PHP exceptions provide several methods to inspect error details. getMessage() returns the exception message, getCode() returns the error code, getFile() and getLine() show where the exception was thrown, and getTraceAsString() provides a formatted stack trace. These methods help debug issues by providing context about where and why exceptions occurred.
Section 2: Exception Hierarchy
Section titled “Section 2: Exception Hierarchy”Understand PHP’s exception hierarchy and how it compares to Java.
Built-in Exception Classes
Section titled “Built-in Exception Classes”<?php
declare(strict_types=1);
// Exception hierarchy in PHP// Throwable (interface)// ├── Exception// │ ├── LogicException// │ │ ├── BadFunctionCallException// │ │ ├── BadMethodCallException// │ │ ├── DomainException// │ │ ├── InvalidArgumentException// │ │ ├── LengthException// │ │ └── OutOfRangeException// │ └── RuntimeException// │ ├── OutOfBoundsException// │ ├── OverflowException// │ ├── RangeException// │ ├── UnderflowException// │ └── UnexpectedValueException// └── Error// ├── ArithmeticError// ├── AssertionError// ├── ParseError// └── TypeError
// Catching specific exceptionstry { $array = [1, 2, 3]; if (!isset($array[5])) { throw new OutOfBoundsException("Index out of bounds"); }} catch (OutOfBoundsException $e) { echo "Out of bounds: {$e->getMessage()}\n";} catch (RuntimeException $e) { echo "Runtime error: {$e->getMessage()}\n";} catch (Exception $e) { echo "General error: {$e->getMessage()}\n";}Expected Result:
Out of bounds: Index out of boundsWhy It Works:
PHP’s exception hierarchy allows catching specific exception types. When multiple catch blocks are present, PHP matches exceptions from most specific to least specific. Here, OutOfBoundsException is caught first because it’s the exact type thrown. If it weren’t present, the RuntimeException catch would handle it (since OutOfBoundsException extends RuntimeException), and if that weren’t present, the generic Exception catch would handle it.
Multiple Catch Blocks
Section titled “Multiple Catch Blocks”<?php
declare(strict_types=1);
// PHP 7.1+: Multiple exception types in one catchtry { // Some operation throw new InvalidArgumentException("Bad argument");} catch (InvalidArgumentException | DomainException $e) { echo "Input error: {$e->getMessage()}\n";} catch (RuntimeException $e) { echo "Runtime error: {$e->getMessage()}\n";}Expected Result:
Input error: Bad argumentWhy It Works:
PHP 7.1+ allows catching multiple exception types in a single catch block using the pipe (|) operator. This reduces code duplication when the same handling logic applies to multiple exception types. The catch block matches if the thrown exception is an instance of any of the specified types.
Section 3: Custom Exceptions
Section titled “Section 3: Custom Exceptions”Create custom exception classes for specific error conditions.
Custom Exception Classes
Section titled “Custom Exception Classes”::: code-group
<?php
declare(strict_types=1);
namespace App\Exceptions;
use Exception;
class UserNotFoundException extends Exception{ public function __construct(int $userId) { parent::__construct("User with ID {$userId} not found", 404); }}
class ValidationException extends Exception{ public function __construct( string $message, private array $errors = [] ) { parent::__construct($message, 422); }
public function getErrors(): array { return $this->errors; }}
class DatabaseException extends Exception{ public function __construct( string $message, private string $query = '' ) { parent::__construct($message, 500); }
public function getQuery(): string { return $this->query; }}
// Usagefunction findUser(int $id): array{ // Simulate database query if ($id <= 0) { throw new ValidationException( "Invalid user ID", ['id' => 'Must be positive'] ); }
if ($id > 1000) { throw new UserNotFoundException($id); }
return ['id' => $id, 'name' => 'User ' . $id];}
try { $user = findUser(1001);} catch (UserNotFoundException $e) { echo "Not found: {$e->getMessage()}\n";} catch (ValidationException $e) { echo "Validation failed: {$e->getMessage()}\n"; print_r($e->getErrors());}Expected Result:
Not found: User with ID 1001 not foundWhy It Works:
Custom exceptions allow you to create domain-specific error types with additional context. UserNotFoundException automatically includes the user ID in its message, while ValidationException stores validation errors in an array accessible via getErrors(). This makes error handling more expressive and provides richer error information to callers.
package com.example.exceptions;
public class UserNotFoundException extends Exception { private int userId;
public UserNotFoundException(int userId) { super("User with ID " + userId + " not found"); this.userId = userId; }
public int getUserId() { return userId; }}
// ValidationException.javapackage com.example.exceptions;
import java.util.Map;
public class ValidationException extends Exception { private Map<String, String> errors;
public ValidationException(String message, Map<String, String> errors) { super(message); this.errors = errors; }
public Map<String, String> getErrors() { return errors; }}
// Usagepublic User findUser(int id) throws UserNotFoundException, ValidationException { if (id <= 0) { throw new ValidationException( "Invalid user ID", Map.of("id", "Must be positive") ); }
if (id > 1000) { throw new UserNotFoundException(id); }
return new User(id, "User " + id);}:::
Section 4: Error vs Exception
Section titled “Section 4: Error vs Exception”Understand the difference between PHP errors and exceptions.
PHP 7+ Error Handling
Section titled “PHP 7+ Error Handling”<?php
declare(strict_types=1);
// PHP 7+ converts most errors to exceptions
// TypeError (PHP 7+)try { function requiresInt(int $value): void { echo "Value: $value\n"; }
requiresInt("string"); // TypeError thrown} catch (TypeError $e) { echo "Type error: {$e->getMessage()}\n";}
// DivisionByZeroError (PHP 7+)try { $result = 10 % 0; // DivisionByZeroError} catch (DivisionByZeroError $e) { echo "Division error: {$e->getMessage()}\n";}
// Catching both Error and Exceptiontry { // Some code} catch (Throwable $e) { // Catches both Error and Exception echo "Something went wrong: {$e->getMessage()}\n";}Expected Result:
Type error: Argument #1 ($value) must be of type int, string givenDivision error: Division by zeroSomething went wrong: [error message]Why It Works:
PHP 7+ converts most errors to exceptions. TypeError is thrown when type declarations are violated, DivisionByZeroError is thrown for division by zero operations. The Throwable interface is the base for both Exception and Error, allowing you to catch both types in a single catch block. This unified error handling makes PHP’s error system more consistent and easier to work with.
Error Levels (Legacy)
Section titled “Error Levels (Legacy)”<?php
// Old PHP error levels (avoid in modern code)// E_ERROR, E_WARNING, E_NOTICE, E_DEPRECATED
// Set error reporting (use exceptions instead)error_reporting(E_ALL);ini_set('display_errors', '1');
// Custom error handler (converts errors to exceptions)set_error_handler(function($severity, $message, $file, $line) { throw new ErrorException($message, 0, $severity, $file, $line);});
// Now warnings become exceptionstry { $file = file_get_contents('/nonexistent/file');} catch (ErrorException $e) { echo "File error: {$e->getMessage()}\n";}Expected Result:
File error: file_get_contents(/nonexistent/file): Failed to open stream: No such file or directoryWhy It Works:
Legacy PHP error handling used error levels (E_ERROR, E_WARNING, etc.) instead of exceptions. Modern PHP code should use exceptions, but you can convert legacy errors to exceptions using set_error_handler(). This allows you to handle all errors uniformly using try-catch blocks, making error handling more consistent across your application.
Section 5: Finally Block
Section titled “Section 5: Finally Block”Master the finally block for cleanup code.
Finally Block Behavior
Section titled “Finally Block Behavior”<?php
declare(strict_types=1);
function processFile(string $filename): void{ $handle = null;
try { $handle = fopen($filename, 'r');
if ($handle === false) { throw new RuntimeException("Cannot open file"); }
// Process file $content = fread($handle, 1024);
if ($content === false) { throw new RuntimeException("Cannot read file"); }
echo "Content: $content\n";
} catch (RuntimeException $e) { echo "Error: {$e->getMessage()}\n";
} finally { // Cleanup: Always runs if ($handle !== null && is_resource($handle)) { fclose($handle); echo "File closed\n"; } }}
// Finally runs even if exception is thrownprocessFile('/nonexistent.txt');Expected Result:
Error: Cannot open fileFile closedWhy It Works:
The finally block always executes, even when exceptions are thrown. This makes it perfect for cleanup operations like closing file handles, database connections, or releasing resources. Even though the file couldn’t be opened and an exception was thrown, the finally block ensures the file handle is checked and closed if it was opened, preventing resource leaks.
Finally with Return
Section titled “Finally with Return”<?php
declare(strict_types=1);
function testFinally(): string{ try { return "try block"; } finally { // Finally runs BEFORE the return echo "Finally block executed\n"; }}
$result = testFinally();// Output:// Finally block executed// Then returns "try block"Expected Result:
Finally block executedWhy It Works:
The finally block executes before the return statement completes. This ensures cleanup code runs even when a function returns early. The return value is preserved, but the finally block’s code executes first. This behavior is consistent with Java’s finally block and ensures predictable cleanup regardless of how the function exits.
Section 6: Exception Best Practices
Section titled “Section 6: Exception Best Practices”Learn exception handling best practices.
Do’s and Don’ts
Section titled “Do’s and Don’ts”<?php
declare(strict_types=1);
// ✅ DO: Be specific with exceptionsclass UserService{ public function findUser(int $id): User { if ($id <= 0) { throw new InvalidArgumentException("User ID must be positive"); }
$user = $this->repository->find($id);
if ($user === null) { throw new UserNotFoundException($id); }
return $user; }}
// ✅ DO: Catch specific exceptions firsttry { $user = $userService->findUser(-1);} catch (InvalidArgumentException $e) { // Handle validation errors return $this->badRequest($e->getMessage());} catch (UserNotFoundException $e) { // Handle not found return $this->notFound($e->getMessage());} catch (Exception $e) { // Handle unexpected errors $this->log($e); return $this->serverError("Internal error");}
// ❌ DON'T: Catch generic Exception firsttry { $user = $userService->findUser($id);} catch (Exception $e) { // Too broad // This catches EVERYTHING}
// ❌ DON'T: Swallow exceptions silentlytry { riskyOperation();} catch (Exception $e) { // Empty catch - bad practice!}
// ✅ DO: Always log or handletry { riskyOperation();} catch (Exception $e) { $logger->error($e->getMessage(), ['exception' => $e]); throw $e; // Re-throw if can't handle}
// ✅ DO: Use meaningful exception messagesthrow new InvalidArgumentException( "Email must be a valid format, got: {$email}");
// ❌ DON'T: Use generic messagesthrow new Exception("Error"); // Not helpful!Exception Chaining
Section titled “Exception Chaining”<?php
declare(strict_types=1);
try { try { throw new DatabaseException("Connection failed"); } catch (DatabaseException $e) { // Wrap in higher-level exception, preserving original throw new ServiceException( "User service unavailable", 0, $e // Previous exception ); }} catch (ServiceException $e) { echo "Service error: {$e->getMessage()}\n"; echo "Caused by: {$e->getPrevious()->getMessage()}\n";
// Full exception chain $current = $e; while ($current !== null) { echo "- {$current->getMessage()}\n"; $current = $current->getPrevious(); }}Section 7: Practical Example - API Error Handling
Section titled “Section 7: Practical Example - API Error Handling”Build a robust error handling system for an API.
<?php
declare(strict_types=1);
namespace App;
// Custom exceptionsclass ApiException extends \Exception {}class ValidationException extends ApiException {}class NotFoundException extends ApiException {}class UnauthorizedException extends ApiException {}
// API Response handlerclass ApiResponse{ public static function success(mixed $data, int $status = 200): void { http_response_code($status); header('Content-Type: application/json'); echo json_encode([ 'success' => true, 'data' => $data ]); }
public static function error(string $message, int $status = 500, array $details = []): void { http_response_code($status); header('Content-Type: application/json'); echo json_encode([ 'success' => false, 'error' => [ 'message' => $message, 'details' => $details ] ]); }}
// Global exception handlerset_exception_handler(function(\Throwable $e) { // Log the exception error_log($e->getMessage());
// Return appropriate response based on exception type match (true) { $e instanceof ValidationException => ApiResponse::error( $e->getMessage(), 422, method_exists($e, 'getErrors') ? $e->getErrors() : [] ), $e instanceof NotFoundException => ApiResponse::error( $e->getMessage(), 404 ), $e instanceof UnauthorizedException => ApiResponse::error( $e->getMessage(), 401 ), default => ApiResponse::error( 'Internal server error', 500 ) };});
// Controllerclass UserController{ public function __construct( private UserService $userService ) {}
public function show(int $id): void { $user = $this->userService->findUser($id); ApiResponse::success($user); }
public function store(array $data): void { // Validate if (empty($data['email'])) { throw new ValidationException( 'Validation failed', ['email' => 'Email is required'] ); }
$user = $this->userService->createUser($data); ApiResponse::success($user, 201); }}Section 8: Result Objects & Railway Oriented Programming
Section titled “Section 8: Result Objects & Railway Oriented Programming”Learn modern alternatives to exceptions for expected failures.
The Problem with Exceptions for Control Flow
Section titled “The Problem with Exceptions for Control Flow”<?php
declare(strict_types=1);
// ❌ Using exceptions for expected failures (expensive)class UserService{ public function authenticate(string $email, string $password): User { $user = $this->findByEmail($email);
if ($user === null) { throw new UserNotFoundException(); // Expected case! }
if (!$this->verifyPassword($user, $password)) { throw new InvalidPasswordException(); // Expected case! }
return $user; }}
// Caller must catch exceptions for normal flowtry { $user = $userService->authenticate($email, $password); // Success path} catch (UserNotFoundException $e) { // Expected failure - wrong email} catch (InvalidPasswordException $e) { // Expected failure - wrong password}Result Object Pattern
Section titled “Result Object Pattern”::: code-group
<?php
declare(strict_types=1);
namespace App\Support;
/** * Result object for operations that can fail * * @template T */final class Result{ private function __construct( private readonly bool $success, private readonly mixed $value, private readonly ?string $error ) {}
/** * @template U * @param U $value * @return Result<U> */ public static function ok(mixed $value): self { return new self(true, $value, null); }
/** * @template U * @param string $error * @return Result<U> */ public static function fail(string $error): self { return new self(false, null, $error); }
public function isSuccess(): bool { return $this->success; }
public function isFailure(): bool { return !$this->success; }
/** * @return T */ public function getValue(): mixed { if ($this->isFailure()) { throw new \LogicException("Cannot get value from failed result"); } return $this->value; }
public function getError(): string { if ($this->isSuccess()) { throw new \LogicException("Cannot get error from successful result"); } return $this->error; }
/** * Get value or default if failed * @return T */ public function getOrDefault(mixed $default): mixed { return $this->isSuccess() ? $this->value : $default; }
/** * Map the success value to another value * @template U * @param callable(T): U $mapper * @return Result<U> */ public function map(callable $mapper): self { if ($this->isFailure()) { return self::fail($this->error); }
return self::ok($mapper($this->value)); }
/** * Chain operations that return Results * @template U * @param callable(T): Result<U> $mapper * @return Result<U> */ public function flatMap(callable $mapper): self { if ($this->isFailure()) { return self::fail($this->error); }
return $mapper($this->value); }}<?php
declare(strict_types=1);
use App\Support\Result;
class UserService{ /** * @return Result<User> */ public function authenticate(string $email, string $password): Result { $user = $this->findByEmail($email);
if ($user === null) { return Result::fail("User not found"); }
if (!$this->verifyPassword($user, $password)) { return Result::fail("Invalid password"); }
return Result::ok($user); }
/** * @return Result<User> */ public function register(array $data): Result { // Validate if (empty($data['email'])) { return Result::fail("Email is required"); }
if ($this->emailExists($data['email'])) { return Result::fail("Email already registered"); }
$user = new User($data); $this->repository->save($user);
return Result::ok($user); }}
// Usage: Explicit success/failure handling$result = $userService->authenticate($email, $password);
if ($result->isSuccess()) { $user = $result->getValue(); session_start(); $_SESSION['user_id'] = $user->id; redirect('/dashboard');} else { $error = $result->getError(); showError($error);}
// Chaining operations$result = $userService->register($data) ->map(fn(User $user) => $this->sendWelcomeEmail($user)) ->map(fn(User $user) => $this->createDefaultSettings($user)) ->flatMap(fn(User $user) => $this->authenticate($user->email, $data['password']));
if ($result->isSuccess()) { echo "Registration complete!\n";}import java.util.Optional;
public class UserService { // Java 8+ Optional for nullable values public Optional<User> findByEmail(String email) { User user = repository.findByEmail(email); return Optional.ofNullable(user); }
// Custom Result type (similar pattern) public Result<User> authenticate(String email, String password) { return findByEmail(email) .map(user -> verifyPassword(user, password) ? Result.ok(user) : Result.fail("Invalid password")) .orElse(Result.fail("User not found")); }
// Usage Result<User> result = userService.authenticate(email, password);
if (result.isSuccess()) { User user = result.getValue(); // Success handling } else { String error = result.getError(); // Error handling }}:::
When to Use Result vs Exception
Section titled “When to Use Result vs Exception”| Scenario | Use Result Object | Use Exception |
|---|---|---|
| Expected failures | ✅ Login failure, validation errors | ❌ Too expensive |
| Business logic errors | ✅ Insufficient funds, out of stock | ❌ Not exceptional |
| Unexpected errors | ❌ Use exceptions | ✅ Database connection failure |
| Performance critical | ✅ Faster (no stack trace) | ❌ Expensive to throw |
| External I/O errors | Maybe (depends) | ✅ File not found, network error |
| Programming errors | ❌ Use exceptions | ✅ Null pointer, type error |
::: tip Best Practice
- Result objects: For expected failures (validation, business rules)
- Exceptions: For unexpected errors (system failures, programming errors)
- Null/Boolean: For simple yes/no cases with no error context needed :::
Section 9: Structured Logging & Error Context
Section titled “Section 9: Structured Logging & Error Context”Learn to log exceptions with rich context for debugging.
Exception Context
Section titled “Exception Context”<?php
declare(strict_types=1);
namespace App\Exceptions;
use Throwable;
class EnrichedException extends \Exception{ private array $context = [];
public function __construct( string $message, int $code = 0, ?Throwable $previous = null ) { parent::__construct($message, $code, $previous);
// Automatically capture context $this->context['timestamp'] = date('c'); $this->context['memory_usage'] = memory_get_usage(true); $this->context['request_id'] = $_SERVER['HTTP_X_REQUEST_ID'] ?? uniqid(); }
public function setContext(array $context): self { $this->context = array_merge($this->context, $context); return $this; }
public function getContext(): array { return $this->context; }
public function toArray(): array { return [ 'message' => $this->getMessage(), 'code' => $this->getCode(), 'file' => $this->getFile(), 'line' => $this->getLine(), 'context' => $this->context, 'trace' => $this->getTraceAsString(), 'previous' => $this->getPrevious()?->getMessage(), ]; }}
// Usage with contextclass DatabaseException extends EnrichedException {}
try { $result = $pdo->query($sql);} catch (PDOException $e) { throw (new DatabaseException("Query failed", 0, $e)) ->setContext([ 'query' => $sql, 'bindings' => $bindings, 'connection' => $connectionName, 'duration_ms' => $duration, ]);}Structured Logging
Section titled “Structured Logging”::: code-group
<?php
declare(strict_types=1);
namespace App\Logging;
interface Logger{ public function emergency(string $message, array $context = []): void; public function alert(string $message, array $context = []): void; public function critical(string $message, array $context = []): void; public function error(string $message, array $context = []): void; public function warning(string $message, array $context = []): void; public function notice(string $message, array $context = []): void; public function info(string $message, array $context = []): void; public function debug(string $message, array $context = []): void;}
class JsonLogger implements Logger{ public function __construct( private string $logPath ) {}
public function error(string $message, array $context = []): void { $this->log('ERROR', $message, $context); }
private function log(string $level, string $message, array $context): void { $entry = [ 'timestamp' => date('c'), 'level' => $level, 'message' => $message, 'context' => $context, 'request_id' => $_SERVER['HTTP_X_REQUEST_ID'] ?? null, 'user_id' => $_SESSION['user_id'] ?? null, 'ip' => $_SERVER['REMOTE_ADDR'] ?? null, 'url' => $_SERVER['REQUEST_URI'] ?? null, 'method' => $_SERVER['REQUEST_METHOD'] ?? null, ];
// Write as JSON line file_put_contents( $this->logPath, json_encode($entry) . "\n", FILE_APPEND | LOCK_EX ); }}<?php
declare(strict_types=1);
use App\Logging\Logger;
class ExceptionHandler{ public function __construct( private Logger $logger, private bool $debug = false ) {}
public function handle(\Throwable $e): void { // Log with full context $this->logger->error($e->getMessage(), [ 'exception' => get_class($e), 'code' => $e->getCode(), 'file' => $e->getFile(), 'line' => $e->getLine(), 'trace' => $this->formatTrace($e), 'previous' => $this->getPreviousChain($e), 'context' => method_exists($e, 'getContext') ? $e->getContext() : [], ]);
// Return appropriate response if ($this->debug) { $this->renderDebugError($e); } else { $this->renderProductionError($e); } }
private function formatTrace(\Throwable $e): array { return array_map(function($trace) { return [ 'file' => $trace['file'] ?? 'unknown', 'line' => $trace['line'] ?? 0, 'function' => $trace['function'] ?? 'unknown', 'class' => $trace['class'] ?? null, ]; }, $e->getTrace()); }
private function getPreviousChain(\Throwable $e): array { $chain = []; $current = $e->getPrevious();
while ($current !== null) { $chain[] = [ 'exception' => get_class($current), 'message' => $current->getMessage(), 'code' => $current->getCode(), ]; $current = $current->getPrevious(); }
return $chain; }
private function renderProductionError(\Throwable $e): void { http_response_code(500); header('Content-Type: application/json'); echo json_encode([ 'error' => 'Internal server error', 'reference' => $_SERVER['HTTP_X_REQUEST_ID'] ?? uniqid(), ]); }
private function renderDebugError(\Throwable $e): void { http_response_code(500); header('Content-Type: application/json'); echo json_encode([ 'error' => $e->getMessage(), 'exception' => get_class($e), 'file' => $e->getFile(), 'line' => $e->getLine(), 'trace' => $e->getTrace(), ], JSON_PRETTY_PRINT); }}
// Register handler$logger = new JsonLogger(__DIR__ . '/logs/app.log');$handler = new ExceptionHandler($logger, debug: true);
set_exception_handler([$handler, 'handle']);:::
Log Aggregation & Search
Section titled “Log Aggregation & Search”<?php
// Structured logs can be easily searched and analyzed
// Example log entry:// {"timestamp":"2025-01-15T10:30:45+00:00","level":"ERROR","message":"Database connection failed","context":{"host":"localhost","port":3306,"database":"myapp","duration_ms":5000},"request_id":"abc123","user_id":42}
// Search logs with jq:// cat logs/app.log | jq 'select(.level == "ERROR")'// cat logs/app.log | jq 'select(.context.database == "myapp")'// cat logs/app.log | jq 'select(.user_id == 42)'
// Or use log aggregation services:// - ELK Stack (Elasticsearch, Logstash, Kibana)// - Splunk// - Datadog// - New RelicSection 10: Performance Considerations
Section titled “Section 10: Performance Considerations”Understand the performance impact of exceptions and when to avoid them.
Exception Cost
Section titled “Exception Cost”<?php
declare(strict_types=1);
// Benchmark: Exception vs Return Value
function testExceptions(int $iterations): float{ $start = microtime(true);
for ($i = 0; $i < $iterations; $i++) { try { if ($i % 2 === 0) { throw new Exception("Error"); } } catch (Exception $e) { // Handle } }
return microtime(true) - $start;}
function testReturnValues(int $iterations): float{ $start = microtime(true);
for ($i = 0; $i < $iterations; $i++) { $result = ($i % 2 === 0) ? null : $i; if ($result === null) { // Handle } }
return microtime(true) - $start;}
$iterations = 10000;
$exceptionTime = testExceptions($iterations);$returnTime = testReturnValues($iterations);
echo "Exceptions: {$exceptionTime}s\n";echo "Return values: {$returnTime}s\n";echo "Exceptions are " . round($exceptionTime / $returnTime, 2) . "x slower\n";
// Typical results:// Exceptions: 0.25s// Return values: 0.001s// Exceptions are 250x slowerBest Practices for Performance
Section titled “Best Practices for Performance”::: warning Performance Guidelines
1. Don’t use exceptions for control flow
// ❌ BAD: Exception in loopfor ($i = 0; $i < 1000000; $i++) { try { $result = divide($a, $b); } catch (DivisionByZeroException $e) { $result = 0; }}
// ✅ GOOD: Check before operationfor ($i = 0; $i < 1000000; $i++) { $result = ($b !== 0) ? $a / $b : 0;}2. Use early returns instead of exceptions for validation
// ❌ BAD: Throw exception for expected validationfunction processUser(array $data): User{ if (empty($data['email'])) { throw new ValidationException("Email required"); } // ... more validation}
// ✅ GOOD: Return Result objectfunction processUser(array $data): Result{ if (empty($data['email'])) { return Result::fail("Email required"); } // ... more validation return Result::ok($user);}3. Catch exceptions at the right level
// ❌ BAD: Catch-and-rethrow in every methodfunction a() { try { b(); } catch (Exception $e) { throw $e; // Unnecessary }}
function b() { try { c(); } catch (Exception $e) { throw $e; // Unnecessary }}
// ✅ GOOD: Let exceptions bubble upfunction a() { b(); // Exception propagates naturally}
function b() { c(); // Exception propagates naturally}
// Only catch at boundarytry { a();} catch (Exception $e) { handleError($e);}:::
When Exceptions Are OK
Section titled “When Exceptions Are OK”<?php
declare(strict_types=1);
// ✅ Exceptions are appropriate for:
// 1. Truly exceptional conditionstry { $pdo = new PDO($dsn, $user, $pass);} catch (PDOException $e) { // Database unavailable is exceptional die("Database connection failed");}
// 2. External I/O failurestry { $content = file_get_contents($url);} catch (Exception $e) { // Network/file errors are exceptional logError($e);}
// 3. Programming errorsfunction divide(int $a, int $b): float{ if ($b === 0) { // Programming error - should never happen throw new LogicException("Division by zero"); } return $a / $b;}
// 4. Transaction failurestry { $pdo->beginTransaction(); $pdo->exec($sql1); $pdo->exec($sql2); $pdo->commit();} catch (PDOException $e) { $pdo->rollBack(); throw $e;}Section 11: Testing Exception Handling
Section titled “Section 11: Testing Exception Handling”Learn to test exception scenarios effectively.
Testing Exceptions
Section titled “Testing Exceptions”::: code-group
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
class UserServiceTest extends TestCase{ private UserService $service;
protected function setUp(): void { $this->service = new UserService(); }
/** * Test that exception is thrown */ public function testFindUserThrowsExceptionForInvalidId(): void { // Expect specific exception $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage("User ID must be positive"); $this->expectExceptionCode(400);
$this->service->findUser(-1); }
/** * Test exception message contains specific text */ public function testDatabaseExceptionContainsQuery(): void { $this->expectException(DatabaseException::class); $this->expectExceptionMessageMatches('/SELECT.*users/');
$this->service->executeQuery("SELECT * FROM users WHERE invalid"); }
/** * Test exception with try-catch (more flexible) */ public function testValidationExceptionIncludesErrors(): void { try { $this->service->createUser(['name' => '']); // Missing email $this->fail('Expected ValidationException was not thrown'); } catch (ValidationException $e) { // Assert exception properties $this->assertEquals('Validation failed', $e->getMessage()); $this->assertArrayHasKey('email', $e->getErrors()); $this->assertEquals('Email is required', $e->getErrors()['email']); } }
/** * Test no exception is thrown */ public function testSuccessfulOperationDoesNotThrow(): void { // This test passes if no exception is thrown $user = $this->service->findUser(1); $this->assertInstanceOf(User::class, $user); }
/** * Test exception chaining */ public function testExceptionChainPreservesCause(): void { try { $this->service->complexOperation(); $this->fail('Expected exception'); } catch (ServiceException $e) { // Check outer exception $this->assertEquals('Service unavailable', $e->getMessage());
// Check inner exception $previous = $e->getPrevious(); $this->assertInstanceOf(DatabaseException::class, $previous); $this->assertEquals('Connection failed', $previous->getMessage()); } }}<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;use App\Support\Result;
class UserServiceWithResultTest extends TestCase{ private UserService $service;
protected function setUp(): void { $this->service = new UserService(); }
public function testAuthenticateReturnsSuccessForValidCredentials(): void { $result = $this->service->authenticate('user@example.com', 'password');
$this->assertTrue($result->isSuccess()); $this->assertFalse($result->isFailure()); $this->assertInstanceOf(User::class, $result->getValue()); }
public function testAuthenticateReturnsFailureForInvalidEmail(): void { $result = $this->service->authenticate('invalid@example.com', 'password');
$this->assertFalse($result->isSuccess()); $this->assertTrue($result->isFailure()); $this->assertEquals('User not found', $result->getError()); }
public function testAuthenticateReturnsFailureForInvalidPassword(): void { $result = $this->service->authenticate('user@example.com', 'wrong');
$this->assertTrue($result->isFailure()); $this->assertEquals('Invalid password', $result->getError()); }
public function testResultChaining(): void { $result = $this->service->register([ 'email' => 'new@example.com', 'password' => 'password123' ]) ->map(fn($user) => $user->id) ->map(fn($id) => $id * 2);
$this->assertTrue($result->isSuccess()); $this->assertIsInt($result->getValue()); }}import org.junit.jupiter.api.Test;import static org.junit.jupiter.api.Assertions.*;
class UserServiceTest { private UserService service = new UserService();
@Test void testFindUserThrowsExceptionForInvalidId() { // JUnit 5 - assertThrows InvalidArgumentException exception = assertThrows( InvalidArgumentException.class, () -> service.findUser(-1) );
assertEquals("User ID must be positive", exception.getMessage()); }
@Test void testValidationExceptionIncludesErrors() { ValidationException exception = assertThrows( ValidationException.class, () -> service.createUser(Map.of("name", "")) );
assertTrue(exception.getErrors().containsKey("email")); assertEquals("Email is required", exception.getErrors().get("email")); }
@Test void testSuccessfulOperationDoesNotThrow() { // No exception expected assertDoesNotThrow(() -> { User user = service.findUser(1); assertNotNull(user); }); }}:::
Mock Exceptions in Tests
Section titled “Mock Exceptions in Tests”<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
class PaymentServiceTest extends TestCase{ public function testHandlesGatewayFailureGracefully(): void { // Create mock that throws exception $gateway = $this->createMock(PaymentGateway::class); $gateway->method('charge') ->willThrowException(new GatewayException('Connection timeout'));
$service = new PaymentService($gateway);
// Expect service to handle exception $result = $service->processPayment(100.00);
$this->assertFalse($result->isSuccess()); $this->assertStringContainsString('timeout', $result->getError()); }
public function testRetriesOnTransientFailure(): void { $gateway = $this->createMock(PaymentGateway::class);
// Throw exception first 2 times, then succeed $gateway->method('charge') ->will($this->onConsecutiveCalls( $this->throwException(new TransientException('Timeout')), $this->throwException(new TransientException('Timeout')), $this->returnValue(['transaction_id' => '12345']) ));
$service = new PaymentService($gateway, maxRetries: 3); $result = $service->processPayment(100.00);
$this->assertTrue($result->isSuccess()); }}Section 12: PHP 8+ Error Handling Features
Section titled “Section 12: PHP 8+ Error Handling Features”Leverage modern PHP 8+ features for better error handling.
Never Return Type
Section titled “Never Return Type”<?php
declare(strict_types=1);
// PHP 8.1+: never type indicates function never returns normallyfunction abort(string $message, int $code = 500): never{ http_response_code($code); echo json_encode(['error' => $message]); exit();}
function failFast(string $error): never{ throw new \RuntimeException($error);}
// Type system knows these never returnfunction processRequest(): void{ if (!authenticated()) { abort('Unauthorized', 401); // Compiler knows this exits // No code here is reachable }
if (!validated()) { failFast('Invalid input'); // Compiler knows this throws // No code here is reachable }
// Continue normal flow echo "Processing...\n";}Union Types for Error Handling
Section titled “Union Types for Error Handling”<?php
declare(strict_types=1);
// PHP 8.0+: Union types for nullable or error results
class UserService{ /** * Returns User or null (using union type) */ public function findUser(int $id): User|null { return $this->repository->find($id); }
/** * Returns User or error message */ public function authenticate(string $email, string $password): User|string { $user = $this->findByEmail($email);
if ($user === null) { return "User not found"; }
if (!$this->verifyPassword($user, $password)) { return "Invalid password"; }
return $user; }}
// Usage with pattern matching$result = $userService->authenticate($email, $password);
match (true) { $result instanceof User => redirectToDashboard($result), is_string($result) => showError($result),};
// Or with type checkingif ($result instanceof User) { // Success path $_SESSION['user'] = $result;} else { // Error path $errorMessage = $result; showError($errorMessage);}Intersection Types
Section titled “Intersection Types”<?php
declare(strict_types=1);
// PHP 8.1+: Intersection types
interface Loggable{ public function toLog(): array;}
interface Notifiable{ public function getNotificationChannels(): array;}
// Exception that is both Loggable and Notifiablefunction handleSpecialException(Loggable&Notifiable $exception): void{ // Can call methods from both interfaces $logData = $exception->toLog(); $channels = $exception->getNotificationChannels();
// Log and notify logger()->error($logData); notifier()->send($channels, $exception);}
class CriticalException extends \Exception implements Loggable, Notifiable{ public function toLog(): array { return [ 'message' => $this->getMessage(), 'severity' => 'critical', ]; }
public function getNotificationChannels(): array { return ['slack', 'email', 'pagerduty']; }}Match Expression for Error Handling
Section titled “Match Expression for Error Handling”<?php
declare(strict_types=1);
// PHP 8.0+: Match expression (better than switch for exceptions)
function handleError(\Throwable $e): never{ $response = match (true) { $e instanceof ValidationException => [ 'status' => 422, 'message' => $e->getMessage(), 'errors' => $e->getErrors(), ], $e instanceof NotFoundException => [ 'status' => 404, 'message' => $e->getMessage(), ], $e instanceof UnauthorizedException => [ 'status' => 401, 'message' => 'Unauthorized', ], $e instanceof \PDOException => [ 'status' => 500, 'message' => 'Database error', ], default => [ 'status' => 500, 'message' => 'Internal server error', ], };
http_response_code($response['status']); header('Content-Type: application/json'); echo json_encode($response); exit();}
set_exception_handler('handleError');Exercises
Section titled “Exercises”Exercise 1: Custom Exception Classes
Section titled “Exercise 1: Custom Exception Classes”Create a file called custom-exceptions.php and implement:
Requirements:
- Create
PaymentExceptionbase class extendingException - Create
InsufficientFundsExceptionextendingPaymentException - Create
InvalidCardExceptionextendingPaymentException - Create
PaymentProcessorclass withprocessPayment()method - Throw appropriate exceptions based on validation logic
- Include meaningful error messages and error codes
Validation: Test your implementation:
$processor = new PaymentProcessor();
try { $processor->processPayment(-100, '1234');} catch (InsufficientFundsException $e) { echo "Expected: {$e->getMessage()}\n";}
try { $processor->processPayment(100, 'invalid');} catch (InvalidCardException $e) { echo "Expected: {$e->getMessage()}\n";}Exercise 2: Result Object Pattern
Section titled “Exercise 2: Result Object Pattern”Implement a Result class and refactor a service to use it:
Requirements:
- Create a
Resultclass withok()andfail()static methods - Implement
isSuccess(),isFailure(),getValue(),getError()methods - Create
UserServicewithauthenticate()method returningResult<User> - Handle both success and failure cases without exceptions
- Chain operations using
map()andflatMap()methods
Validation: Test your implementation:
$service = new UserService();
$result = $service->authenticate('user@example.com', 'password');
if ($result->isSuccess()) { $user = $result->getValue(); echo "Authenticated: {$user->email}\n";} else { echo "Error: {$result->getError()}\n";}Exercise 3: Global Exception Handler
Section titled “Exercise 3: Global Exception Handler”Create a global exception handler for a web API:
Requirements:
- Create custom exception classes:
ValidationException,NotFoundException,UnauthorizedException - Implement
ApiExceptionHandlerclass - Use
matchexpression to map exceptions to HTTP status codes - Return JSON responses with error details
- Log exceptions with context (request ID, user ID, timestamp)
- Set up the handler using
set_exception_handler()
Validation: Test your implementation:
// Should return 422 JSON responsethrow new ValidationException('Email is required', ['email' => 'Required']);
// Should return 404 JSON responsethrow new NotFoundException('User not found');
// Should return 401 JSON responsethrow new UnauthorizedException('Invalid token');Exercise 4: Exception Chaining
Section titled “Exercise 4: Exception Chaining”Implement exception chaining for a multi-layer application:
Requirements:
- Create
DatabaseException,ServiceException,ControllerException - Implement a
UserServicethat catchesDatabaseExceptionand wraps it inServiceException - Implement a
UserControllerthat catchesServiceExceptionand wraps it inControllerException - Preserve the exception chain using the
$previousparameter - Display the full exception chain when handling errors
Validation: Test your implementation:
try { $controller->getUser(123);} catch (ControllerException $e) { // Should show full chain: ControllerException -> ServiceException -> DatabaseException $current = $e; while ($current !== null) { echo get_class($current) . ": {$current->getMessage()}\n"; $current = $current->getPrevious(); }}Exercise 5: Structured Logging
Section titled “Exercise 5: Structured Logging”Create a structured logging system for exceptions:
Requirements:
- Create
StructuredLoggerclass implementing PSR-3-like interface - Log exceptions as JSON with context (timestamp, level, message, exception class, file, line, trace)
- Include request context (request ID, user ID, IP address, URL, method)
- Create
EnrichedExceptionclass that captures context automatically - Use the logger in exception handler
Validation: Test your implementation:
$logger = new StructuredLogger('logs/app.log');
try { throw new EnrichedException('Database query failed') ->setContext(['query' => 'SELECT * FROM users', 'duration_ms' => 500]);} catch (EnrichedException $e) { $logger->error($e->getMessage(), $e->getContext());}
// Check logs/app.log for JSON entryTroubleshooting
Section titled “Troubleshooting”Error: “Uncaught Exception: …”
Section titled “Error: “Uncaught Exception: …””Symptom: Exception is thrown but not caught, causing a fatal error.
Cause: No try-catch block around code that throws exceptions, or exception type doesn’t match catch block.
Solution:
// ❌ BAD: No exception handlingfunction riskyOperation() { throw new Exception("Error");}riskyOperation(); // Fatal error!
// ✅ GOOD: Proper exception handlingtry { riskyOperation();} catch (Exception $e) { echo "Caught: {$e->getMessage()}\n";}Error: “Catch block order matters”
Section titled “Error: “Catch block order matters””Symptom: More specific exceptions are caught by generic catch blocks.
Cause: Catch blocks are evaluated in order, and generic exceptions catch specific ones.
Solution:
// ❌ BAD: Generic catch firsttry { throw new InvalidArgumentException("Bad input");} catch (Exception $e) { // Catches everything! echo "Generic error";} catch (InvalidArgumentException $e) { // Never reached echo "Invalid argument";}
// ✅ GOOD: Specific catches firsttry { throw new InvalidArgumentException("Bad input");} catch (InvalidArgumentException $e) { // Caught here echo "Invalid argument";} catch (Exception $e) { // Fallback for other exceptions echo "Generic error";}Error: “Finally block not executing”
Section titled “Error: “Finally block not executing””Symptom: Finally block code doesn’t run.
Cause: Fatal errors or script termination (exit(), die()) bypass finally blocks.
Solution:
// ❌ BAD: exit() prevents finally from runningtry { exit("Script ends");} finally { echo "This never runs";}
// ✅ GOOD: Use return or throw insteadtry { return "Script ends";} finally { echo "This runs before return";}Error: “Exception context lost”
Section titled “Error: “Exception context lost””Symptom: Exception thrown but context information is missing.
Cause: Not preserving exception chain or not adding context to exceptions.
Solution:
// ❌ BAD: Context losttry { throw new DatabaseException("Query failed");} catch (DatabaseException $e) { throw new ServiceException("Service unavailable"); // Original exception lost}
// ✅ GOOD: Preserve exception chaintry { throw new DatabaseException("Query failed");} catch (DatabaseException $e) { throw new ServiceException("Service unavailable", 0, $e); // Chain preserved}Error: “Performance issues with exceptions”
Section titled “Error: “Performance issues with exceptions””Symptom: Application is slow, exceptions thrown frequently in loops.
Cause: Using exceptions for control flow in performance-critical code.
Solution:
// ❌ BAD: Exception in loop (expensive)for ($i = 0; $i < 1000000; $i++) { try { if ($i % 2 === 0) { throw new Exception("Even number"); } } catch (Exception $e) { // Handle }}
// ✅ GOOD: Check condition firstfor ($i = 0; $i < 1000000; $i++) { if ($i % 2 === 0) { // Handle even number without exception }}Error: “Exception message not helpful”
Section titled “Error: “Exception message not helpful””Symptom: Exception messages don’t provide enough context for debugging.
Cause: Generic exception messages without context.
Solution:
// ❌ BAD: Generic messagethrow new Exception("Error");
// ✅ GOOD: Descriptive message with contextthrow new ValidationException( "Email validation failed: '{$email}' is not a valid email address", ['email' => $email, 'field' => 'email', 'value' => $email]);Wrap-up Checklist
Section titled “Wrap-up Checklist”Before moving to the next chapter, ensure you can:
- Use try-catch-finally blocks
- Throw and catch exceptions
- Create custom exception classes
- Understand PHP’s exception hierarchy
- Handle multiple exception types
- Use finally for cleanup code
- Implement Result objects for expected failures
- Add rich context to exceptions for debugging
- Set up structured logging
- Understand performance implications of exceptions
- Test exception scenarios with PHPUnit
- Use PHP 8+ error handling features (never type, union types, match)
- Follow exception handling best practices
- Build robust error handling systems
::: tip Completed Part 2! Congratulations! You’ve completed Part 2: Object-Oriented PHP. In Chapter 8: Composer & Dependencies, we’ll begin Part 3: Modern PHP Development, learning about Composer (PHP’s Maven/Gradle equivalent). :::
Further Reading
Section titled “Further Reading”PHP Documentation: