DEV Community

Cover image for Result Types in PHP: Stop Throwing Exceptions for Business Errors
Gabriel Anhaia
Gabriel Anhaia

Posted on

Result Types in PHP: Stop Throwing Exceptions for Business Errors


Open any mid-size Laravel or Symfony codebase and grep for throw new. Count what comes back. Plenty of those throws are not exceptional. They are the function's normal output. "User already exists." "Coupon expired." "Card declined." "Email not yet verified." None of them are surprises. Every one of them is a business outcome the caller was always going to hit, and every one of them is hidden behind a control-flow mechanism designed for the parts of the program that are not supposed to happen.

That is the bug.

This is a hot take. Exceptions are for exceptional cases. A PDO connection dying mid-transaction is exceptional. So is a TypeError from a bug. A user trying to redeem a coupon that expired last Tuesday isn't. It is the most predictable, most-documented branch of your business logic, and you are routing it through the language's panic channel.

For business rule violations, return a Result type. Keep the throw-and-propagate story for the things that genuinely break.

an exception thrown into the air being caught by a net, beside a closed envelope labelled Result that travels straight to a hand

What exceptions cost you when they carry business outcomes

Look at a use case that throws for every "business no":

public function execute(RedeemCouponInput $input): RedeemCouponOutput
{
    $coupon = $this->coupons->find($input->code)
        ?? throw new CouponNotFound($input->code);

    if ($coupon->isExpired($this->clock->now())) {
        throw new CouponExpired($input->code);
    }

    if ($coupon->alreadyRedeemedBy($input->customerId)) {
        throw new CouponAlreadyUsed($input->code, $input->customerId);
    }

    $coupon->redeem($input->customerId, $this->clock->now());
    $this->coupons->save($coupon);

    return new RedeemCouponOutput(
        couponCode: $coupon->code,
        discountCents: $coupon->discount->amountInMinorUnits,
    );
}
Enter fullscreen mode Exit fullscreen mode

Now the call site. Every caller that wants to handle these outcomes — and every caller does, that's the whole point of having them — writes the same shape:

try {
    $output = $this->redeem->execute($input);
    $response = new RedeemSucceeded($output);
} catch (CouponNotFound $e) {
    $response = new RedeemFailed('NOT_FOUND', $e->getMessage());
} catch (CouponExpired $e) {
    $response = new RedeemFailed('EXPIRED', $e->getMessage());
} catch (CouponAlreadyUsed $e) {
    $response = new RedeemFailed('ALREADY_USED', $e->getMessage());
}
Enter fullscreen mode Exit fullscreen mode

Three problems live in those eight lines.

The type system stops helping you. The signature of execute() says it returns RedeemCouponOutput. It lies. It also returns, via throw, one of three named outcomes. PHPStan and Psalm can be coaxed into modelling that with @throws annotations, but the language itself does not enforce it. Add a fourth outcome (CouponNotYetActive) to the use case and the compiler does not whisper a word. The new branch silently turns into a 500 in every caller that forgot.

Stack-unwinding control flow is invisible at the call site. redeem->execute($input) reads as a function call. It is, in practice, a five-way branch. Future-you reads this line and has to context-switch into "okay what throws here, scroll up, find the implementation, scan it." A returned value lives on the same line as the call. A thrown one lives somewhere up the stack you have to chase.

Performance is not the point, but cost is real. Every PHP exception captures a stack trace. On a hot path doing thousands of coupon redemptions a minute, you are allocating and serializing one stack trace per refusal to communicate "yep, expired again." That's fine. It's also waste, for an outcome you already knew was coming.

The deeper problem is the one architecture cares about: the use case is not telling the truth about its contract. A function whose contract includes "may also return one of three named refusals" should say that in the type. Exceptions do not say that in the type. Results do.

A small Result in PHP 8.3

PHP doesn't have sealed unions, generics, or pattern matching. It has readonly classes, enums, intersection and union types, match, and instanceof. That is enough to build a Result you can actually live with.

<?php declare(strict_types=1);

namespace App\Common;

/**
 * @template T
 * @template E
 */
interface Result
{
    public function isOk(): bool;
    public function isErr(): bool;
}

/**
 * @template T
 * @template-implements Result<T, never>
 */
final readonly class Ok implements Result
{
    /** @param T $value */
    public function __construct(public mixed $value) {}

    public function isOk(): bool { return true; }
    public function isErr(): bool { return false; }
}

/**
 * @template E
 * @template-implements Result<never, E>
 */
final readonly class Err implements Result
{
    /** @param E $error */
    public function __construct(public mixed $error) {}

    public function isOk(): bool { return false; }
    public function isErr(): bool { return true; }
}
Enter fullscreen mode Exit fullscreen mode

Three types. One interface, two readonly final implementations. PHP cannot enforce that Result<T, E> is sealed at the language level, but final on Ok and Err plus a code-review rule ("no other class implements Result") gets you most of the way. PHPStan with strict-rules enforces the rest.

The E slot deserves more thought. Stringly-typed errors (new Err('expired')) recreate the original problem in a different shape. You want a real type, and a native enum is the lightest one PHP offers:

<?php declare(strict_types=1);

namespace App\Redeem;

enum RedeemError: string
{
    case NotFound = 'NOT_FOUND';
    case Expired = 'EXPIRED';
    case AlreadyUsed = 'ALREADY_USED';
    case NotYetActive = 'NOT_YET_ACTIVE';
}
Enter fullscreen mode Exit fullscreen mode

For cases where the error needs to carry data (the expiry date, the customer who already used the coupon), pair the enum with a readonly DTO or use one error class per case:

abstract readonly class RedeemFailure
{
    public function __construct(public string $code) {}
}

final readonly class CouponNotFound extends RedeemFailure {}
final readonly class CouponExpired extends RedeemFailure
{
    public function __construct(string $code, public \DateTimeImmutable $expiredAt)
    {
        parent::__construct($code);
    }
}
final readonly class CouponAlreadyUsed extends RedeemFailure
{
    public function __construct(string $code, public string $customerId)
    {
        parent::__construct($code);
    }
}
Enter fullscreen mode Exit fullscreen mode

This is the closest PHP gets to a sealed union. A static analyser sees RedeemFailure as the parent and the three final subclasses as the exhaustive set. match against ::class rounds the corner.

Side by side: the same use case both ways

The exception version returns a single happy type and throws three named refusals. The Result version returns a single Result whose E is the union of those refusals.

public function execute(RedeemCouponInput $input): Result
{
    $coupon = $this->coupons->find($input->code);
    if ($coupon === null) {
        return new Err(new CouponNotFound($input->code));
    }

    if ($coupon->isExpired($this->clock->now())) {
        return new Err(new CouponExpired(
            $input->code,
            $coupon->expiresAt,
        ));
    }

    if ($coupon->alreadyRedeemedBy($input->customerId)) {
        return new Err(new CouponAlreadyUsed(
            $input->code,
            $input->customerId,
        ));
    }

    $coupon->redeem($input->customerId, $this->clock->now());
    $this->coupons->save($coupon);

    return new Ok(new RedeemCouponOutput(
        couponCode: $coupon->code,
        discountCents: $coupon->discount->amountInMinorUnits,
    ));
}
Enter fullscreen mode Exit fullscreen mode

The return type is Result. The reader of the signature now knows three things the exception version was hiding: the function can fail; the failures are part of the contract; and the failures are typed. PHPDoc adds the Result<RedeemCouponOutput, RedeemFailure> annotation so the analyser can refine it further.

The HTTP controller becomes a match:

public function __invoke(Request $request): Response
{
    $input = $this->parseInput($request);

    $result = $this->redeem->execute($input);

    if ($result instanceof Ok) {
        return new JsonResponse($result->value, 200);
    }

    return match (true) {
        $result->error instanceof CouponNotFound =>
            $this->error('NOT_FOUND', "Coupon not found.", 404),
        $result->error instanceof CouponExpired =>
            $this->error('EXPIRED', "Coupon expired on {$result->error->expiredAt->format('Y-m-d')}.", 422),
        $result->error instanceof CouponAlreadyUsed =>
            $this->error('ALREADY_USED', "You already used this coupon.", 422),
    };
}
Enter fullscreen mode Exit fullscreen mode

No try/catch. The control flow is one expression. The match is exhaustive over the four RedeemFailure subclasses; if you add CouponNotYetActive to the use case and forget to add an arm here, PHPStan flags the missing case at static-analysis time. Try/catch cannot offer that: PHP exceptions are open-class, so anything is throwable and nothing is exhaustive.

two function signatures side by side: one returns Output and throws three named exceptions, the other returns Result<Output, Failure> with the failure list visible

Where exceptions still belong

The hot take is not "delete every throw." Plenty of code should keep throwing.

  • Bugs. A TypeError, a DivisionByZero, an invariant violation that means the program reached a state your model said could not exist — throw. Let it unwind to the top-of-process handler. The right place for a bug is in the logs, not in a Result the caller has to inspect.
  • Infrastructure failures the caller cannot recover from. Database is down. The Redis connection refused. The payment gateway is rate-limiting you. These are not business outcomes; they are the world breaking. Throw, log, return a 503 from the boundary. A Result for "Postgres is unreachable" is theatre — the caller has nothing to do with it except surface a 5xx.
  • Constructor invariants. new Money(-100, 'EUR') should throw, because constructing a negative Money is a programming error, not a domain refusal. Constructors are the one place where a Result return type fights PHP itself.

Use the test: would the caller routinely write a recovery branch for this? If yes, it is a business outcome, return a Result. If no, and every caller would just bubble it up, keep the exception.

A typical use case ends up with both. The Result return type carries the named business refusals. The body still throws for "the database fell over" and "an invariant got violated." The boundary still has one final catch (\Throwable $e) that logs and renders a 500.

What this fixes that you might not notice yet

Day one, the type system starts telling the truth. The contract of the function is in the signature. New callers cannot forget to handle the failure modes; the type forces them. Static analysis goes from "checked at runtime, on the unhappy path the caller forgot about" to "checked at CI time, on every branch."

By week two, the test suite changes shape. Asserting "this use case returns an Err(CouponExpired) for an expired coupon" reads as a value comparison. Asserting "this throws CouponExpired" reads as control flow. The first is something PHPUnit, Pest, and Psalm all understand natively. The second is a special-case assertion baked into the test runner.

Six months in, the payoff lands. When a junior engineer adds CouponNotYetActive to the failure set, the type system walks them to every call site that needs to handle it. The exception version waits until a customer hits it in production, sees a 500, and files a ticket.

Business outcomes do not belong on the language's panic channel. Move them onto the return type.


If this was useful

The full version of this argument — including transactions across Result boundaries, how to mix Result and exceptions inside the same use case without going mad, and the chapter on where Result types actually pay off versus where they cost more than they save — is Chapter 22 of Decoupled PHP. The book takes the same hexagonal layout and pushes it through error handling, transactions, async adapters, and migrations of real Laravel and Symfony services.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)