Exceptions and Error Handling in PHP 8.4: Structuring Error Classes the Right Way
AI generated
<?php
8.4
PHP · Exceptions · Throwable · SPL
Exceptions and Error Handling in PHP 8.4
Structuring error classes instead of catching blindly

Anyone who catches every exception with a blanket catch (\Exception $e) loses the distinction between programming mistakes and expected runtime problems. This article shows how the built-in Throwable hierarchy and the SPL exceptions of PHP 8.4 enable robust error handling, including try/catch/finally, error conversion and clean exception logging.

12 min read Throwable · SPL exceptions · try/catch/finally PHP 8.4

1. Why unstructured error handling makes code unmaintainable

In many grown PHP projects, error handling looks like this: a single catch (\Exception $e) block wrapped around an entire method body, followed by a silent return null or an empty catch block with no logging at all. The problem here is not the syntax, it is the missing distinction. A typo in a variable, a missing database record and a network timeout all end up in the same error handling, even though they have fundamentally different causes and consequences. A programming mistake should ideally surface immediately and get fixed, while a network timeout is an expected operational state that the application needs to react to without crashing.

The consequence of blanket error handling usually only becomes visible months later: a support ticket lands on your desk, the log only says "Exception occurred", and nobody can reconstruct whether the failure was caused by bad input, a broken external service, or an actual bug in the code itself. Using @ to suppress errors or mixing return values like false and null with genuine error states makes this problem worse, because error information is lost completely instead of staying visible in the call stack.

Structured error handling does not solve this problem by adding more code, but by classifying errors correctly. PHP already ships with a well thought out, built-in class hierarchy that distinguishes precisely between programming mistakes, expected runtime states, and genuine engine-level failures. The following sections show how this hierarchy works and how to use it consistently in practice instead of ignoring it through blanket catch blocks.

2. Throwable, Error and Exception: understanding the built-in PHP hierarchy

Since PHP 7, both Error and Exception implement the shared interface Throwable. That was a deliberate design decision: before that, internal engine errors such as calling a non-existent method could not be caught with try/catch, they produced a fatal error that terminated the script immediately. With Throwable as a shared base, both categories can now be handled technically the same way, though that does not mean they should be handled the same way from a design perspective. That is exactly where the crucial distinction for clean error handling lies.

Error and its subclasses such as TypeError, ValueError, ArgumentCountError and DivisionByZeroError represent internal states of the PHP engine that almost always indicate a programming mistake: a wrong type declaration, a missing required argument, a division by zero. These errors should typically not be "handled", but fixed, they are a signal that code at this location is broken. Exception and its subclasses, on the other hand, represent states that can occur during normal program flow and that the application is meant to deal with deliberately, such as invalid user input or an unreachable external service.

In practice this means: catch (\Throwable $e) makes sense as a last line of defense at a central point, for example in an application's global error handler, but not as a default pattern in every method. Catching specific error and exception types before the general safety net lets you react differently per error class instead of treating everything the same way. The following example shows this staggered catch order in concrete terms.


<?php

declare(strict_types=1);

// Throwable is the root interface implemented by both Error and Exception
function divide(int $numerator, int $denominator): float
{
    if ($denominator === 0) {
        // DivisionByZeroError extends ArithmeticError extends Error
        throw new DivisionByZeroError('Division by zero is not allowed');
    }

    return $numerator / $denominator;
}

try {
    $result = divide(10, 0);
} catch (DivisionByZeroError $error) {
    // Catch the specific Error subclass first
    error_log('Arithmetic error: ' . $error->getMessage());
} catch (Error $error) {
    // Catch any other engine-level Error (TypeError, ArgumentCountError, ...)
    error_log('Engine error: ' . $error->getMessage());
} catch (Exception $exception) {
    // Application-level exceptions land here, never engine errors
    error_log('Application exception: ' . $exception->getMessage());
} catch (Throwable $throwable) {
    // Last resort: anything implementing Throwable that was not caught above
    error_log('Unexpected throwable: ' . $throwable->getMessage());
}

3. The SPL exceptions at a glance: LogicException, RuntimeException and their subclasses

The Standard PHP Library ships with a number of concrete exception classes that essentially form two branches: LogicException and RuntimeException, both direct children of Exception. This split is not a formality, it is the most important decision for structured exception handling in PHP. A LogicException signals an error that could already have been avoided at development time and can only be fixed by a code change, for example an invalid argument or a call made in the wrong order. Its subclasses are BadFunctionCallException, BadMethodCallException, DomainException, InvalidArgumentException, LengthException and OutOfRangeException.

A RuntimeException, on the other hand, represents an error that only arises at runtime under adverse circumstances and could not have been fully ruled out while writing the code, for example an unreachable file or a full buffer. Its subclasses are OutOfBoundsException, OverflowException, RangeException, UnderflowException and UnexpectedValueException. The naming is sometimes misleading: OutOfRangeException (logic branch) checks an argument at call time, while OutOfBoundsException (runtime branch) concerns access to a collection at runtime, for example a non-existent array index.

These built-in SPL classes are already sufficient for most use cases without needing to define custom exception classes. For readers who also want to design their own, domain-named exception hierarchies for business errors, a dedicated follow-up article covers those deeper patterns, here it is enough to note that custom classes typically extend one of the SPL base classes and inherit its meaning. The following table and code example show how to correctly assign the built-in classes in everyday work.


<?php

declare(strict_types=1);

final class OrderQuantityValidator
{
    private const int MAX_QUANTITY = 500;

    /**
     * Validate an order quantity against business and technical constraints.
     */
    public function validate(int $quantity): void
    {
        // LogicException: caller passed a value that is wrong by contract
        if ($quantity <= 0) {
            throw new InvalidArgumentException(
                sprintf('Quantity must be positive, %d given', $quantity)
            );
        }

        // LogicException: value is technically valid but outside the allowed domain
        if ($quantity > self::MAX_QUANTITY) {
            throw new DomainException(
                sprintf('Quantity %d exceeds the maximum of %d', $quantity, self::MAX_QUANTITY)
            );
        }
    }
}

final class WarehouseStock
{
    /** @var array<int, int> */
    private array $stockByProductId = [];

    public function reserve(int $productId, int $quantity): void
    {
        $available = $this->stockByProductId[$productId] ?? 0;

        // RuntimeException: condition only known at execution time
        if ($quantity > $available) {
            throw new RuntimeException(
                sprintf('Cannot reserve %d units, only %d available', $quantity, $available)
            );
        }

        $this->stockByProductId[$productId] = $available - $quantity;
    }

    public function itemAt(int $index): int
    {
        $values = array_values($this->stockByProductId);

        if (!array_key_exists($index, $values)) {
            // OutOfBoundsException: index is outside the valid range of the collection
            throw new OutOfBoundsException(sprintf('No stock entry at index %d', $index));
        }

        return $values[$index];
    }
}
Exception class Base class When to use Example scenario
InvalidArgumentException LogicException Passed argument has the wrong type or an invalid value per the method contract Negative quantity, empty required string
RangeException RuntimeException Numeric value falls outside a valid range only at runtime Sensor reading outside the calibration range
DomainException LogicException Value is formally valid but violates a business domain rule Order quantity exceeds warehouse limit
RuntimeException Exception (direct) Error only arises at runtime due to external, unforeseeable circumstances Network timeout, file not readable
LogicException Exception (direct) Error is based on an avoidable programming mistake in the calling code Wrong method call order, invalid internal state
OutOfBoundsException RuntimeException Access to an index or key outside the valid range of a collection Array index does not exist at runtime

4. Using try/catch/finally correctly: multiple catch blocks and union types

A try block may have several catch blocks, and PHP evaluates them in the order given, from top to bottom. The first matching block, whose exception type matches the thrown exception or is one of its parent classes, gets executed, and every later block is skipped. This has an important consequence: specific exception types must always come before more general types. A catch (Exception $e) placed before a catch (InvalidArgumentException $e) makes the second block unreachable, because InvalidArgumentException is a subclass of Exception and is already caught by the first, more general block.

Since PHP 7.1, several exception types can be combined in a single catch block with the pipe operator, for example catch (RuntimeException | JsonException $e). This union-type catching makes sense whenever multiple distinct causes require the same handling, without duplicating code. The finally block, in turn, is guaranteed to execute regardless of whether the try block completes successfully, an exception is thrown, or the code even exits early via return. That makes finally the right place for resource cleanup such as closing file handles or database connections.

An important detail in the interplay between return and finally: if both the try block and the finally block return a value, the value from finally always wins. This is a common source of bugs when a stray return in the finally block accidentally overwrites the actual return value. In practice, finally should therefore be used exclusively for cleanup logic without any control flow of its own.


<?php

declare(strict_types=1);

/**
 * Read and decode a JSON configuration file.
 *
 * @return array<string, mixed>
 */
function loadJsonConfig(string $path): array
{
    $handle = null;

    try {
        $handle = fopen($path, 'rb');

        if ($handle === false) {
            throw new RuntimeException(sprintf('Cannot open config file "%s"', $path));
        }

        $contents = stream_get_contents($handle);

        if ($contents === false) {
            throw new RuntimeException(sprintf('Cannot read config file "%s"', $path));
        }

        /** @var array<string, mixed> $decoded */
        $decoded = json_decode($contents, true, 512, JSON_THROW_ON_ERROR);

        return $decoded;
    } catch (RuntimeException | JsonException $exception) {
        // Union type catch: both failure modes are handled identically here
        throw new RuntimeException(
            sprintf('Failed to load config "%s": %s', $path, $exception->getMessage()),
            previous: $exception
        );
    } finally {
        // Executes on success, on exception and even after an early return
        if (is_resource($handle)) {
            fclose($handle);
        }
    }
}

5. Converting errors into exceptions: set_error_handler and ErrorException

Alongside the Throwable hierarchy, PHP still retains the classic error model made of warnings and notices, working through E_WARNING, E_NOTICE and related constants, for example when native functions raise a warning instead of an exception on invalid arguments. These classic errors do not flow through try/catch by default, they are handled separately through an error handler or simply printed. For consistent error handling, it is therefore common practice to convert these classic errors into real exceptions using set_error_handler().

The built-in class ErrorException is designed exactly for this purpose: it extends RuntimeException and additionally accepts the severity, file and line of the original error. A self-registered error handler that converts every error into an ErrorException and throws it ensures that warnings from legacy code or third-party libraries can be handled in the same try/catch flow as regular exceptions. It is important here to respect the current error_reporting() mask, so that deliberately suppressed errors, for example via the @ operator, do not suddenly result in a thrown exception.

This technique is especially valuable when integrating older code that does not yet rely consistently on exceptions, or when dealing with native PHP functions that, for historical reasons, still raise warnings instead of exceptions. For newly written code, however, throwing exceptions directly should still be preferred, set_error_handler() is a tool for unifying existing error sources, not a replacement for clean error handling in your own code.


<?php

declare(strict_types=1);

/**
 * Convert classic PHP errors (warnings, notices) into catchable exceptions.
 */
set_error_handler(static function (int $severity, string $message, string $file, int $line): bool {
    // Respect the current error_reporting mask, e.g. suppressed @-operator calls
    if (!(error_reporting() & $severity)) {
        return false;
    }

    throw new ErrorException($message, 0, $severity, $file, $line);
});

try {
    // fopen on a missing path would trigger a warning without this handler
    $handle = fopen('/path/does/not/exist.json', 'rb');

    if ($handle === false) {
        throw new RuntimeException('fopen already returned false explicitly');
    }
} catch (ErrorException $exception) {
    // Now handled uniformly like any other exception
    error_log(sprintf(
        'Converted PHP error: %s in %s:%d',
        $exception->getMessage(),
        $exception->getFile(),
        $exception->getLine()
    ));
} finally {
    restore_error_handler();
}

6. Using exception chaining with the previous parameter correctly

Every built-in exception class in PHP has a third constructor parameter called $previous, which lets you attach one exception to another. This is essential when a higher layer converts a lower-level, technical exception into a more meaningful, context-aware exception without losing the original cause of the error. Without this mechanism, throwing a new exception inside a catch block would completely discard the original error message and stack trace, which makes debugging unnecessarily harder.

getPrevious() lets you walk the entire chain backwards until you reach null, which marks the end of the chain. This technique is useful regardless of whether you exclusively use built-in SPL classes or have defined your own exception classes, it belongs to the basic toolkit of any solid error handling. It is important to consistently set the $previous parameter whenever a caught exception is converted into a new exception, instead of silently discarding the original information.

When debugging, getTraceAsString() combined with the traversed chain often reveals the full path an error took through multiple layers of the application. Many logging libraries automatically format chained exceptions with a "Caused by" notation similar to Java, which speeds up root-cause analysis considerably, because you see not just the last exception but the entire error history.


<?php

declare(strict_types=1);

final class PaymentGatewayException extends RuntimeException
{
}

final class PaymentService
{
    /**
     * @throws PaymentGatewayException
     */
    public function charge(string $orderId, int $amountInCents): void
    {
        try {
            $this->callGatewayApi($orderId, $amountInCents);
        } catch (JsonException $decodingError) {
            // Wrap the low-level cause in a higher-level, meaningful exception
            throw new PaymentGatewayException(
                sprintf('Payment for order %s failed: malformed gateway response', $orderId),
                previous: $decodingError
            );
        }
    }

    private function callGatewayApi(string $orderId, int $amountInCents): void
    {
        $response = '{invalid-json';
        json_decode($response, true, 512, JSON_THROW_ON_ERROR);
    }
}

try {
    (new PaymentService())->charge('ORD-4711', 1999);
} catch (PaymentGatewayException $exception) {
    // Walk the chain to log the full root-cause context
    $current = $exception;

    do {
        error_log(sprintf('%s: %s', $current::class, $current->getMessage()));
        $current = $current->getPrevious();
    } while ($current !== null);
}

7. Where exceptions should be thrown and where they should be caught

One of the most important, yet least followed, rules for clean error handling is: throw early, catch late. An exception should be thrown at exactly the point where a rule is violated or an error occurs, with as much context as possible at the moment of the throw. It should be caught, on the other hand, at a point that can actually respond meaningfully to the error, typically at the edge of the application: in the HTTP controller, in the CLI entry point, or in the consumer of a message queue.

A common anti-pattern is catching an exception in the middle of a deep call chain, only to log it and then rethrow it unchanged. This adds no value, unnecessarily lengthens the stack trace, and obscures where responsibility for error handling actually lies. An intermediate catch only makes sense if it genuinely changes something: converting the exception into a different, more meaningful type, enriching it with additional context, or triggering compensating logic such as a retry mechanism.

As a general rule, you should only deliberately catch Error and its subclasses when you truly have a meaningful reaction to it, for example in a library that offers an alternative computation path when a DivisionByZeroError occurs. In most cases, an Error is a sign of a genuine bug that needs to be fixed in the code, not papered over with try/catch in production. This clear separation between "fix" and "handle" is at the core of thoughtful error handling.

8. Logging exceptions: context, stack trace and structured error data

A caught exception without logging is lost information. For effective error handling in production, it is not enough to just print the error message with getMessage(), because that alone often does not reveal in which business context the error occurred. What makes sense is a structured log entry that, besides the message, also includes the exception class via $exception::class, the error code via getCode(), file and line via getFile() and getLine(), as well as the full stack trace via getTraceAsString().

You should also log business context that goes beyond the exception instance itself, such as the affected order number, the user ID, or the endpoint that was called. PSR-3-compatible loggers support a separate context array parameter for this, which is logged in a structured way rather than as free text, and can later be filtered and analyzed in log aggregation tools. Important: sensitive data such as passwords, payment details or access tokens must never end up unfiltered in the exception context, even if they were present in variables at the time of the error.

For chained exceptions, as described in the previous section, logging should capture the entire chain, not just the outermost exception. A monitoring or error-tracking system that automatically groups and deduplicates benefits considerably when exception class, message and stack trace are structured consistently, rather than being formatted as a different string on every call. This makes it easier to distinguish recurring root causes from one-off incidents.

9. Common mistakes with exceptions and how to avoid them

The most common mistake is the empty catch block: catch (\Exception $e) {} with no further action at all. That makes the error vanish without a trace, the program appears to continue running normally even though something failed at that point. At the very least, every catch block should log why it handled an exception, even if no further action is required. A second widespread mistake is using exceptions for normal control flow, for example throwing an exception to signal an expected "not found" situation, when a return value like null or a result object would be a better fit from a design perspective. Exceptions are meant for exceptional states, not for every alternative code path.

Another typical mistake when converting exceptions: the original exception is not passed along as the $previous parameter when a new exception is thrown, causing the actual root cause to get lost. Equally problematic is comparing error states via getMessage() strings instead of the exception type, for example if (str_contains($e->getMessage(), 'not found')). Messages can change with every PHP or library update, while the class name stays stable and can be checked reliably via instanceof or targeted catch types.

Finally, it is often forgotten that resources such as file handles, database connections or locks also need to be released in the error path. Placing cleanup code only in the success path after the try block risks a resource leak on every thrown exception. The finally block, as shown in section 4, is designed exactly for this purpose and should be used consistently for every resource acquired inside the try block.

10. Summary

Structured error handling in PHP 8.4 starts with understanding the built-in Throwable hierarchy: Error for programming mistakes and engine states that should be fixed, Exception for expected runtime states that the application is meant to deliberately deal with. The SPL exceptions LogicException and RuntimeException with their respective subclasses already provide suitable, standardized error classes for most use cases, without needing to define custom classes. try/catch/finally with correctly ordered catch blocks, union types for handling related errors together, and a clean finally block for cleanup form the technical foundation.

Beyond that, set_error_handler() combined with ErrorException provides consistent handling of classic PHP errors, the $previous parameter provides an unbroken error chain across layer boundaries, and structured logging with full context enables fast error analysis in production. Combining these building blocks consistently replaces blanket error handling with a traceable, maintainable error strategy in which every exception class carries a clear, documented meaning.

Exceptions and Error Handling in PHP 8.4: The Essentials at a Glance

Throwable hierarchy

Error for programming mistakes and engine states that need fixing. Exception for expected runtime states the application deliberately deals with.

SPL exceptions

LogicException for avoidable programming mistakes, RuntimeException for states only recognizable at runtime. Both with matching subclasses.

try/catch/finally

Place specific catch blocks before general ones, use union types for related errors, use finally only for cleanup with no return value of its own.

Chaining & logging

Consistently set the previous parameter, log structured context instead of plain text, and exclude sensitive data from it.

11. FAQ: Exceptions and Error Handling in PHP

1What is the difference between Error and Exception?
Error represents internal engine states like TypeError, usually indicating a programming mistake that should be fixed. Exception represents expected runtime states the application deliberately deals with. Both implement Throwable.
2Should I always use catch (Throwable $e)?
No, only as a last line of defense at a central location. In most methods, specific types should be caught deliberately before the general safety net.
3What does SPL mean in SPL exceptions?
SPL stands for Standard PHP Library and ships concrete exception classes such as LogicException and RuntimeException with their respective subclasses.
4When LogicException instead of RuntimeException?
LogicException for avoidable programming mistakes that could have been recognized at development time. RuntimeException for errors that only arise through external circumstances at runtime.
5How do I convert warnings into exceptions?
Register a function with set_error_handler() that throws an ErrorException, while respecting the current error_reporting() mask.
6What does the previous parameter do?
It chains a new exception to its original cause. getPrevious() allows walking the entire chain when debugging.
7Can finally override a return value?
Yes, a return in finally always wins against a return in the try block. That is why finally should only be used for cleanup without its own return.
8Use exceptions for control flow?
No, exceptions are meant for exceptional states. Expected situations like "not found" are better represented through return values.
9How do I log exceptions correctly?
Log class, code, file, line and stack trace, supplemented with business context as a structured array. Exclude sensitive data from it.
10Where in the code should I catch exceptions?
Throw early, catch late: throw right at the point of failure, catch at the edge of the application where a meaningful reaction is possible.

Mironsoft

PHP development with robust error handling and clean architecture

Is your error handling disappearing into empty catch blocks?

We review existing PHP code for unstructured exception handling, introduce the built-in Throwable hierarchy consistently, and build traceable logging for your production environment.

Code audit

Analysis of existing catch blocks and error handling patterns in the project

Refactoring

Introducing clean SPL exception usage and consistent error classes

Logging setup

Structured exception logging with context for faster error analysis