# How to design an exception hierarchy for a PHP API client

> Give a PHP API client one marker interface and a small exception tree, so callers catch everything from your package in one clause, or one failure class at a time.

Source: https://voxgig.com/howto/exception-hierarchy-for-a-php-api-client

- Audience: library-maintainer
- Level: intermediate
- Languages: php
- Verified: 2026-10-21
- Published: 2026-10-17

## Short answer

Declare a marker interface, `MeterApiException extends Throwable`, and a `RuntimeException` subclass `ApiException` that implements it and carries the request and the response. Extend it with `ClientException` for 4xx, `ServerException` for 5xx, `ValidationException` for a 4xx with field errors, and `TransportException` for no response at all. Send through PSR-18, which returns 4xx and 5xx as responses, map the status in one place, and read the body from `getResponse()`, never from the message.

---
## You will need

PHP 8.3 or later, Composer, and a client that sends through a PSR-18 `ClientInterface`, which
Guzzle implements. Verified 2026-10-21 against PHP 8.3.6, `guzzlehttp/guzzle` 8.2.0,
`psr/http-client` 1.0.3, `symfony/http-client` 7.4.21, and `phpunit/phpunit` 12.5.40. The sample is a
client for an invented metering API with Guzzle's mock handler in place of the network, so
nothing on this page opens a socket. `npm ci` in the sample directory runs `composer install`
for you.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [Guzzle exceptions](https://docs.guzzlephp.org/en/stable/quickstart.html#exceptions) | Your package depends on Guzzle anyway and wants the request, the response, and the handler context on every failure | A dependency on one client's class tree, and a message that cuts the body at 120 bytes so the detail has to be read again from the response | Your package accepts any PSR-18 client, or you want every status mapped by your own rule |
| [PSR-18 exception interfaces](https://www.php-fig.org/psr/psr-18/) | A library that runs on whatever HTTP client the host app already installed | The interfaces say nothing about status codes, because a 4xx is a response to PSR-18, so every status mapping is yours to write and test | You ship with Guzzle only and want its richer exceptions without writing any |
| [Symfony HttpClient exceptions](https://symfony.com/doc/current/http_client.html#handling-exceptions) | A Symfony app, or a client that wants lazy and concurrent responses | Exceptions that fire when the content is read or when an unread response is destroyed, so a status check has to come first or the throw lands far from the call | Your users bring Guzzle or another PSR-18 client, which Symfony also provides through `Psr18Client` |

Guzzle's tree is the richest of the three, and it ties your package to Guzzle: a user who swaps
clients loses every catch clause written against it. PSR-18 is the portable floor, and it leaves
the status mapping entirely to you, since the standard forbids a client from treating a 404 as
an error. Symfony's client throws late, on `getContent()` or at destruction, which keeps
concurrent requests cheap and moves the exception away from the line that made the request.

## Declare one interface and one base class

Start with an empty interface that extends `Throwable`, and a base class that extends
`RuntimeException` and implements it. The interface is what a caller names in a catch clause
to take everything from your package, and the base class is where the request and the response
live.

```php title="src/Exception/MeterApiException.php"
interface MeterApiException extends \Throwable
{
}
```

```php title="src/Exception/ApiException.php"
class ApiException extends \RuntimeException implements MeterApiException
{
    public function __construct(
        string $message,
        private readonly RequestInterface $request,
        private readonly ?ResponseInterface $response = null,
        ?\Throwable $previous = null,
    ) {
        parent::__construct($message, $response?->getStatusCode() ?? 0, $previous);
    }

    public static function fromResponse(RequestInterface $request, ResponseInterface $response): static
    {
        return new static(self::describe($response), $request, $response);
    }
```

`fromResponse` builds the message from the problem document's `detail`, and `getProblem()`
decodes the body on demand as an array, so a caller branches on `type` and `errors` rather than
on message text. [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457.html) is the shape assumed;
an API that sends something else changes `describe()` and nothing else. The status rides along
as the exception code, which keeps `getCode()` meaningful in a log line.
[`Throwable`](https://www.php.net/manual/en/class.throwable.php) is the interface `Exception`
and `Error` share, and extending it is what lets an interface stand in a catch clause.

Four subclasses complete the tree. `ClientException` is any 4xx and `ServerException` any 5xx,
each an empty class, because the status and the body already sit on the base.
`ValidationException` extends `ClientException` and adds `getFieldErrors()`, a map from JSON
Pointer to detail read from the problem document's `errors` list. `TransportException` is the
case with no response, and it keeps the HTTP client's own exception as `getPrevious()`.

## Send through PSR-18 and map the status in one place

The portability comes from where the mapping sits. [PSR-18](https://www.php-fig.org/psr/psr-18/)
says a client MUST NOT treat a 4xx or a 5xx as an error and MUST return it, and MUST throw its
`ClientExceptionInterface` only when no response could be obtained at all.[^1] One catch clause
therefore covers every transport failure, and every status goes through one `decode` method.

```php title="src/Client.php"
private function send(RequestInterface $request): array
{
    try {
        $response = $this->http->sendRequest($request);
    } catch (ClientExceptionInterface $e) {
        // PSR-18 reserves this for "no response at all": a network failure, a timeout, a
        // malformed request. A 4xx or a 5xx is a response, and the client must return it.
        throw new TransportException($request, $e);
    }

    return $this->decode($request, $response);
}

/** @return array<string, mixed> */
private function decode(RequestInterface $request, ResponseInterface $response): array
{
    // The mapping reads the body more than once, and PSR-7 allows a body that cannot
    // rewind. Copy such a body once, so the exception still carries every byte of it.
    if (!$response->getBody()->isSeekable()) {
        $response = $response->withBody($this->streams->createStream((string) $response->getBody()));
    }

    $status = $response->getStatusCode();
    if ($status >= 500) {
        throw ServerException::fromResponse($request, $response);
    }
    if ($status >= 400) {
        throw ValidationException::describesFields($response)
            ? ValidationException::fromResponse($request, $response)
            : ClientException::fromResponse($request, $response);
    }
    if ($status < 200 || $status >= 300) {
        throw ApiException::fromResponse($request, $response);
    }
```

Guzzle keeps that contract on its PSR-18 method only. `Client::sendRequest()`
[sets `http_errors` and `allow_redirects` to false](https://github.com/guzzle/guzzle/blob/master/src/Client.php)
before sending, while `request()` keeps the
[`http_errors` default of true](https://docs.guzzlephp.org/en/stable/request-options.html#http-errors)
and throws `ClientException` or `ServerException` itself. A package built on `sendRequest()`
maps every status by its own rule, and the same code runs on any other PSR-18 client.

The validation rule here is about shape rather than status. Any 4xx whose problem document carries
an `errors` list with pointers becomes a `ValidationException`, so a 400 and a 422 from two
endpoints map the same way. An `errors` member of any other shape is skipped, not iterated:
most frameworks turn a PHP warning into an `ErrorException`, which no catch on the marker
interface takes. `decode` also copies a body that cannot rewind before reading it, because PSR-7
allows such a stream and the mapping reads the body more than once.

## Run every answer through the mock

Guzzle's [mock handler](https://docs.guzzlephp.org/en/stable/testing.html) answers requests
from a queue of responses and exceptions, which is all the demonstration needs: six answers,
five of them responses for `decode` and one a `ConnectException` for the network.

```bash
php demo.php
```

```text output
what each answer becomes
  200   ok                    Hall with 3 readings
  404   ClientException       404 Not Found: meter mtr_404 does not exist
  422   ValidationException   /name must not be empty; /reading must be a number
  500   ServerException       500 Internal Server Error: the readings store is unavailable
  503   ServerException       503 Service Unavailable  (no problem body)
  none  TransportException    GET /v1/meters/mtr_42 got no response: Connection refused
        previous: GuzzleHttp\Exception\ConnectException

one catch (MeterApiException) clause: 5 of 5 failures caught, 1 success returned
```

Each line is a class the caller can name, with the detail from the body in the message and the
whole body one call away. The 503 has no JSON, so `getProblem()` is null and the message falls
back to the reason phrase, while the class is still right. The last line shows the marker
interface at work: one clause catches all five failures and lets the success return as data.

## Check it worked

Run `vendor/bin/phpunit` with its [`--testdox` reporter](https://docs.phpunit.de/en/12.5/),
which prints each test name as a sentence. The suite drives a `ValidationException` test through the mock too,
and checks the one-clause claim across five failures.

```php title="tests/ClientTest.php"
        $caught = [];
        for ($i = 0; $i < 5; $i++) {
            try {
                $client->getMeter('mtr_42');
            } catch (MeterApiException $e) {
                $caught[] = (new \ReflectionClass($e))->getShortName();
            }
        }

        $this->assertSame(
            ['ClientException', 'ValidationException', 'ServerException', 'ApiException', 'TransportException'],
            $caught,
        );
```

```bash
vendor/bin/phpunit --no-configuration --bootstrap vendor/autoload.php --testdox tests
```

```text output
Client (Meterco\Tests\Client)
 ✔ A 404 is a client exception carrying the problem detail
 ✔ A 422 with an errors list is a validation exception keyed by pointer
 ✔ A 500 is a server exception
 ✔ A non json body still maps to its status class
 ✔ A network failure is a transport exception wrapping the psr 18 one
 ✔ One catch clause takes every failure the package throws
 ✔ A body that cannot rewind still reaches the exception
 ✔ A malformed errors list never escapes the marker interface
 ✔ A 204 is a success with nothing to decode
 ✔ A 2xx body that is not json is still an exception from the package
 ✔ The whole body is on the exception whatever the message says
 ✔ Guzzle itself truncates the body inside its exception message

OK (12 tests, 29 assertions)
```

`OK (12 tests, 29 assertions)` covers each status class, the network failure, the marker
interface over five failures, and both halves of the truncation pitfall below. Four more cover a
body that cannot rewind, a malformed `errors` list under a handler that throws on warnings, a
204, and a 200 that is not JSON.

## When it goes wrong

The detail you need is missing from the exception message. Guzzle's `request()` summarizes the
body into the message and cuts it at 120 bytes, a figure set in
[guzzlehttp/psr7's `Message` class](https://github.com/guzzle/psr7/blob/master/src/Message.php),[^2]
so a problem document whose `detail` runs past that ends in `(truncated...)`. The response on
the exception is intact.

```bash
php pitfall.php
```

```text output
the problem body is 402 bytes

request(), http_errors on (the default)
  throws            GuzzleHttp\Exception\ClientException
  message           Client error: `GET https://api.meterco.example/v1/meters/mtr_404` resulted in a `404 Not Found` response:
  body in message   120 of 402 bytes, then ' (truncated...)'
  getResponse()     402 bytes, detail intact: true

sendRequest(), the PSR-18 method
  returns           404 Not Found with 402 bytes of body, nothing thrown
```

Read the body from `getResponse()->getBody()` and decode it; never parse the message. Through
`sendRequest()` the question does not arise, because Guzzle returns the 404 and your `decode`
reads the stream.

A caller's handler stops matching after you change HTTP clients. They caught
`GuzzleHttp\Exception\ConnectException` because that is what your package used to let through.
Wrap every transport failure in your `TransportException` and document `getPrevious()` as the
place the client-specific detail lives, so their catch names your class and survives the swap.

With Symfony's client, the exception lands far from the call.
[Symfony HttpClient](https://symfony.com/doc/current/http_client.html#handling-exceptions)
returns a 404 without throwing and throws when `getContent()` or `toArray()` reads it, or when
an unread response object is destroyed.[^3]

```bash
php symfony.php
```

```text output
request() returned without throwing
getStatusCode()      404, no exception
getContent()         throws Symfony\Component\HttpClient\Exception\ClientException
getContent(false)    134 bytes, no exception
unassigned response  throws Symfony\Component\HttpClient\Exception\ClientException from its destructor
network failure      throws Symfony\Component\HttpClient\Exception\TransportException: Connection refused
```

Call `getStatusCode()` first, which Symfony documents as the opt-out, or pass `false` to
`getContent()`, and map the status yourself the way `decode` does. Its
`TransportExceptionInterface` is the counterpart of PSR-18's network exception, and the
component ships a `Psr18Client`, so a client built the way this page describes runs on Symfony
unchanged.

## When not to do this

Do not extend Guzzle's exceptions, and do not make your base class extend theirs as well as
yours. Their tree changes with their major versions, and a caller who catches
`BadResponseException` before your class gets the wrong handler first. Wrap, keep the original
as `getPrevious()`, and keep the tree yours.

Do not mint a class per status code. Sixty classes that nobody catches are worse than five that
everybody does, and the status sits on the instance for the rare caller who needs it. Add a
class when callers branch on it, and `NotFoundException` is usually the first.

Do not throw where the answer is an answer. A 404 from a method whose purpose is to ask whether
a meter exists is a `null` return. A caller who has to wrap an existence check in try and
catch will resent it.

Do not catch `\Throwable` inside the library to wrap everything. A `TypeError` in your own code
is a bug that should surface as itself, and the marker interface is for the failures of the API
rather than for yours.

## Related how-tos

- [Build a PHP SDK that takes any PSR-18 HTTP client](/howto/php-sdk-on-psr-18-http-clients)

- [Add an API key with Guzzle middleware in PHP](/howto/api-key-guzzle-middleware)

- [Return validation errors a client can map to a form](/howto/field-level-validation-errors-clients-can-map)

- [Translate upstream API errors behind your own API](/howto/translate-upstream-errors-behind-your-api)

## Last verified

Verified 2026-10-21 against PHP 8.3.6, `guzzlehttp/guzzle` 8.2.0, `guzzlehttp/psr7` 3.1.0,
`psr/http-client` 1.0.3, `symfony/http-client` 7.4.21, and `phpunit/phpunit` 12.5.40. Every output
block is what the command preceding it printed. Every response came from Guzzle's mock handler
or Symfony's `MockHttpClient`, so no request left the process, and the truncation figure is read
from the guzzlehttp/psr7 source rather than from documentation.

[^1]: The error handling section of [PSR-18](https://www.php-fig.org/psr/psr-18/) is mostly a
list of what is not an error. A well-formed response of any status MUST be returned. A client
MUST throw `ClientExceptionInterface` if and only if it could not send the request or parse the
response, and `NetworkExceptionInterface` is reserved for a network failure, timeouts included.
The "if and only if" carries the weight. To the client, a 500 is a successfully transported
response, and whether it is a failure is an opinion the calling library is left to hold.

[^2]: The 120 is `DEFAULT_BODY_SUMMARY_TRUNCATE_AT` in
[guzzlehttp/psr7's `Message` class](https://github.com/guzzle/psr7/blob/master/src/Message.php).
There `bodySummary()` reads that many bytes and appends `(truncated...)`. When the cut falls
inside a character of more than one byte, it reads up to three bytes more to confirm the
character, then cuts before it. Guzzle's
`BodySummarizer` accepts another length and the `http_errors` middleware accepts another
summarizer, so the figure is a default. The sentence it cuts is treated with less ceremony than
the character.

[^3]: Symfony's documentation calls the throw at destruction time
[part of the fail-safe design of the component](https://symfony.com/doc/current/http_client.html#handling-exceptions).
No error goes unnoticed, because an unhandled bad status throws when the response object is
destroyed. In PHP a destructor runs when the last reference goes, and for a return value nobody
assigned that is the moment the statement finishes. The exception therefore arrives on the line
that made the request after all, by a route the caller did not choose.