
Chapter 7: Error Handling
Intermediate 60-75 minOverview
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
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
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
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
Goal
Master try-catch-finally blocks in PHP.
Try-Catch-Finally
# filename: exception-basic.php
<?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 zero
Cleanup 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
# filename: exception-methods.php
<?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 wrong
Code: 500
File: /path/to/exception-methods.php
Line: 120
Trace:
#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
Goal
Understand PHP's exception hierarchy and how it compares to Java.
Built-in Exception Classes
# filename: exception-hierarchy.php
<?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 exceptions
try {
$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
# filename: multiple-catch.php
<?php
declare(strict_types=1);
// PHP 7.1+: Multiple exception types in one catch
try {
// 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
Goal
Create custom exception classes for specific error conditions.
Custom Exception Classes
# filename: custom-exceptions.php
<?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;
}
}
// Usage
function 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.
// UserNotFoundException.java
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.java
package 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;
}
}
// Usage
public 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
Goal
Understand the difference between PHP errors and exceptions.
PHP 7+ Error Handling
# filename: error-to-exception.php
<?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 Exception
try {
// 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 given
Division error: Division by zero
Something 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)
# filename: legacy-error-handling.php
<?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 exceptions
try {
$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
Goal
Master the finally block for cleanup code.
Finally Block Behavior
# filename: finally-block.php
<?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 thrown
processFile('/nonexistent.txt');Expected Result:
Error: Cannot open file
File 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
# filename: finally-return.php
<?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
Goal
Learn exception handling best practices.
Do's and Don'ts
# filename: exception-best-practices.php
<?php
declare(strict_types=1);
// ✅ DO: Be specific with exceptions
class 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 first
try {
$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 first
try {
$user = $userService->findUser($id);
} catch (Exception $e) { // Too broad
// This catches EVERYTHING
}
// ❌ DON'T: Swallow exceptions silently
try {
riskyOperation();
} catch (Exception $e) {
// Empty catch - bad practice!
}
// ✅ DO: Always log or handle
try {
riskyOperation();
} catch (Exception $e) {
$logger->error($e->getMessage(), ['exception' => $e]);
throw $e; // Re-throw if can't handle
}
// ✅ DO: Use meaningful exception messages
throw new InvalidArgumentException(
"Email must be a valid format, got: {$email}"
);
// ❌ DON'T: Use generic messages
throw new Exception("Error"); // Not helpful!Exception Chaining
# filename: exception-chaining.php
<?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
Goal
Build a robust error handling system for an API.
# filename: api-error-handling.php
<?php
declare(strict_types=1);
namespace App;
// Custom exceptions
class ApiException extends \Exception {}
class ValidationException extends ApiException {}
class NotFoundException extends ApiException {}
class UnauthorizedException extends ApiException {}
// API Response handler
class 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 handler
set_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
)
};
});
// Controller
class 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
Goal
Learn modern alternatives to exceptions for expected failures.
The Problem with Exceptions for Control Flow
# filename: exception-control-flow-problem.php
<?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 flow
try {
$user = $userService->authenticate($email, $password);
// Success path
} catch (UserNotFoundException $e) {
// Expected failure - wrong email
} catch (InvalidPasswordException $e) {
// Expected failure - wrong password
}Result Object Pattern
# filename: result-class.php
<?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);
}
}# filename: result-usage.php
<?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
| 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 |
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
Goal
Learn to log exceptions with rich context for debugging.
Exception Context
# filename: enriched-exception.php
<?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 context
class 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
# filename: structured-logger.php
<?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
);
}
}# filename: exception-handler-logging.php
<?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
<?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
Goal
Understand the performance impact of exceptions and when to avoid them.
Exception Cost
# filename: exception-performance-benchmark.php
<?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
Performance Guidelines
1. Don't use exceptions for control flow
// ❌ BAD: Exception in loop
for ($i = 0; $i < 1000000; $i++) {
try {
$result = divide($a, $b);
} catch (DivisionByZeroException $e) {
$result = 0;
}
}
// ✅ GOOD: Check before operation
for ($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 validation
function processUser(array $data): User
{
if (empty($data['email'])) {
throw new ValidationException("Email required");
}
// ... more validation
}
// ✅ GOOD: Return Result object
function 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 method
function a() {
try {
b();
} catch (Exception $e) {
throw $e; // Unnecessary
}
}
function b() {
try {
c();
} catch (Exception $e) {
throw $e; // Unnecessary
}
}
// ✅ GOOD: Let exceptions bubble up
function a() {
b(); // Exception propagates naturally
}
function b() {
c(); // Exception propagates naturally
}
// Only catch at boundary
try {
a();
} catch (Exception $e) {
handleError($e);
}When Exceptions Are OK
# filename: when-exceptions-ok.php
<?php
declare(strict_types=1);
// ✅ Exceptions are appropriate for:
// 1. Truly exceptional conditions
try {
$pdo = new PDO($dsn, $user, $pass);
} catch (PDOException $e) {
// Database unavailable is exceptional
die("Database connection failed");
}
// 2. External I/O failures
try {
$content = file_get_contents($url);
} catch (Exception $e) {
// Network/file errors are exceptional
logError($e);
}
// 3. Programming errors
function 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 failures
try {
$pdo->beginTransaction();
$pdo->exec($sql1);
$pdo->exec($sql2);
$pdo->commit();
} catch (PDOException $e) {
$pdo->rollBack();
throw $e;
}Section 11: Testing Exception Handling
Goal
Learn to test exception scenarios effectively.
Testing Exceptions
# filename: UserServiceTest.php
<?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());
}
}
}# filename: UserServiceWithResultTest.php
<?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
# filename: PaymentServiceTest.php
<?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
Goal
Leverage modern PHP 8+ features for better error handling.
Never Return Type
# filename: never-type.php
<?php
declare(strict_types=1);
// PHP 8.1+: never type indicates function never returns normally
function 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 return
function 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
# filename: union-types-error-handling.php
<?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 checking
if ($result instanceof User) {
// Success path
$_SESSION['user'] = $result;
} else {
// Error path
$errorMessage = $result;
showError($errorMessage);
}Intersection Types
# filename: intersection-types.php
<?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 Notifiable
function 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
# filename: match-expression-error-handling.php
<?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
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
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
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 response
throw new ValidationException('Email is required', ['email' => 'Required']);
// Should return 404 JSON response
throw new NotFoundException('User not found');
// Should return 401 JSON response
throw new UnauthorizedException('Invalid token');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
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
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 handling
function riskyOperation() {
throw new Exception("Error");
}
riskyOperation(); // Fatal error!
// ✅ GOOD: Proper exception handling
try {
riskyOperation();
} catch (Exception $e) {
echo "Caught: {$e->getMessage()}\n";
}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 first
try {
throw new InvalidArgumentException("Bad input");
} catch (Exception $e) { // Catches everything!
echo "Generic error";
} catch (InvalidArgumentException $e) { // Never reached
echo "Invalid argument";
}
// ✅ GOOD: Specific catches first
try {
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"
Symptom: Finally block code doesn't run.
Cause: Fatal errors or script termination (exit(), die()) bypass finally blocks.
Solution:
// ❌ BAD: exit() prevents finally from running
try {
exit("Script ends");
} finally {
echo "This never runs";
}
// ✅ GOOD: Use return or throw instead
try {
return "Script ends";
} finally {
echo "This runs before return";
}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 lost
try {
throw new DatabaseException("Query failed");
} catch (DatabaseException $e) {
throw new ServiceException("Service unavailable"); // Original exception lost
}
// ✅ GOOD: Preserve exception chain
try {
throw new DatabaseException("Query failed");
} catch (DatabaseException $e) {
throw new ServiceException("Service unavailable", 0, $e); // Chain preserved
}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 first
for ($i = 0; $i < 1000000; $i++) {
if ($i % 2 === 0) {
// Handle even number without exception
}
}Error: "Exception message not helpful"
Symptom: Exception messages don't provide enough context for debugging.
Cause: Generic exception messages without context.
Solution:
// ❌ BAD: Generic message
throw new Exception("Error");
// ✅ GOOD: Descriptive message with context
throw new ValidationException(
"Email validation failed: '{$email}' is not a valid email address",
['email' => $email, 'field' => 'email', 'value' => $email]
);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
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
PHP Documentation: