PHP PHP

Building Resilient PHP Applications with Exception Hierarchies

Dima Iul 26, 2026

Introduction

Every production PHP application encounters failures. Network calls time out. CSV files arrive malformed. Database connections drop under load. The question is not whether failures will happen — it is whether your code knows the difference between a malformed row that should be skipped, an API timeout that should be retried, and a database failure that should abort the entire operation. Code that catches \Exception everywhere and logs "something went wrong" answers all three the same way: badly.

The cost of undifferentiated error handling is invisible at first. Imports silently skip every row because a type coercion failure matches the same catch block as a network timeout. Operators receive generic alert messages with no actionable information. Post-mortems reveal that a retryable transient failure killed a batch that processed ten thousand records before failing on the last one — and the whole batch was retried from scratch. These are not corner cases; they are the normal failure modes of any system that handles real data from the outside world.

This tutorial builds a CSV import service that reads partner data feeds, validates each row, enriches records via an external API, and writes to a database. Each section introduces one tool from PHP's exception system — the class hierarchy, custom exception types, multi-catch ordering, finally for cleanup, exception chaining, and a top-level handler — and shows exactly what problem each tool solves in the context of the import service.


Background

PHP's exception system is built on a two-branch hierarchy:

  • \Throwable — the root interface implemented by both \Exception and \Error. Catching \Throwable catches everything, including fatal engine errors like calling a method on null.
  • \Exception — the base class for application exceptions. Subclass this for things your code deliberately throws.
  • \Error — the base class for engine-level errors (type errors, parse errors, division by zero). Do not subclass this in application code.
  • \RuntimeException — extends \Exception. For conditions that cannot be detected before execution: a file that exists but cannot be read, a network connection that drops, a database that refuses a write.
  • \LogicException — extends \Exception. For conditions that represent programmer mistakes detectable before execution: passing a negative page size, calling a method before initialization, violating a precondition.

Custom exception classes extend the most specific appropriate base. A MalformedRowException that represents invalid input extends \RuntimeException. An InvalidBatchSizeException that represents a programming mistake extends \LogicException. This structure lets callers catch at the right level of specificity — catching \RuntimeException handles all transient failures without accidentally catching programmer mistakes.

The $previous parameter of \Exception::__construct() enables exception chaining: wrapping a low-level exception inside a higher-level one so that the call stack of both is preserved in logs.


Practical Scenario

A logistics company receives inventory data feeds from a dozen partner warehouses. Each feed is a CSV file with columns for SKU, quantity, warehouse code, and unit cost. A nightly import service reads each file, validates the rows, calls an enrichment API to fetch current commodity pricing, and writes the enriched records to the inventory database.

Failures are not exceptional — they are routine. Partners send malformed CSVs when their export systems have bugs. The enrichment API is a third-party service that occasionally returns 503s under load. The database has maintenance windows that cause brief connection failures. Each failure type demands a different response: skip the malformed row and continue, retry the API call up to three times, abort and alert when the database is unavailable.

The import service processes millions of rows per night across hundreds of files. A single undifferentiated error handler that stops the whole import on any failure would leave the inventory system dark for hours. A handler that silently swallows all errors would process the night's data while hiding the fact that 30% of rows failed validation. The exception hierarchy is the tool that makes these distinctions enforced by the language rather than by developer discipline.


The Problem

The first version catches \Exception everywhere and logs a generic message regardless of what failed.

Create a new file:

touch import.php

Run it using:

php import.php
<?php

function validateRow(array $row): array
{
    if (empty($row['sku']) || !is_numeric($row['quantity']) || $row['quantity'] < 0) {
        throw new \Exception("Row validation failed");
    }
    return $row;
}

function enrichWithPricing(array $row): array
{
    // Simulate intermittent API failure
    if ($row['sku'] === 'SKU-007') {
        throw new \Exception("API error");
    }
    $row['unit_cost'] = 12.50;
    return $row;
}

function writeToDatabase(array $row): void
{
    // Simulate database failure
    if ($row['sku'] === 'SKU-009') {
        throw new \Exception("DB error");
    }
    echo "Stored: {$row['sku']} qty={$row['quantity']}\n";
}

$rows = [
    ['sku' => 'SKU-001', 'quantity' => '100', 'warehouse' => 'WH-A'],
    ['sku' => '',         'quantity' => '50',  'warehouse' => 'WH-B'],
    ['sku' => 'SKU-003', 'quantity' => '-5',  'warehouse' => 'WH-A'],
    ['sku' => 'SKU-007', 'quantity' => '200', 'warehouse' => 'WH-C'],
    ['sku' => 'SKU-009', 'quantity' => '75',  'warehouse' => 'WH-A'],
];

foreach ($rows as $row) {
    try {
        $validated = validateRow($row);
        $enriched  = enrichWithPricing($validated);
        writeToDatabase($enriched);
    } catch (\Exception $e) {
        echo "Error: " . $e->getMessage() . "\n";
    }
}


Stored: SKU-001 qty=100
Error: Row validation failed
Error: Row validation failed
Error: API error
Error: DB error


Every failure looks identical in the log. A malformed row, a transient API failure, and a database outage all produce the same one-line message with no indication of whether the batch should continue, retry, or abort. The catch block has no way to distinguish between them — all three extend \Exception and the code treats them identically. When this runs across hundreds of files, operators have no basis for deciding which errors are noise and which require intervention.


Custom Exception Classes

Replacing the generic \Exception throws with custom types gives each failure a name and a class that callers can catch individually. MalformedRowException extends \RuntimeException because malformed input is a runtime condition, not a programming mistake. EnrichmentApiException and DatabaseWriteException also extend \RuntimeException for the same reason.

Replace the entire content of import.php with the following:

<?php

class MalformedRowException extends \RuntimeException {}

class EnrichmentApiException extends \RuntimeException {}

class DatabaseWriteException extends \RuntimeException {}

function validateRow(array $row): array
{
    if (empty($row['sku'])) {
        throw new MalformedRowException("Missing SKU in row: " . json_encode($row));
    }
    if (!is_numeric($row['quantity']) || (int)$row['quantity'] < 0) {
        throw new MalformedRowException("Invalid quantity '{$row['quantity']}' for SKU {$row['sku']}");
    }
    return $row;
}

function enrichWithPricing(array $row): array
{
    if ($row['sku'] === 'SKU-007') {
        throw new EnrichmentApiException("Pricing API returned 503 for SKU {$row['sku']}");
    }
    $row['unit_cost'] = 12.50;
    return $row;
}

function writeToDatabase(array $row): void
{
    if ($row['sku'] === 'SKU-009') {
        throw new DatabaseWriteException("Connection refused writing SKU {$row['sku']}");
    }
    echo "Stored: {$row['sku']} qty={$row['quantity']}\n";
}

$rows = [
    ['sku' => 'SKU-001', 'quantity' => '100', 'warehouse' => 'WH-A'],
    ['sku' => '',         'quantity' => '50',  'warehouse' => 'WH-B'],
    ['sku' => 'SKU-003', 'quantity' => '-5',  'warehouse' => 'WH-A'],
    ['sku' => 'SKU-007', 'quantity' => '200', 'warehouse' => 'WH-C'],
    ['sku' => 'SKU-009', 'quantity' => '75',  'warehouse' => 'WH-A'],
];

foreach ($rows as $row) {
    try {
        $validated = validateRow($row);
        $enriched  = enrichWithPricing($validated);
        writeToDatabase($enriched);
    } catch (MalformedRowException $e) {
        echo "[SKIP] Malformed row: {$e->getMessage()}\n";
    } catch (EnrichmentApiException $e) {
        echo "[RETRY] API failure: {$e->getMessage()}\n";
    } catch (DatabaseWriteException $e) {
        echo "[ABORT] Database failure: {$e->getMessage()}\n";
    }
}


Stored: SKU-001 qty=100
[SKIP] Malformed row: Missing SKU in row: {"sku":"","quantity":"50","warehouse":"WH-B"}
[SKIP] Malformed row: Invalid quantity '-5' for SKU SKU-003
[RETRY] API failure: Pricing API returned 503 for SKU SKU-007
[ABORT] Database failure: Connection refused writing SKU SKU-009


Each exception type carries a specific, actionable message and can be caught by the exact catch block that knows what to do with it. Operators reading the log can immediately distinguish a data quality issue from a transient service failure from an infrastructure problem. Adding a new failure mode — a file encoding error, a schema mismatch — means adding a new exception class and a new catch block; the existing handlers are untouched.


Multiple Catch Blocks and Ordering

The current catch blocks treat each exception type in isolation. Production systems need a fallback for unexpected failures — a catch block for the base class that handles anything not caught by a more specific handler. Catch blocks must be ordered most-specific to least-specific; PHP evaluates them top-to-bottom and stops at the first match.

Replace the foreach loop in import.php with the following:

<?php

class MalformedRowException extends \RuntimeException {}

class EnrichmentApiException extends \RuntimeException {}

class DatabaseWriteException extends \RuntimeException {}

function validateRow(array $row): array
{
    if (empty($row['sku'])) {
        throw new MalformedRowException("Missing SKU in row: " . json_encode($row));
    }
    if (!is_numeric($row['quantity']) || (int)$row['quantity'] < 0) {
        throw new MalformedRowException("Invalid quantity '{$row['quantity']}' for SKU {$row['sku']}");
    }
    return $row;
}

function enrichWithPricing(array $row): array
{
    if ($row['sku'] === 'SKU-007') {
        throw new EnrichmentApiException("Pricing API returned 503 for SKU {$row['sku']}");
    }
    $row['unit_cost'] = 12.50;
    return $row;
}

function writeToDatabase(array $row): void
{
    if ($row['sku'] === 'SKU-009') {
        throw new DatabaseWriteException("Connection refused writing SKU {$row['sku']}");
    }
    echo "Stored: {$row['sku']} qty={$row['quantity']}\n";
}

$rows = [
    ['sku' => 'SKU-001', 'quantity' => '100', 'warehouse' => 'WH-A'],
    ['sku' => '',         'quantity' => '50',  'warehouse' => 'WH-B'],
    ['sku' => 'SKU-003', 'quantity' => '-5',  'warehouse' => 'WH-A'],
    ['sku' => 'SKU-007', 'quantity' => '200', 'warehouse' => 'WH-C'],
    ['sku' => 'SKU-009', 'quantity' => '75',  'warehouse' => 'WH-A'],
    ['sku' => 'SKU-011', 'quantity' => '40',  'warehouse' => null],
];

foreach ($rows as $index => $row) {
    try {
        $validated = validateRow($row);
        $enriched  = enrichWithPricing($validated);
        writeToDatabase($enriched);
    } catch (MalformedRowException $e) {
        echo "[SKIP] Row {$index}: {$e->getMessage()}\n";
    } catch (EnrichmentApiException $e) {
        echo "[RETRY] Row {$index}: {$e->getMessage()}\n";
    } catch (DatabaseWriteException $e) {
        echo "[ABORT] Database failure — halting batch: {$e->getMessage()}\n";
        break;
    } catch (\RuntimeException $e) {
        echo "[WARN] Unexpected runtime error at row {$index}: {$e->getMessage()}\n";
    } catch (\Throwable $e) {
        echo "[FATAL] Unhandled error at row {$index}: " . get_class($e) . ": {$e->getMessage()}\n";
    }
}


Stored: SKU-001 qty=100
[SKIP] Row 1: Missing SKU in row: {"sku":"","quantity":"50","warehouse":"WH-B"}
[SKIP] Row 2: Invalid quantity '-5' for SKU SKU-003
[RETRY] Row 3: Pricing API returned 503 for SKU SKU-007
[ABORT] Database failure  halting batch: Connection refused writing SKU SKU-009


The \RuntimeException fallback catches any exception that extends it but was not matched by a more specific block — for example, a third-party library throwing its own \RuntimeException subclass. The \Throwable fallback at the end catches engine-level errors like TypeError that would otherwise propagate uncaught and terminate the process. Ordering from most specific to least specific ensures that MalformedRowException, which extends \RuntimeException, is always matched by its own handler rather than the generic one.

Note: Placing \RuntimeException before MalformedRowException would silently catch all malformed rows under the generic handler — PHP evaluates catch blocks in declaration order, not specificity order.


Finally for Resource Cleanup

The import service opens a file handle at the start of each batch and must close it regardless of whether the import succeeded or failed. The finally block runs after any try or catch block — even if an exception escapes or a return is executed — making it the only reliable place for cleanup code.

Replace the entire content of import.php with the following:

<?php

class MalformedRowException extends \RuntimeException {}
class EnrichmentApiException extends \RuntimeException {}
class DatabaseWriteException extends \RuntimeException {}

function validateRow(array $row): array
{
    if (empty($row['sku'])) {
        throw new MalformedRowException("Missing SKU in row: " . json_encode($row));
    }
    if (!is_numeric($row['quantity']) || (int)$row['quantity'] < 0) {
        throw new MalformedRowException("Invalid quantity '{$row['quantity']}' for SKU {$row['sku']}");
    }
    return $row;
}

function enrichWithPricing(array $row): array
{
    if ($row['sku'] === 'SKU-007') {
        throw new EnrichmentApiException("Pricing API returned 503 for SKU {$row['sku']}");
    }
    $row['unit_cost'] = 12.50;
    return $row;
}

function writeToDatabase(array $row): void
{
    if ($row['sku'] === 'SKU-009') {
        throw new DatabaseWriteException("Connection refused writing SKU {$row['sku']}");
    }
    echo "Stored: {$row['sku']} qty={$row['quantity']}\n";
}

function processBatch(string $filename): void
{
    $handle = null;

    try {
        $handle = fopen($filename, 'r');
        if ($handle === false) {
            throw new \RuntimeException("Cannot open feed file: {$filename}");
        }

        echo "Opened feed: {$filename}\n";
        $index = 0;

        // Read CSV rows (simulated inline for this example)
        $rows = [
            ['sku' => 'SKU-001', 'quantity' => '100', 'warehouse' => 'WH-A'],
            ['sku' => '',         'quantity' => '50',  'warehouse' => 'WH-B'],
            ['sku' => 'SKU-007', 'quantity' => '200', 'warehouse' => 'WH-C'],
            ['sku' => 'SKU-009', 'quantity' => '75',  'warehouse' => 'WH-A'],
        ];

        foreach ($rows as $row) {
            try {
                $validated = validateRow($row);
                $enriched  = enrichWithPricing($validated);
                writeToDatabase($enriched);
            } catch (MalformedRowException $e) {
                echo "[SKIP] Row {$index}: {$e->getMessage()}\n";
            } catch (EnrichmentApiException $e) {
                echo "[RETRY] Row {$index}: {$e->getMessage()}\n";
            } catch (DatabaseWriteException $e) {
                echo "[ABORT] Database failure: {$e->getMessage()}\n";
                throw $e;
            }
            $index++;
        }
    } finally {
        if ($handle !== null) {
            fclose($handle);
            echo "Feed file handle closed\n";
        }
    }
}

try {
    processBatch('/dev/null');
} catch (DatabaseWriteException $e) {
    echo "[BATCH FAILED] {$e->getMessage()}\n";
}


Opened feed: /dev/null
Stored: SKU-001 qty=100
[SKIP] Row 1: Missing SKU in row: {"sku":"","quantity":"50","warehouse":"WH-B"}
[RETRY] Row 2: Pricing API returned 503 for SKU SKU-007
[ABORT] Database failure: Connection refused writing SKU SKU-009
Feed file handle closed
[BATCH FAILED] Connection refused writing SKU SKU-009


The finally block guarantees that fclose() runs regardless of whether processBatch() returns normally, re-throws a DatabaseWriteException, or encounters an unexpected failure. Without finally, every early exit path — return, throw, or uncaught exception — requires a manual fclose() call. With finally, there is one cleanup site and it is impossible to leave a file handle open by forgetting to close it on an error path.

Note: A finally block runs even when the catch block re-throws an exception. The exception continues propagating after finally completes — finally does not suppress it.


Exception Chaining

When the import service catches a low-level exception from a library or driver and wraps it in a domain-specific exception, the original stack trace should be preserved. The $previous parameter of \Exception::__construct() threads the original exception into the new one so that both are visible in logs and debuggers.

Replace the writeToDatabase function and the class definitions in import.php with the following complete file:

<?php

class MalformedRowException extends \RuntimeException {}
class EnrichmentApiException extends \RuntimeException {}

class DatabaseWriteException extends \RuntimeException
{
    public function __construct(string $message, \Throwable $previous = null)
    {
        parent::__construct($message, 0, $previous);
    }
}

class PdoDriverException extends \RuntimeException {}

function validateRow(array $row): array
{
    if (empty($row['sku'])) {
        throw new MalformedRowException("Missing SKU in row: " . json_encode($row));
    }
    if (!is_numeric($row['quantity']) || (int)$row['quantity'] < 0) {
        throw new MalformedRowException("Invalid quantity '{$row['quantity']}' for SKU {$row['sku']}");
    }
    return $row;
}

function enrichWithPricing(array $row): array
{
    if ($row['sku'] === 'SKU-007') {
        throw new EnrichmentApiException("Pricing API returned 503 for SKU {$row['sku']}");
    }
    $row['unit_cost'] = 12.50;
    return $row;
}

function writeToDatabase(array $row): void
{
    if ($row['sku'] === 'SKU-009') {
        // Simulate a low-level driver exception
        $driverException = new PdoDriverException("SQLSTATE[08006]: Connection failure: server closed the connection unexpectedly");
        throw new DatabaseWriteException(
            "Failed to write SKU {$row['sku']} to inventory table",
            $driverException
        );
    }
    echo "Stored: {$row['sku']} qty={$row['quantity']}\n";
}

$rows = [
    ['sku' => 'SKU-001', 'quantity' => '100', 'warehouse' => 'WH-A'],
    ['sku' => 'SKU-009', 'quantity' => '75',  'warehouse' => 'WH-A'],
];

foreach ($rows as $index => $row) {
    try {
        $validated = validateRow($row);
        $enriched  = enrichWithPricing($validated);
        writeToDatabase($enriched);
    } catch (MalformedRowException $e) {
        echo "[SKIP] Row {$index}: {$e->getMessage()}\n";
    } catch (EnrichmentApiException $e) {
        echo "[RETRY] Row {$index}: {$e->getMessage()}\n";
    } catch (DatabaseWriteException $e) {
        echo "[ABORT] {$e->getMessage()}\n";
        echo "  Caused by: " . get_class($e->getPrevious()) . ": {$e->getPrevious()->getMessage()}\n";
        break;
    }
}


Stored: SKU-001 qty=100
[ABORT] Failed to write SKU SKU-009 to inventory table
  Caused by: PdoDriverException: SQLSTATE[08006]: Connection failure: server closed the connection unexpectedly


The [ABORT] message tells operators what failed at the domain level: writing a specific SKU. The "Caused by" line tells them why at the infrastructure level: the database connection dropped. Both pieces of information are in one log entry. Without chaining, the DatabaseWriteException would contain only the domain message and the low-level SQLSTATE code would be lost, leaving the on-call engineer to guess whether the failure was a constraint violation, a timeout, or a dropped connection.


Top-Level Exception Handler

Some exceptions escape all catch blocks — a misconfigured database connection string, an out-of-memory error, a bug in initialization code. set_exception_handler() registers a function that PHP calls for any uncaught exception before the script terminates, giving the application one last opportunity to log a structured error and notify monitoring systems instead of printing a raw PHP fatal error.

Replace the entire content of import.php with the following:

<?php

class MalformedRowException extends \RuntimeException {}
class EnrichmentApiException extends \RuntimeException {}

class DatabaseWriteException extends \RuntimeException
{
    public function __construct(string $message, \Throwable $previous = null)
    {
        parent::__construct($message, 0, $previous);
    }
}

class PdoDriverException extends \RuntimeException {}
class ImportConfigException extends \LogicException {}

set_exception_handler(function (\Throwable $e): void {
    $timestamp = date('Y-m-d H:i:s');
    echo "[{$timestamp}] UNCAUGHT " . get_class($e) . ": {$e->getMessage()}\n";
    echo "  File: {$e->getFile()}:{$e->getLine()}\n";
    if ($e->getPrevious() !== null) {
        echo "  Caused by: " . get_class($e->getPrevious()) . ": {$e->getPrevious()->getMessage()}\n";
    }
    echo "  [Monitoring alert dispatched]\n";
    exit(1);
});

function validateRow(array $row): array
{
    if (empty($row['sku'])) {
        throw new MalformedRowException("Missing SKU in row: " . json_encode($row));
    }
    if (!is_numeric($row['quantity']) || (int)$row['quantity'] < 0) {
        throw new MalformedRowException("Invalid quantity '{$row['quantity']}' for SKU {$row['sku']}");
    }
    return $row;
}

function enrichWithPricing(array $row): array
{
    if ($row['sku'] === 'SKU-007') {
        throw new EnrichmentApiException("Pricing API returned 503 for SKU {$row['sku']}");
    }
    $row['unit_cost'] = 12.50;
    return $row;
}

function writeToDatabase(array $row): void
{
    if ($row['sku'] === 'SKU-009') {
        $driverException = new PdoDriverException("SQLSTATE[08006]: server closed the connection unexpectedly");
        throw new DatabaseWriteException("Failed to write SKU {$row['sku']} to inventory table", $driverException);
    }
    echo "Stored: {$row['sku']} qty={$row['quantity']}\n";
}

function loadBatchConfig(string $env): array
{
    if ($env === 'staging') {
        throw new ImportConfigException("Batch import is disabled in staging environment");
    }
    return ['batch_size' => 500, 'timeout' => 30];
}

$rows = [
    ['sku' => 'SKU-001', 'quantity' => '100', 'warehouse' => 'WH-A'],
    ['sku' => 'SKU-007', 'quantity' => '200', 'warehouse' => 'WH-C'],
];

// This throws ImportConfigException which is not caught below — goes to the handler
$config = loadBatchConfig('staging');

foreach ($rows as $index => $row) {
    try {
        $validated = validateRow($row);
        $enriched  = enrichWithPricing($validated);
        writeToDatabase($enriched);
    } catch (MalformedRowException $e) {
        echo "[SKIP] Row {$index}: {$e->getMessage()}\n";
    } catch (EnrichmentApiException $e) {
        echo "[RETRY] Row {$index}: {$e->getMessage()}\n";
    } catch (DatabaseWriteException $e) {
        echo "[ABORT] {$e->getMessage()}\n";
        echo "  Caused by: " . get_class($e->getPrevious()) . ": {$e->getPrevious()->getMessage()}\n";
        break;
    }
}


[2026-05-14 00:00:00] UNCAUGHT ImportConfigException: Batch import is disabled in staging environment
  File: /home/coder/learning/import.php:72
  [Monitoring alert dispatched]


The set_exception_handler() callback is the application's last line of defense before PHP prints a raw fatal error message. It transforms an unhandled exception into a structured, timestamped log entry that naming-service monitoring can parse. The handler also calls exit(1) to ensure that the process exits with a non-zero code, which process supervisors and CI pipelines use to detect failures. Without this handler, uncaught exceptions produce PHP's default error format — useful for development, misleading in production log aggregators.

Note: set_exception_handler() does not catch exceptions that are thrown inside a catch block during exception handling. It is a fallback for exceptions that reach the top of the call stack without being caught.


Summary

This tutorial built a CSV import service that handles malformed rows, transient API failures, and database outages with distinct, appropriate responses using PHP's exception hierarchy.

  • The exception hierarchy (\Throwable\Exception\RuntimeException\LogicException) provides meaningful base classes; extending the right one determines which generic catch blocks will catch a custom exception.
  • Custom exception classes named after domain concepts (MalformedRowException, DatabaseWriteException) carry specific messages and can be caught individually, making log output actionable rather than generic.
  • Multiple catch blocks must be ordered most-specific to least-specific; PHP stops at the first matching block, so a base-class catch before a subclass catch will silently swallow specific exceptions.
  • A \RuntimeException fallback catch handles unexpected library exceptions without hiding domain-specific failures that have their own handlers.
  • finally runs unconditionally after try/catch — after a return, after a throw, and after re-throwing — making it the only reliable location for file handle and connection cleanup.
  • Exception chaining via the $previous parameter preserves the original low-level exception inside a domain-specific wrapper, so a single log entry can show both what failed at the application level and why at the infrastructure level.
  • set_exception_handler() registers a last-resort handler for uncaught exceptions, enabling structured logging and a non-zero exit code instead of PHP's default fatal error output.

Trebuie să fii autentificat pentru a accesa laboratorul cloud.

Autentifică-te