PHP

PHP Exception Best Practices: Hard Rules, Project-Level Hierarchies, and Modern 8.4 Patterns

18 min readBy Joseph Edmonds

Exceptions are the primary way PHP code communicates that something has gone wrong. Get them right and the stack trace, the log line, and the API error response all tell the same coherent story. Get them wrong and you end up parsing human-readable messages with regex, swallowing errors "temporarily", and turning every production incident into an archaeology dig. This article lays out a set of simple hard rules, with a PHP 8.4 implementation pattern that makes them trivial to follow. Python and TypeScript get a cursory section at the end — the principles transfer directly.

The Hard Rules (In Order)

Before anything else, these are the non-negotiables. Everything that follows is the mechanism for applying them without friction.

  1. \RuntimeException is reserved for the truly unexpected. If any layer other than the outermost handler is catching one, something is wrong. Either the failure mode is expected and deserves a proper project-level exception, or nobody should be catching it.
  2. \LogicException is for impossible states. Type guards, invariant checks, "this branch should be unreachable" — things that, if they ever throw in production, mean the code is wrong.
  3. \InvalidArgumentException is for argument validation at the boundary. Throw as early as possible, so invalid values never enter the domain.
  4. Always chain the previous exception. Never swallow debugging information by dropping the original cause.
  5. Never encode data inside message strings. Data belongs on typed properties. The message is synthesised from those properties via a published sprintf constant.
  6. Use named static factory methodscreate and createWithPrevious. No more wondering which constructor overload you are looking at.
  7. Messages live in class constants as sprintf formats. One source of truth. Tests reuse the constant, so changing wording never breaks a test and never creates a magic string to hunt down.
  8. Never, ever, ever swallow an exception. Catching without rethrowing, recovering, or translating is fraud — the caller is told everything worked when it did not.

The rest of this article is the machinery that makes all of the above easy.

The SPL Hierarchy — What to Extend and When

PHP's built-in exception classes form a small tree that encodes a useful distinction. Use it.

<?php

// PHP's SPL exception hierarchy (simplified).
//
// \Throwable (interface)
//   ├── \Error                        // engine-level failures — rarely caught
//   │   ├── \TypeError
//   │   ├── \ValueError
//   │   └── \AssertionError
//   └── \Exception
//       ├── \LogicException           // programmer bugs — impossible states
//       │   ├── \BadFunctionCallException
//       │   │   └── \BadMethodCallException
//       │   ├── \DomainException      // value outside its domain
//       │   ├── \InvalidArgumentException
//       │   ├── \LengthException
//       │   └── \OutOfRangeException
//       └── \RuntimeException         // environmental / truly unexpected
//           ├── \OutOfBoundsException
//           ├── \OverflowException
//           ├── \RangeException
//           ├── \UnderflowException
//           └── \UnexpectedValueException
//
// Rule of thumb:
//   LogicException  = "this is a bug, fix the code"
//   RuntimeException = "something external went wrong, not my code's fault"

The single most important division is between \LogicException and \RuntimeException. It is a binary question about whose fault the failure is:

  • \LogicException means the code is wrong. This should never happen. If it does, a developer needs to fix the code. No amount of retrying or recovery will help.
  • \RuntimeException means the environment is wrong. The database went away. The network timed out. The disk is full. The code is fine — the world is misbehaving.

Here is the distinction in practice:

<?php

declare(strict_types=1);

namespace App\Example;

use LogicException;
use RuntimeException;

final class PaymentProcessor
{
    public function __construct(
        private readonly PaymentGateway $gateway,
    ) {}

    public function charge(Money $amount): ChargeResult
    {
        // LogicException: the caller broke an invariant. This is a BUG.
        // A zero/negative amount should have been stopped at the value-object
        // boundary. If we are here, the code upstream is wrong.
        if ($amount->isZeroOrNegative()) {
            throw new LogicException(
                'PaymentProcessor::charge() received non-positive amount. '
                . 'Money value object should have prevented this.'
            );
        }

        try {
            return $this->gateway->process($amount);
        } catch (GatewayTimeoutException $previous) {
            // RuntimeException: the network is flaky, the gateway is down,
            // or the provider returned a 500. Nothing in our code can
            // prevent this. Bubble it up — only the outermost layer should
            // translate it into a user-facing "something went wrong" message.
            throw new RuntimeException(
                'Payment gateway did not respond in time.',
                previous: $previous,
            );
        }
    }
}

And InvalidArgumentException — a subclass of LogicException — covers the canonical "you passed me rubbish" case:

<?php

declare(strict_types=1);

namespace App\Example;

use InvalidArgumentException;

final class EmailAddress
{
    public function __construct(
        public readonly string $value,
    ) {
        // InvalidArgumentException: the argument is structurally wrong.
        // Caller passed something that could never be valid for this type.
        // Throw at the boundary — as early as possible — so the wrong value
        // never enters the domain.
        if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
            throw new InvalidArgumentException(
                sprintf('Value "%s" is not a valid email address.', $value),
            );
        }
    }
}

The Containment Rule for RuntimeException

This is worth stating twice because it is the rule most often violated.

No layer of the application may catch a bare RuntimeException except the outermost one. Not the service layer. Not the repository. Not the controller. Only the kernel or top-level exception listener that turns failures into a user-facing "something went wrong" response.

If you find yourself wanting to catch a RuntimeException in the middle of the stack, stop and ask: why is this failure expected here? If you can answer that question, the failure deserves its own project-level exception class that describes what happened in domain terms. Replace the bare RuntimeException with that new class, and now the catch block has a name and a contract.

The same rule applies, even more strictly, to Throwable and Exception. Generic catches in the middle of a stack are how silent bugs survive into production.

Project-Level Exception Hierarchy

Every project of any size needs its own exception tree. The minimum useful structure is a root marker interface that every project-thrown exception implements, a couple of category marker interfaces for things you want to react to generically, and abstract base classes corresponding to the LogicException and RuntimeException split.

<?php

declare(strict_types=1);

namespace App\Exception;

/**
 * Root marker interface for every exception this project throws itself.
 *
 * Anything that implements this is *expected* — it describes a known
 * failure mode the code has reasoned about. A bare \RuntimeException or
 * \Exception reaching the outer handler means we hit something we did not
 * anticipate and should add a proper exception class for.
 */
interface AppExceptionInterface extends \Throwable {}

/** Anything the user caused (bad input, forbidden action). */
interface UserFacingExceptionInterface extends AppExceptionInterface {}

/** Anything worth retrying automatically (transient). */
interface RetryableExceptionInterface extends AppExceptionInterface {}

/** Security-relevant — always logged to the security channel. */
interface SecurityExceptionInterface extends AppExceptionInterface {}

/**
 * Base class for domain-meaningful failures.
 * Extends \RuntimeException because these are "expected at runtime but
 * still an error", and they should surface with context, not get caught
 * by a generic handler in the middle of the stack.
 */
abstract class AppException extends \RuntimeException implements AppExceptionInterface {}

/**
 * Base class for code-level bugs. Extending \LogicException signals
 * clearly: if you see one of these in production, the code is wrong.
 */
abstract class AppLogicException extends \LogicException implements AppExceptionInterface {}

The marker interfaces pay for themselves instantly. The kernel exception listener can match on UserFacingExceptionInterface and return a structured 4xx without caring what the specific subclass is. The security logger listens for SecurityExceptionInterface. A retry middleware loops when it sees RetryableExceptionInterface. None of those components need to know about every concrete exception class — they react to capability.

The AppExceptionInterface root marker is the one that really earns its keep. If an exception reaching the top handler does not implement it, that is a signal: the code threw something it did not reason about. Either promote the exception to a proper domain class, or wrap it at the boundary where it originated.

Composition Over Inheritance — Keep the Tree Shallow

PHP forces you to extend Exception (or one of its subclasses) because throw only accepts a Throwable. That is the one piece of inheritance you cannot avoid. Everything else — categorisation, shared behaviour, rich context — should be composed, not inherited.

The common trap is using deep inheritance chains to "share a bit of behaviour" or to "group related exceptions". It looks tidy on a class diagram and falls apart in practice:

<?php

declare(strict_types=1);

// ❌ ANTI-PATTERN — deep inheritance tree used to share behaviour.
//
// Every concrete exception lives at the bottom of a chain of bases that
// each add "a little bit" to the previous. You can no longer tell what
// a given exception actually is without walking the whole hierarchy.

abstract class AppException extends \RuntimeException {}
abstract class OrderException extends AppException {}
abstract class OrderValidationException extends OrderException {}
abstract class OrderStockException extends OrderValidationException {}
final class InsufficientStockException extends OrderStockException {}
//                                        ^
//  5 levels deep just to say "order failed because of stock".
//  Worse: subclassing `InsufficientStockException` to add a "partial"
//  variant creates a 6th level, and now a `catch` on the parent silently
//  also catches the child — rarely what you want.

// ❌ Also an anti-pattern — subclassing a *concrete* exception to tweak it.
final class PartialStockException extends InsufficientStockException {}

// The catch block below catches BOTH concretes. The developer who added
// PartialStockException may not have realised.
try {
    $this->place($order);
} catch (InsufficientStockException $e) {
    // ...
}

The rule: concrete exceptions are exactly one level below the abstract base. No mid-tier abstracts like OrderException sitting between AppException and InsufficientStockException. No subclassing a concrete exception to tweak it. If you need to group exceptions for handling, group them with a marker interface. If you need to share mechanical boilerplate, share it with a trait.

Traits for Shared Mechanical Boilerplate

Traits are a clean way to share the MESSAGE_FORMAT / sprintf / static factory pattern across every exception without an intermediate base class:

<?php

declare(strict_types=1);

namespace App\Exception;

use Throwable;

/**
 * Composition — a trait that gives any exception the standard boilerplate:
 *
 *   - a MESSAGE_FORMAT constant contract (enforced at compile time)
 *   - named static factories that synthesise the message for you
 *
 * Concrete exceptions use this trait AND extend the shallow base class.
 * They get the full pattern without inheritance going deeper than one level.
 */
trait ProvidesMessageFormatTrait
{
    /**
     * Every exception that uses this trait must publish a MESSAGE_FORMAT
     * constant on the class. PHPStan's `constantsOfClasses` check enforces
     * the contract statically.
     *
     * @param array<int, scalar> $args
     */
    protected static function format(array $args): string
    {
        return sprintf(static::MESSAGE_FORMAT, ...$args);
    }

    /** @param array<int, scalar> $args */
    protected static function buildMessage(array $args, ?Throwable $previous): static
    {
        return new static(
            message: self::format($args),
            code: 0,
            previous: $previous,
        );
    }
}

/**
 * Concrete exceptions that use the trait stay ONE level deep:
 *
 *     final class InsufficientStockException extends AppException {
 *         use ProvidesMessageFormatTrait;
 *         public const string MESSAGE_FORMAT = '...';
 *
 *         public static function create(Sku $sku, int $r, int $a): self { ... }
 *     }
 *
 * No mid-tier abstract bases. No "OrderStockException" layer. The marker
 * interfaces handle cross-cutting categorisation; the trait handles shared
 * mechanical boilerplate.
 */

A concrete exception stays one level deep (extends AppException), uses the trait for boilerplate, declares its MESSAGE_FORMAT constant, and implements whichever marker interfaces describe its capabilities. Behaviour is composed from three orthogonal axes — class (for throw compatibility), interfaces (for categorisation), trait (for boilerplate) — not stacked up in an inheritance chain.

Compose Rich Context via Value Objects

When an exception would otherwise sprout six or seven flat properties, the right move is to compose a typed value object and have the exception hold that, rather than duplicating every field on the exception itself:

<?php

declare(strict_types=1);

namespace App\Exception\Payment;

use App\Exception\AppException;
use App\Exception\RetryableExceptionInterface;
use App\Exception\UserFacingExceptionInterface;
use App\Value\Money;
use App\Value\PaymentAttemptContext;
use Throwable;

/**
 * Composition — when an exception would otherwise need eight flat properties,
 * compose a typed value object instead. The exception carries ONE `context`
 * property whose fields are all typed, all readable, all testable.
 *
 * Benefits:
 *   - Exception signature stays tiny regardless of how rich the context is.
 *   - The context object can be reused by other parts of the system
 *     (logs, analytics, audit trail) — it is not trapped inside the
 *     exception class.
 *   - Adding a new field does not change the exception's constructor
 *     signature or break any factory call site.
 *
 * Inheritance is still shallow:
 *     \Exception → \RuntimeException → AppException → PaymentFailedException
 *                                                   (concrete, final)
 */
final class PaymentFailedException extends AppException implements
    UserFacingExceptionInterface,
    RetryableExceptionInterface
{
    public const string MESSAGE_FORMAT =
        'Payment of %s failed for customer %s at gateway "%s" (reason: %s).';

    public function __construct(
        public private(set) PaymentAttemptContext $context,
        ?Throwable $previous = null,
    ) {
        parent::__construct(
            message: sprintf(
                self::MESSAGE_FORMAT,
                $context->amount->format(),
                $context->customerId->value,
                $context->gatewayName,
                $context->reasonCode,
            ),
            previous: $previous,
        );
    }

    public static function create(PaymentAttemptContext $context): self
    {
        return new self($context);
    }

    public static function createWithPrevious(
        PaymentAttemptContext $context,
        Throwable $previous,
    ): self {
        return new self($context, $previous);
    }
}

/**
 * The composed value object lives in App\Value\PaymentAttemptContext —
 * reused by the exception, the audit log writer, and the analytics event
 * publisher. Each consumer gets the typed fields it cares about without
 * the exception having to grow N public properties.
 *
 * readonly final class PaymentAttemptContext {
 *     public function __construct(
 *         public Money $amount,
 *         public CustomerId $customerId,
 *         public string $gatewayName,
 *         public string $reasonCode,
 *         public ?string $gatewayTransactionId = null,
 *     ) {}
 * }
 */

Now the same PaymentAttemptContext value object is reused by the exception, the audit log writer, the analytics publisher, and anywhere else that needs to represent a payment attempt. Adding a new field to the context does not ripple through every exception constructor — the exception's public surface stays stable. This is the same argument for composition over inheritance that applies to any other class in the system; it just holds doubly for exceptions because inheritance is already load-bearing for the throw contract.

Three Axes, Not One Chain

Put it all together and every concrete exception composes along three independent axes:

  • Class — exactly one level below an abstract base (AppException or AppLogicException). This is the only inheritance in play, and it exists solely because throw requires a Throwable.
  • Marker interfaces — orthogonal capabilities (UserFacing, Retryable, Security). An exception can be any combination of these. Try expressing "retryable AND user-facing" with class inheritance and you will see why interfaces win.
  • Traits and value objects — reusable mechanical pieces. The sprintf/factory boilerplate lives in a trait. Rich context lives in a composed value object.

The inheritance tree is two levels deep and stays that way forever. Every other axis of variation is handled by composition.

Data Goes on Properties, Not in Message Strings

This is the rule that most often surprises people, and the one that pays the biggest dividend. Here is the anti-pattern:

<?php

declare(strict_types=1);

// ❌ ANTI-PATTERN — data encoded into the message string.
//
// The only way to test, log, or react to the specifics is to parse
// the message back out again. Change the wording and every test that
// asserted on the string breaks. Monolog cannot index any of it.

throw new \RuntimeException(
    "Order #12345 for customer user-987 failed: insufficient stock for SKU ABC-42 (requested 10, available 3)"
);

// Downstream code ends up doing this:
try {
    $service->place($order);
} catch (\RuntimeException $e) {
    // Fragile regex to pick data out of a human-readable string.
    if (preg_match('/SKU ([A-Z0-9-]+).*requested (\d+).*available (\d+)/', $e->getMessage(), $m)) {
        // ...and it will silently break the day someone "improves the wording".
    }
}

Every piece of data in that message is valuable — the SKU, the requested quantity, the available quantity, the order ID, the customer ID. Downstream code will want to react to those values specifically. Logs want to index them. API responses want to return them as structured fields. But all of it is trapped inside a string, recoverable only by fragile regex that breaks the first time someone improves the wording.

The fix is simple: put the data on the exception as typed properties, and synthesise the message from them via a published sprintf format constant.

<?php

declare(strict_types=1);

namespace App\Exception\Order;

use App\Exception\AppException;
use App\Exception\UserFacingExceptionInterface;
use App\Value\Sku;
use Throwable;

/**
 * ✅ GOOD — the *data* lives in typed, readable properties.
 *           The *message* is synthesised from them via a sprintf const.
 *
 * PHP 8.4 asymmetric visibility (`public private(set)`) lets callers read
 * the fields freely while only the class (and its factories) can set them.
 * No getters, no readonly footguns with inheritance, no magic string parsing.
 */
final class InsufficientStockException extends AppException implements UserFacingExceptionInterface
{
    /** sprintf format — the single source of truth for the wording. */
    public const string MESSAGE_FORMAT =
        'Insufficient stock for SKU %s: requested %d, only %d available.';

    public function __construct(
        public private(set) Sku $sku,
        public private(set) int $requested,
        public private(set) int $available,
        ?Throwable $previous = null,
    ) {
        parent::__construct(
            message: sprintf(self::MESSAGE_FORMAT, $sku->value, $requested, $available),
            previous: $previous,
        );
    }

    public static function create(Sku $sku, int $requested, int $available): self
    {
        return new self($sku, $requested, $available);
    }

    public static function createWithPrevious(
        Sku $sku,
        int $requested,
        int $available,
        Throwable $previous,
    ): self {
        return new self($sku, $requested, $available, $previous);
    }
}

PHP 8.4's asymmetric visibility — public private(set) — is perfect here. The properties are freely readable anywhere (no boilerplate getters), but only the exception itself can set them. No accidental mutation, no readonly gotchas around inheritance.

Downstream code becomes clean and type-safe:

<?php

declare(strict_types=1);

namespace App\Http\Controller;

use App\Exception\Order\InsufficientStockException;

// Callers inspect *properties*, not strings. Refactor-safe, type-safe,
// trivially serialisable into a structured API error payload.

try {
    $orderService->place($order);
} catch (InsufficientStockException $e) {
    return new JsonResponse([
        'error' => 'insufficient_stock',
        'sku'   => $e->sku->value,
        'requested' => $e->requested,
        'available' => $e->available,
    ], status: 409);
}

Static Factory Methods: create() and createWithPrevious()

Exceptions are one of the rare cases where multiple named factories beat a single polymorphic constructor. A fully-typed constructor with five domain parameters plus an optional trailing previous is ugly to call and ambiguous to read. Named factories make the call site self-documenting:

<?php

// Without named factories — ambiguous at the call site, especially once
// the constructor grows optional parameters.
throw new InsufficientStockException($sku, 10, 3, null);
throw new InsufficientStockException($sku, 10, 3, $previous);

// With named factories — the call site reads itself.
throw InsufficientStockException::create($sku, 10, 3);
throw InsufficientStockException::createWithPrevious($sku, 10, 3, $previous);

They also give you a natural place to add convenience constructors later — fromApiResponse(array $body), forOrder(OrderId $id), and so on — without bloating the main constructor signature.

Message Constants — The Single Source of Truth

Exception messages are a surprisingly common source of magic strings scattered through codebases. Every test that asserts on a specific wording becomes a fragile coupling to the exact phrasing. Change the wording and seventeen unrelated tests turn red.

The fix is the MESSAGE_FORMAT class constant:

  • The exception's constructor uses it to build the message.
  • Tests use the same constant to build the expected value.
  • Log parsers (if you really must) reference the constant, not the literal string.

Change the wording in one place. Every caller and every test stays green automatically.

<?php

declare(strict_types=1);

namespace App\Tests\Exception;

use App\Exception\Order\InsufficientStockException;
use App\Value\Sku;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;

final class InsufficientStockExceptionTest extends TestCase
{
    #[Test]
    public function it_exposes_the_stock_data_as_properties(): void
    {
        $sku = new Sku('ABC-42');
        $exception = InsufficientStockException::create($sku, requested: 10, available: 3);

        // Assert on PROPERTIES — not message strings.
        self::assertSame($sku, $exception->sku);
        self::assertSame(10, $exception->requested);
        self::assertSame(3, $exception->available);
    }

    #[Test]
    public function its_message_uses_the_published_format_constant(): void
    {
        $exception = InsufficientStockException::create(new Sku('ABC-42'), 10, 3);

        // Reproduce the expected message from the same constant the production
        // code uses. Change the wording in one place → test still passes.
        // No magic string hard-coded in the assertion.
        $expected = sprintf(
            InsufficientStockException::MESSAGE_FORMAT,
            'ABC-42',
            10,
            3,
        );
        self::assertSame($expected, $exception->getMessage());
    }

    #[Test]
    public function it_preserves_the_previous_exception_for_debugging(): void
    {
        $previous = new \RuntimeException('gateway exploded');
        $exception = InsufficientStockException::createWithPrevious(
            new Sku('ABC-42'), 10, 3, $previous,
        );

        self::assertSame($previous, $exception->getPrevious());
    }
}

Notice how the test never hardcodes the message string. It asserts on properties (which is the right thing to assert on 95% of the time) and, where it does check the message, it reproduces it from the published constant.

Always Chain the Previous Exception

PHP's Exception constructor takes a ?Throwable $previous argument specifically for this. Use it. Every. Single. Time.

When you translate a low-level exception into a domain-meaningful one at a boundary, the low-level exception is not noise to be discarded — it is the root cause of the failure. Stack traces, connection IDs, driver error codes, provider-specific details all live on that original throwable. Throw it away and future you will be guessing.

Monolog's default formatter walks the entire previous chain. Symfony's profiler shows every level. PHPUnit's expectException output includes it. All of this works automatically — if you chain.

The boundary conversion pattern looks like this:

<?php

declare(strict_types=1);

namespace App\Infrastructure\Gateway;

use App\Exception\Payment\PaymentDeclinedException;
use App\Exception\Payment\PaymentGatewayUnavailableException;
use App\Value\Money;

/**
 * Boundary principle:
 *   Low-level exceptions (HTTP, PDO, file I/O) MUST be translated into
 *   domain-meaningful exceptions at the boundary where they originate.
 *
 * The rest of the application never sees \PDOException, \Redis\RedisException,
 * Symfony\HttpClientException, etc. — it sees AppException subclasses that
 * describe what failed in domain terms, with the original exception chained.
 */
final class StripePaymentGateway implements PaymentGateway
{
    public function __construct(
        private readonly HttpClientInterface $http,
    ) {}

    public function charge(Money $amount): ChargeResult
    {
        try {
            $response = $this->http->request('POST', '/v1/charges', [
                'json' => ['amount' => $amount->minorUnits, 'currency' => $amount->currency->value],
            ]);
            return ChargeResult::fromApiResponse($response->toArray());
        } catch (TransportExceptionInterface $previous) {
            // Translate transport-level failure into something the domain
            // can reason about. Previous is preserved for the log.
            throw PaymentGatewayUnavailableException::createWithPrevious(
                gatewayName: 'stripe',
                previous: $previous,
            );
        } catch (ClientExceptionInterface $previous) {
            // 4xx from Stripe — card declined, insufficient funds, etc.
            $body = $previous->getResponse()->toArray(throw: false);
            throw PaymentDeclinedException::createWithPrevious(
                reasonCode: $body['error']['code'] ?? 'unknown',
                amount: $amount,
                previous: $previous,
            );
        }
    }
}

The rule in one sentence: the rest of the application never sees PDOException, RedisException, or HTTP client exceptions — it sees AppException subclasses, and the original exception is always chained.

Never, Ever Swallow Exceptions

This needs its own section because it is the single most damaging pattern in PHP codebases.

<?php

declare(strict_types=1);

// ❌ NEVER. This is the single most destructive pattern in PHP codebases.
try {
    $this->syncCustomer($customer);
} catch (\Throwable) {
    // "it's fine, we'll deal with it later"
}

// ❌ Also never. `@` is swallowing with extra steps.
$handle = @fopen($path, 'r');

// ❌ Logging without rethrowing is still swallowing — the caller is lied to.
try {
    $this->syncCustomer($customer);
} catch (\Throwable $e) {
    $this->logger->error('sync failed', ['exception' => $e]);
    // execution continues as if nothing went wrong
}

// ✅ If you catch, you must do ONE of:
//    1. Rethrow (optionally wrapped with more context and $previous chained)
//    2. Recover with a meaningful alternative path that the caller asked for
//    3. Translate into a different exception the caller is documented to expect

try {
    $this->syncCustomer($customer);
} catch (CustomerApiTimeoutException $previous) {
    // Recovery path — explicitly requested by the caller via a retry policy.
    // NOT "we hope it worked". The exception is replaced with a known state.
    $this->retryQueue->enqueue($customer);
    throw CustomerSyncDeferredException::createWithPrevious($customer->id, $previous);
}

If you catch an exception, you must do exactly one of three things:

  1. Rethrow. Optionally wrapped in a more meaningful exception, with the previous exception chained. The caller still knows something went wrong.
  2. Recover. Execute an alternative path that the caller has explicitly opted into (retry, fallback value, degraded mode). The recovery must be an actual plan, not "we hope it worked".
  3. Translate. Throw a different exception that the caller is documented (via @throws) to expect. This is a rethrow with wrapping.

Logging and then not rethrowing is still swallowing. The caller is told via a normal return that everything succeeded. It did not. This is lying to your own code.

If you are ever tempted to write an empty catch, write a comment first explaining which of the three options above you are doing and why. Nine times out of ten, the act of writing the comment will reveal that you are about to do something wrong.

The Outermost Handler — Where Generic Catches Belong

Given all of the above, there is exactly one place in the application where a generic catch of Throwable makes sense: the kernel-level exception listener. That is the place that decides what a user sees when something goes wrong, and it is the only place that is allowed to deal in generic base types.

<?php

declare(strict_types=1);

namespace App\EventListener;

use App\Exception\AppExceptionInterface;
use App\Exception\UserFacingExceptionInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\Attribute\AsEventListener;

/**
 * The ONLY place a generic `catch \Throwable` / `RuntimeException` is OK.
 *
 * By the time an exception reaches here, everything below has had its
 * chance to recover or translate. This listener decides what the user
 * sees and what gets logged — nothing in the middle of the stack may
 * catch `\Throwable`, `\Exception`, or `\RuntimeException` generically.
 */
#[AsEventListener]
final class KernelExceptionListener
{
    public function __construct(
        private readonly LoggerInterface $exceptionLogger, // monolog channel: exception
    ) {}

    public function __invoke(ExceptionEvent $event): void
    {
        $throwable = $event->getThrowable();

        // Always log — never swallow at the top either.
        $this->exceptionLogger->error($throwable->getMessage(), [
            'exception' => $throwable, // Monolog's built-in exception formatter
                                       // walks the whole previous chain.
        ]);

        $event->setResponse(match (true) {
            $throwable instanceof UserFacingExceptionInterface => new JsonResponse(
                ['error' => $throwable::class, 'message' => $throwable->getMessage()],
                status: 400,
            ),
            $throwable instanceof AppExceptionInterface => new JsonResponse(
                ['error' => 'application_error'],
                status: 500,
            ),
            // Truly unexpected — a bare \RuntimeException, \Error, etc.
            // The user gets a generic message. The full trace is in the log.
            default => new JsonResponse(
                ['error' => 'something_went_wrong'],
                status: 500,
            ),
        });
    }
}

Notice how the handler uses the marker interfaces from the project hierarchy to branch. It does not enumerate concrete exception classes. The list of concrete exceptions can grow without the handler ever needing to change.

Monolog: A Dedicated Exception Log Channel

Exceptions deserve their own log channel with its own retention policy and its own format. Mixing them into the main application log makes them hard to find and hard to correlate across requests.

Here is a Symfony Monolog configuration for this setup:

# config/packages/monolog.yaml
#
# Dedicated "exception" channel with its own handler and log file.
# Every caught-and-rethrown or uncaught exception gets logged here with
# full structured context (including the whole $previous chain).

monolog:
    channels:
        - exception    # our dedicated channel
        - security

    handlers:
        # 1. Dedicated exception log — rotated daily, 30 day retention.
        #    Uses the JSON formatter so structured data (including exception
        #    properties) is queryable in log aggregation tooling.
        exception_file:
            type: rotating_file
            path: '%kernel.logs_dir%/exception.log'
            level: error
            max_files: 30
            channels: ['exception']
            formatter: monolog.formatter.json

        # 2. fingers_crossed on the main channel — a single error triggers
        #    the whole request's debug log being flushed, giving you the
        #    full context around the exception.
        main:
            type: fingers_crossed
            action_level: error
            handler: nested_main
            channels: ['!exception', '!security']
            excluded_http_codes: [404, 405]
            buffer_size: 50

        nested_main:
            type: stream
            path: '%kernel.logs_dir%/%kernel.environment%.log'
            level: debug

        # 3. Security channel — authentication failures, authorisation
        #    failures, anything implementing SecurityExceptionInterface.
        security:
            type: rotating_file
            path: '%kernel.logs_dir%/security.log'
            level: info
            max_files: 90
            channels: ['security']
            formatter: monolog.formatter.json

The key moves:

  • Dedicated exception channel with its own rotating log file, separate from the main application log.
  • JSON formatter — exception properties (the structured data we put on the class) get serialised as queryable fields, not flattened into a message string.
  • Fingers-crossed handler on main — a single error triggers the whole request's debug log being flushed, so you get full context around the failure without drowning in noise during healthy requests.
  • Separate security channel for anything implementing SecurityExceptionInterface, with longer retention.

The top-level kernel listener injects a channel-specific logger — Symfony auto-wires the channel by argument name (exceptionLogger resolves to the exception channel). Every thrown-and-logged exception ends up in var/log/exception.log as structured JSON.

A Monolog Processor for Typed Exception Data

Because we put exception data on real typed properties, we can reflect them out in a Monolog processor and attach them to every log record automatically. No manual context array at every throw site:

<?php

// ❌ Without structured exception data, callers end up repeating themselves
// at every throw site. Brittle, noisy, easy to get out of sync.
$this->logger->error($msg, ['sku' => $sku, 'qty' => $qty, 'available' => $available]);

// ✅ With typed exception properties and the Monolog processor, this all
// happens automatically — the processor reflects the properties onto the
// log record. The throw site just throws.
throw InsufficientStockException::create($sku, $qty, $available);

Here is the processor that makes it automatic:

<?php

declare(strict_types=1);

namespace App\Logging;

use App\Exception\AppExceptionInterface;
use Monolog\LogRecord;
use Monolog\Processor\ProcessorInterface;

/**
 * Monolog processor — enriches every log record that carries an
 * AppExceptionInterface with its typed properties.
 *
 * Because exceptions carry data as real properties (not inside message
 * strings), we can reflect them out here and every exception log entry
 * gets structured, queryable context for free.
 */
final class AppExceptionProcessor implements ProcessorInterface
{
    public function __invoke(LogRecord $record): LogRecord
    {
        $exception = $record->context['exception'] ?? null;
        if (!$exception instanceof AppExceptionInterface) {
            return $record;
        }

        $properties = [];
        foreach ((new \ReflectionObject($exception))->getProperties() as $property) {
            // Skip Exception base properties — monolog's own formatter covers those.
            if ($property->getDeclaringClass()->getName() === \Exception::class) {
                continue;
            }
            $properties[$property->getName()] = $property->getValue($exception);
        }

        $record->extra['app_exception'] = [
            'class'      => $exception::class,
            'properties' => $properties,
            'chain'      => self::walkChain($exception),
        ];

        return $record;
    }

    /** @return list<array{class: class-string, message: string}> */
    private static function walkChain(\Throwable $e): array
    {
        $chain = [];
        for ($current = $e; $current !== null; $current = $current->getPrevious()) {
            $chain[] = ['class' => $current::class, 'message' => $current->getMessage()];
        }
        return $chain;
    }
}

Wire it up in services.yaml:

# config/services.yaml — register the processor and tag it for Monolog.

services:
    App\Logging\AppExceptionProcessor:
        tags:
            - { name: monolog.processor, channel: exception }
            - { name: monolog.processor, channel: security }

Now every log entry that carries an AppExceptionInterface in its context automatically gets an extra.app_exception block with the full class name, every typed property, and the full previous chain. All of this ends up as indexed, queryable JSON fields in your log aggregation tool of choice.

PHPStan — Making @throws a Type Constraint

PHP has no checked exceptions in the language, but PHPStan can simulate them. With the right configuration, every exception class in your project hierarchy is treated as "checked" — meaning PHPStan will fail the build if a function throws (or calls a function that throws) one of your exceptions without declaring it in @throws.

# phpstan.neon — enforce that every thrown exception is declared via @throws.
#
# Combined with typed exception properties, this closes the loop: the set
# of exceptions a function can throw is part of its signature, and the
# type-checker fails the build if an @throws disappears or a new throw is
# added without documenting it.

includes:
    - vendor/phpstan/phpstan-strict-rules/rules.neon

parameters:
    level: 9
    paths:
        - src

    exceptions:
        check:
            missingCheckedExceptionInThrows: true
            tooWideThrowType: true
        implicitThrows: false
        uncheckedExceptionClasses:
            # Truly unexpected — the outer handler deals with these.
            - LogicException
            - RuntimeException
        checkedExceptionClasses:
            # Every project exception is a "checked" exception — PHPStan
            # will require @throws on every function in its call path.
            - App\Exception\AppExceptionInterface

Combined with the project-level hierarchy and typed properties, this closes the loop. The set of exceptions a function can throw is now part of its signature. Static analysis enforces it. Adding a new throw somewhere deep in the call graph surfaces as a required @throws update everywhere the exception can propagate — or a required try / catch at a natural boundary.

Bare RuntimeException and LogicException are left in uncheckedExceptionClasses on purpose — you do not want the type-checker demanding that every function declare that it might throw one. They are truly unexpected by definition. The outer handler catches them.

Putting It All Together: A Modern PHP 8.4 Exception

Here is the full pattern in one class, using the features PHP 8.4 gives us:

<?php

declare(strict_types=1);

namespace App\Exception\Order;

use App\Exception\AppException;
use App\Exception\UserFacingExceptionInterface;
use App\Value\CustomerId;
use App\Value\OrderId;
use Throwable;

/**
 * A fully modern PHP 8.4 exception using:
 *
 *   - Asymmetric visibility  — `public private(set)` so properties are
 *                              readable anywhere but only this class
 *                              can set them (during construction).
 *   - Property hooks         — a computed `summary` property, derived
 *                              from the typed data. No getter soup.
 *   - `new` without parens   — chainable construction syntax.
 *   - Typed class constants  — `const string` for the sprintf template.
 *   - Named arguments        — clear call sites at factories.
 */
final class OrderRejectedException extends AppException implements UserFacingExceptionInterface
{
    public const string MESSAGE_FORMAT =
        'Order %s for customer %s was rejected: %s.';

    /** Computed, read-only-from-outside via a property hook. */
    public string $summary {
        get => sprintf('[%s→%s] %s', $this->customerId->value, $this->orderId->value, $this->reason);
    }

    public function __construct(
        public private(set) OrderId $orderId,
        public private(set) CustomerId $customerId,
        public private(set) string $reason,
        ?Throwable $previous = null,
    ) {
        parent::__construct(
            message: sprintf(self::MESSAGE_FORMAT, $orderId->value, $customerId->value, $reason),
            previous: $previous,
        );
    }

    public static function create(
        OrderId $orderId,
        CustomerId $customerId,
        string $reason,
    ): self {
        return new self(orderId: $orderId, customerId: $customerId, reason: $reason);
    }

    public static function createWithPrevious(
        OrderId $orderId,
        CustomerId $customerId,
        string $reason,
        Throwable $previous,
    ): self {
        return new self(
            orderId: $orderId,
            customerId: $customerId,
            reason: $reason,
            previous: $previous,
        );
    }
}

// Call sites read naturally — no getters, no casts.
// $e = OrderRejectedException::create($orderId, $customerId, 'duplicate');
// $e->summary;          // computed
// $e->orderId->value;   // readable
// $e->orderId = ...;    // ❌ compile error — asymmetric visibility

Every rule from the top of this article is applied:

  • Extends an AppException base, implements the UserFacingExceptionInterface marker.
  • Data is on typed properties, not in the message.
  • Asymmetric visibility means those properties are read-only from outside, without the readonly pitfalls.
  • A property hook gives us a computed summary attribute with no getter boilerplate.
  • Named static factories — create and createWithPrevious — for self-documenting call sites.
  • The message is synthesised from a MESSAGE_FORMAT constant that tests can reuse.
  • The previous exception is always chainable via the dedicated factory.

Exceptions Are Not For Control Flow

If "not found" is an expected outcome of a finder method, do not throw — return null, or a result object. Exceptions are for failure, not for signalling a normal code path that happened to yield no result.

The moment "throw and catch" becomes part of the happy path, the signal value of exceptions degrades. Logs fill with noise. Stack traces become routine. The outer handler stops meaning "something genuinely went wrong" and starts meaning "one of fifty expected things happened". Every rule in this article depends on exceptions being rare and meaningful. Using them for control flow breaks that assumption at the root.

Python — Same Principles, Different Syntax

The principles port directly. Python's raise ... from previous is the language-level equivalent of PHP's previous constructor argument. Data goes on instance attributes, __str__ synthesises the message from them.

"""Python parallels — the same principles, different syntax.

- Subclass domain exceptions from `Exception`, never `BaseException`.
- Keep data in attributes, not f-strings inside the message.
- Use `raise ... from previous` to chain — the equivalent of PHP's
  `$previous` argument. Never `raise ... from None` unless you are
  deliberately hiding a noisy cause (rarely the right call).
"""

from dataclasses import dataclass


class AppError(Exception):
    """Root of the project's exception tree — catch-all marker."""


@dataclass
class InsufficientStockError(AppError):
    """Data on attributes; message is synthesised in __str__."""

    sku: str
    requested: int
    available: int

    MESSAGE_FORMAT = "Insufficient stock for SKU {sku}: requested {requested}, only {available} available."

    def __str__(self) -> str:
        return self.MESSAGE_FORMAT.format(
            sku=self.sku,
            requested=self.requested,
            available=self.available,
        )


# Usage — always chain with `from`:
def place_order(sku: str, qty: int, available: int) -> None:
    if qty > available:
        try:
            notify_warehouse(sku)
        except WarehouseTimeout as previous:
            raise InsufficientStockError(sku, qty, available) from previous
        raise InsufficientStockError(sku, qty, available)

Two Python-specific notes:

  • Subclass Exception, never BaseException. BaseException is reserved for things like SystemExit and KeyboardInterrupt that should generally not be caught.
  • raise ... from None suppresses the previous-exception chain. It exists for rare cases where the cause is a noisy implementation detail, but it is usually wrong. Default to raise ... from previous.

TypeScript — Error.cause and Nominal Typing via Class

TypeScript's story is cleaner than you might expect. ES2022 added Error.cause — the direct analogue of PHP's previous exception — and the Error constructor accepts it as a second argument via an options object.

// TypeScript parallels — same rules, ES2022 gives us `Error.cause`
// which is the direct analogue of PHP's `$previous`.
//
// - Extend `Error` (or a project base class) for every domain failure.
// - Put data on typed properties; synthesise the message from them.
// - Always pass `{ cause }` — never discard the underlying error.
// - Use `instanceof` at the boundary, not string matching on messages.

export abstract class AppError extends Error {
  // Marker base for project-owned errors.
}

export class InsufficientStockError extends AppError {
  public static readonly MESSAGE_FORMAT =
    'Insufficient stock for SKU %s: requested %d, only %d available.';

  public readonly name = 'InsufficientStockError';

  constructor(
    public readonly sku: string,
    public readonly requested: number,
    public readonly available: number,
    options?: { cause?: unknown },
  ) {
    super(
      InsufficientStockError.MESSAGE_FORMAT
        .replace('%s', sku)
        .replace('%d', String(requested))
        .replace('%d', String(available)),
      options,
    );
  }

  static create(sku: string, requested: number, available: number): InsufficientStockError {
    return new InsufficientStockError(sku, requested, available);
  }

  static createWithCause(
    sku: string,
    requested: number,
    available: number,
    cause: unknown,
  ): InsufficientStockError {
    return new InsufficientStockError(sku, requested, available, { cause });
  }
}

// At the boundary, narrow on the type — never parse message strings.
try {
  await orderService.place(order);
} catch (err) {
  if (err instanceof InsufficientStockError) {
    return { status: 409, body: { sku: err.sku, requested: err.requested, available: err.available } };
  }
  throw err; // bubble anything we did not reason about
}

Three TypeScript-specific notes:

  • instanceof works for domain error classes at runtime — use it in catch blocks, never parse the message.
  • Set this.name explicitly. The default "Error" string makes all errors look alike in logs.
  • The caught value in a catch block is unknown in modern TypeScript. Narrow with instanceof before accessing properties — the type system will force you to be honest about what you actually know.

Summary: The Checklist

Every exception in your project should pass all of these checks. Stick this list next to the keyboard:

  • Extends an AppException base class and implements AppExceptionInterface.
  • Extends LogicException (via AppLogicException) if it is a code bug; RuntimeException (via AppException) otherwise.
  • Implements any relevant marker interfaces (UserFacing, Retryable, Security).
  • All domain data lives on typed properties (PHP 8.4 public private(set)).
  • Message is synthesised from a MESSAGE_FORMAT class constant via sprintf.
  • Named static factories: create and createWithPrevious.
  • The previous exception is accepted and forwarded to the parent constructor — never dropped.
  • Low-level third-party exceptions are wrapped at the boundary, not leaked to the domain.
  • Declared in @throws on every function that throws or propagates it.
  • Tested by asserting on properties, and on messages built from the published constant.

Do this consistently and exceptions stop being an afterthought. They become a structured, testable, observable part of the system's contract — and the outer exception handler genuinely does mean "something unexpected happened" when it fires.