How-to › Make calls that survive failure

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.

Audience
Library maintainer
Level
intermediate
Topic
Handle and design API errors
Languages
PHP
Verified

Users of your PHP client cannot tell what it throws, so they catch \Exception and swallow their own bugs along with your timeouts. One of them catches GuzzleHttp\Exception\ClientException by name, and the day you switch HTTP clients that handler stops matching. The 404 body they need to read is cut off at 120 bytes inside the message.

What you get

You will end up with a marker interface and five exception classes covering every failure your client throws, verified with PHPUnit against a mock handler. This is for you if you maintain a PHP package that calls an API and want callers to catch by meaning.

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
Guzzle exceptionsYour package depends on Guzzle anyway and wants the request, the response, and the handler context on every failureA 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 responseYour package accepts any PSR-18 client, or you want every status mapped by your own rule
PSR-18 exception interfacesA library that runs on whatever HTTP client the host app already installedThe interfaces say nothing about status codes, because a 4xx is a response to PSR-18, so every status mapping is yours to write and testYou ship with Guzzle only and want its richer exceptions without writing any
Symfony HttpClient exceptionsA Symfony app, or a client that wants lazy and concurrent responsesExceptions 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 callYour 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.

interface MeterApiException extends \Throwable
{
}
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 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 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 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.

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 before sending, while request() keeps the http_errors default of true 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 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.

php demo.php
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, 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.

        $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,
        );
vendor/bin/phpunit --no-configuration --bootstrap vendor/autoload.php --testdox tests
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,2 so a problem document whose detail runs past that ends in (truncated...). The response on the exception is intact.

php pitfall.php
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 returns a 404 without throwing and throws when getContent() or toArray() reads it, or when an unread response object is destroyed.3

php symfony.php
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.

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.

Footnotes

  1. The error handling section of 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. ↩︎ Back to text

  2. The 120 is DEFAULT_BODY_SUMMARY_TRUNCATE_AT in guzzlehttp/psr7’s Message class. 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. ↩︎ Back to text

  3. Symfony’s documentation calls the throw at destruction time part of the fail-safe design of the component. 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. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.