Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Some links on this page are affiliate links: if you buy through them we may earn a commission, at no extra cost to you.

Read an HTTP error body only after determining that an HTTP response actually exists. A 404 or 500 response has a status and body; DNS failures, refused connections and timeouts may have neither. The exact code depends on your PHP client: Guzzle exposes a response on response-bearing exceptions, Symfony HttpClient requires getContent(false) to read an error body without throwing, and Laravel returns 4xx/5xx responses normally unless you explicitly call throw().

HTTP status errors and transport failures are different

An HTTP failure means the server (or an intermediary) completed enough of the exchange to send a response. You can inspect a status such as 404, 429 or 500 and read the returned bytes. A transport failure happens before a usable HTTP response exists: DNS resolution can fail, a TCP connection can be refused, TLS negotiation can fail, or a timeout can expire. In that case there is no server response body to retrieve.

  • HTTP failure: status code, headers and usually a body are available.
  • Transport failure: handle the client’s connection/transport exception; do not assume a response object exists.
  • Decoding failure: a body was received, but it is not valid JSON (or does not match the shape you expected).

Keep those categories separate in logs and retry decisions. A 500 may be retried according to your policy; a malformed JSON response needs diagnosis; a DNS error needs network or configuration investigation.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Guzzle: get the exception response body

With Guzzle, 4xx and 5xx statuses become exceptions when the http_errors request option is enabled (the default in common configurations). Catch a response-bearing request exception, check hasResponse(), then read the status and stream. A connection problem can be a ConnectException and has no response.

<?php
use GuzzleHttpClient;
use GuzzleHttpExceptionRequestException;

$client = new Client();
$url = 'https://api.example.test/resource';

try {
    $response = $client->request('GET', $url);
    $status = $response->getStatusCode();
    $body = (string) $response->getBody();
} catch (RequestException $e) {
    if ($e->hasResponse()) {
        $response = $e->getResponse();
        $status = $response->getStatusCode();
        $body = (string) $response->getBody();
        // Preserve $body for diagnosis; decode it only after checking its format.
    } else {
        // No HTTP response: treat this as a transport failure.
        $status = null;
        $body = null;
    }
}

Disable automatic status exceptions when that is clearer

You can set 'http_errors' => false for a request or client. Then Guzzle returns a response for 4xx/5xx, and your code checks getStatusCode() explicitly. This is useful when one common response path handles success and API errors, but it does not remove the need to catch transport exceptions.

$response = $client->request('GET', $url, ['http_errors' => false]);
$status = $response->getStatusCode();
$body = (string) $response->getBody();

if ($status >= 400) {
    // Handle an HTTP error; the response exists.
}

Confirm the installed Guzzle major version and its options before copying examples from older documentation.

Symfony HttpClient: use getContent(false)

Symfony HttpClient’s getHeaders(), getContent() and toArray() methods throw for 3xx–5xx responses by default. Pass false to getContent() when you need the raw body while taking responsibility for status handling yourself.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
use SymfonyComponentHttpClientHttpClient;

$client = HttpClient::create();
$response = $client->request('GET', 'https://api.example.test/resource');

$status = $response->getStatusCode();
$body = $response->getContent(false);

if ($status >= 400) {
    // Inspect or report the raw error body.
}

The response is lazy: network work can be deferred until you call a response method. Explicitly call getStatusCode() and handle it; otherwise the response destructor can surface an unhandled status exception later. Symfony distinguishes HTTP-status, transport and decoding exception categories, so catch or report them separately.

Why not call toArray() first?

toArray() both reads the content and decodes JSON, and it can throw for a bad status or invalid JSON. Retrieve the raw content with getContent(false) first when debugging or when an API sometimes returns HTML, plain text or an empty body. Decode deliberately:

$raw = $response->getContent(false);
$data = json_decode($raw, true);

if (json_last_error() !== JSON_ERROR_NONE) {
    // Keep $raw; the response was received but was not valid JSON.
}

Laravel HTTP client: inspect the response or call throw()

Laravel’s HTTP client does not throw automatically for HTTP 4xx/5xx responses. Read the body and inspect status helpers directly.

use IlluminateSupportFacadesHttp;

$response = Http::get('https://api.example.test/resource');

if ($response->failed()) {
    $status = $response->status();
    $body = $response->body();

    if ($response->clientError()) {
        // 4xx response
    } elseif ($response->serverError()) {
        // 5xx response
    }
}

If your application prefers exceptions, make that choice explicit with throw(). A RequestException exposes its response through the public $response property.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
use IlluminateHttpClientRequestException;
use IlluminateSupportFacadesHttp;

try {
    $response = Http::get('https://api.example.test/resource')->throw();
} catch (RequestException $e) {
    $response = $e->response;
    $status = $response->status();
    $body = $response->body();
}

A network problem is represented separately by Laravel’s ConnectionException; it is not an HTTP response with an error body.

A reliable diagnostic sequence

  1. Identify the client and installed version. Defaults and method signatures differ between Guzzle, Symfony and Laravel releases.
  2. Establish whether a response exists. Check hasResponse() in Guzzle, distinguish Symfony transport exceptions, or catch Laravel’s connection exception.
  3. Capture the raw body. Use the client-specific method before JSON decoding.
  4. Record the status independently. A body alone does not tell you whether the server returned 400, 500 or a successful status containing an application-level error.
  5. Decode as a separate operation. Preserve the raw bytes if JSON parsing fails, and report decoding separately from the HTTP failure.
  6. Redact sensitive data. Authorization headers, cookies, tokens and response bodies may contain credentials or personal information; apply your production logging policy before storing them.

Common errors and fixes

“There is no response on the exception”

Usually the request failed during DNS, connection, TLS or timeout handling. In Guzzle, test hasResponse() before calling getResponse(). In Symfony and Laravel, handle the transport/connection exception branch instead of looking for a body.

“Calling Symfony getContent() throws before I can read the body”

Use $response->getContent(false), then inspect getStatusCode(). Do not suppress the exception and forget to check the status.

“Laravel never enters my catch block for a 500”

That is the default behavior. Inspect failed(), status() and body(), or add throw() and catch RequestException.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

“JSON decoding fails even though the request returned”

The server may have returned HTML, plain text, an empty body or malformed JSON. Save the raw body, inspect the Content-Type header, and only then decide whether to retry, report a protocol error or parse an alternate format.

“The body is empty”

Some status responses legitimately contain no content, and intermediaries can replace an upstream response. Treat an empty body as valid input to your error handler rather than assuming the client lost data.

Retries, performance and operational safety

Reading a body is normally inexpensive compared with establishing the connection, but large error pages can consume memory and log volume. Prefer bounded, redacted logging in production. Retry only failures your application can safely repeat; a transport timeout does not prove the server did not process a non-idempotent request. Use idempotency keys where the API supports them.

For high-throughput code, avoid decoding the same body repeatedly. Capture status, selected headers and the raw body once, then pass a structured error object to the rest of the application. Set explicit connect and total timeouts, and distinguish timeout, DNS and TLS diagnostics so operators can act on the cause.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Or skip the browser setup

If the response you need to inspect is a web page rather than an API payload, ScreenshotNeo can return a clean screenshot or PDF through one GET request. It accepts cookie/consent banners before capture and removes more than 60 known consent platforms, newsletter popups and chat widgets. Bot checks, blank pages, timeouts, failed loads and cache hits are not billed, and response headers identify the page verdict and billing result. Its MCP server provides take_screenshot, get_page_info and capture_pdf tools for Claude, Cursor and other MCP clients.

See the complete options in the ScreenshotNeo documentation. cURL:

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp

Python:

import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)

Node.js:

const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

Every plan includes the features: 1,000 screenshots per month are free with no card, and paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account.

FAQ

Should I catch every PHP exception as an HTTP error?

No. Catch the client’s HTTP-status exception separately from transport and decoding exceptions so your response and retry logic remain accurate.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Can an error body be JSON and still be unusable?

Yes. Valid JSON can have an unexpected schema, missing fields or an error format your application does not recognize. Validate the decoded structure after parsing.

Which client is best for applications that prefer explicit status checks?

All three support that style: disable Guzzle’s status exceptions, use Symfony’s non-throwing content call, or rely on Laravel’s default response behavior. Choose according to the framework and conventions already used by your project.

Frequently Asked Questions

Should I catch every PHP exception as an HTTP error?

No. Catch HTTP-status, transport and decoding failures as separate categories.

Can an error body be JSON and still be unusable?

Yes. Validate the decoded structure, not just whether JSON parsing succeeded.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Which client supports explicit status checks?

Guzzle, Symfony HttpClient and Laravel can all use explicit status handling, with the client-specific methods shown above.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.