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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| Guzzle 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 | 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 | 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.
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.
Related how-tos
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
-
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
ClientExceptionInterfaceif and only if it could not send the request or parse the response, andNetworkExceptionInterfaceis 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 -
The 120 is
DEFAULT_BODY_SUMMARY_TRUNCATE_ATin guzzlehttp/psr7’sMessageclass. TherebodySummary()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’sBodySummarizeraccepts another length and thehttp_errorsmiddleware accepts another summarizer, so the figure is a default. The sentence it cuts is treated with less ceremony than the character. ↩︎ Back to text -
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