Skip to content

07: Error Handling

Error Handling Hero

Intermediate 60-75 min

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.

::: info Time Estimate ⏱️ 60-75 minutes to complete this chapter :::

What you need:

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

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

Master try-catch-finally blocks in PHP.

::: code-group

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 regardless

Why 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.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.


Understand PHP’s exception hierarchy and how it compares to Java.

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 bounds

Why 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.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 argument

Why 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.


Create custom exception classes for specific error conditions.

::: code-group

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 found

Why 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);
}

:::


Understand the difference between PHP errors and exceptions.

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.

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 directory

Why 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.


Master the finally block for cleanup code.

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 closed

Why 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-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 executed

Why 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.


Learn exception handling best practices.

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.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

Section titled “Section 7: Practical Example - API Error Handling”

Build a robust error handling system for an API.

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

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”
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
}

::: code-group

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);
}
}
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
}
}

:::

ScenarioUse Result ObjectUse 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 errorsMaybe (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.

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,
]);
}

::: code-group

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
);
}
}
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']);

:::

<?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 Relic

Understand the performance impact of exceptions and when to avoid them.

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 slower

::: warning 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-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;
}

Learn to test exception scenarios effectively.

::: code-group

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());
}
}
}
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);
});
}
}

:::

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

Section titled “Section 12: PHP 8+ Error Handling Features”

Leverage modern PHP 8+ features for better error handling.

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-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.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-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');

Create a file called custom-exceptions.php and implement:

Requirements:

  • Create PaymentException base class extending Exception
  • Create InsufficientFundsException extending PaymentException
  • Create InvalidCardException extending PaymentException
  • Create PaymentProcessor class with processPayment() 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";
}

Implement a Result class and refactor a service to use it:

Requirements:

  • Create a Result class with ok() and fail() static methods
  • Implement isSuccess(), isFailure(), getValue(), getError() methods
  • Create UserService with authenticate() method returning Result<User>
  • Handle both success and failure cases without exceptions
  • Chain operations using map() and flatMap() 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";
}

Create a global exception handler for a web API:

Requirements:

  • Create custom exception classes: ValidationException, NotFoundException, UnauthorizedException
  • Implement ApiExceptionHandler class
  • Use match expression 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');

Implement exception chaining for a multi-layer application:

Requirements:

  • Create DatabaseException, ServiceException, ControllerException
  • Implement a UserService that catches DatabaseException and wraps it in ServiceException
  • Implement a UserController that catches ServiceException and wraps it in ControllerException
  • Preserve the exception chain using the $previous parameter
  • 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();
}
}

Create a structured logging system for exceptions:

Requirements:

  • Create StructuredLogger class 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 EnrichedException class 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 entry

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";
}

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";
}

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";
}

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”

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 first
for ($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 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]
);

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). :::


PHP Documentation: