What’s actually slowing this PC down?

Pick the symptom - the matching free tool is one click away.

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

Handle PHP HTTP failures in two separate paths: an HTTP error (the server returned a status and usually a body) and a transport error (no usable HTTP response exists). Then use the response API provided by your 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 without throwing unless you call throw().

The reliable sequence is: identify the client and version, determine whether a response exists, capture the raw body, inspect the status, and only then decode JSON. The examples below show that sequence for Guzzle, Symfony HttpClient, and Laravel’s HTTP client.

Start by separating HTTP errors from transport failures

An HTTP status such as 404, 401, 429, or 500 means the remote server completed enough of the exchange to send a response. That response may contain the most useful diagnostic information: a JSON error object, a request identifier, or an HTML error page.

A DNS failure, refused connection, TLS negotiation failure, timeout before headers, or other network problem can happen without an HTTP response. There is no response body to read in that case. Treating both categories as the same exception often produces misleading logs such as “the API returned no body” when the API was never reached.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
Failure category What exists What to do
HTTP 3xx–5xx Status, headers, and often a body Read the raw body, record the status, then apply your retry or application policy.
Transport or connection failure No trustworthy HTTP response Handle the client-specific transport exception; preserve the underlying message and retry only when appropriate.
Decode failure A body exists, but it is not valid data in the format you expected Keep the raw body and report decoding separately from the HTTP status.

A client-agnostic diagnostic order

  1. Confirm the installed client and major version. Defaults and exception names differ.
  2. Ask whether the failure includes an HTTP response. If not, stop looking for a body and handle the transport problem.
  3. Retrieve the raw body with the client’s body method before calling a JSON convenience method.
  4. Record and evaluate the status code independently of body parsing.
  5. Decode deliberately. If decoding fails, retain the raw body for a bounded, redacted diagnostic record.
  6. Never place authorization headers, cookies, access tokens, or unredacted sensitive response data in production logs.

Guzzle: read the response from a response-bearing exception

With Guzzle, 4xx responses are represented by ClientException and 5xx responses by ServerException when the http_errors request option is enabled. Both are response-bearing request exceptions. A connection problem is represented separately by ConnectException, and it may have no response.

Catch transport and HTTP failures separately

<?php
require __DIR__ . '/vendor/autoload.php';

use GuzzleHttpClient;
use GuzzleHttpExceptionConnectException;
use GuzzleHttpExceptionRequestException;

$client = new Client([
    'timeout' => 15,
    'http_errors' => true,
]);

try {
    $response = $client->request('GET', $url);
    $status = $response->getStatusCode();
    $body = (string) $response->getBody();
} catch (ConnectException $e) {
    // No HTTP response is available: DNS, connection, or transport failure.
    error_log('Guzzle connection failure: ' . $e->getMessage());
} catch (RequestException $e) {
    if ($e->hasResponse()) {
        $response = $e->getResponse();
        $status = $response->getStatusCode();
        $body = (string) $response->getBody();
        error_log('HTTP ' . $status . ' body: ' . substr($body, 0, 2000));
    } else {
        // A request exception without a response is still a transport failure.
        error_log('Guzzle request failure without response: ' . $e->getMessage());
    }
}

Check hasResponse() before calling getResponse(). The response body is a stream; casting it to a string reads the available contents. For large or sensitive bodies, read only what you need and redact before logging.

Use status inspection instead of exceptions when that fits your flow

Set http_errors to false when your application wants one return path for ordinary HTTP statuses. Transport failures can still throw, so retain a transport exception handler.

<?php
use GuzzleHttpClient;
use GuzzleHttpExceptionTransferException;

$client = new Client(['http_errors' => false, 'timeout' => 15]);

try {
    $response = $client->request('GET', $url);
    $status = $response->getStatusCode();
    $body = (string) $response->getBody();

    if ($status >= 400) {
        // Handle an HTTP error with its status, headers, and raw body.
    }
} catch (TransferException $e) {
    // No usable HTTP response was obtained.
}

Choose one policy consistently. A codebase that sometimes expects exceptions and sometimes expects status checks is harder to reason about, especially when shared middleware retries requests.

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

Symfony HttpClient: call getContent(false) for an error body

Symfony HttpClient’s response methods throw for 3xx–5xx by default. Passing false to getContent() suppresses that status-based exception for the body read; you then own the status decision.

Read the status and raw body explicitly

<?php
require __DIR__ . '/vendor/autoload.php';

use SymfonyComponentHttpClientHttpClient;
use SymfonyContractsHttpClientExceptionTransportExceptionInterface;

$client = HttpClient::create();

try {
    $response = $client->request('GET', $url, ['timeout' => 15]);
    $status = $response->getStatusCode();
    $body = $response->getContent(false);

    if ($status >= 400) {
        // Inspect or store the raw error body before deciding what to return.
    }
} catch (TransportExceptionInterface $e) {
    // DNS, connection, timeout, or another transport-level failure.
    error_log('Symfony transport failure: ' . $e->getMessage());
}

Symfony distinguishes HTTP-status, transport, and decoding failures. The response is lazy, so work can be deferred until a response method is called. An unhandled 3xx–5xx response can also surface through the response destructor; explicitly checking the status and reading content avoids relying on that fallback behavior.

Do not let toArray() hide a decoding problem

toArray() is convenient when the response is known to be successful JSON, but it combines body retrieval and decoding. During incident diagnosis, call getContent(false) first, preserve the string, and decode it yourself so an invalid or HTML body is distinguishable from an HTTP error.

<?php
$body = $response->getContent(false);
$data = json_decode($body, true);

if (json_last_error() !== JSON_ERROR_NONE) {
    error_log('Invalid JSON from upstream: ' . json_last_error_msg());
    // Keep $body for a redacted diagnostic record; do not assume an array.
}

Laravel HTTP client: inspect the response, or opt in to throwing

Laravel’s HTTP client does not throw automatically for 4xx and 5xx responses. Read the body with body() and use status(), failed(), clientError(), or serverError() to classify it. Connection problems are represented by ConnectionException.

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

Default, non-throwing workflow

<?php

use IlluminateHttpClientConnectionException;
use IlluminateSupportFacadesHttp;

try {
    $response = Http::timeout(15)->get($url);

    if ($response->failed()) {
        $status = $response->status();
        $body = $response->body();
        // Log or map the upstream error without losing the raw body.
    }
} catch (ConnectionException $e) {
    // No HTTP response was received.
    report($e);
}

Throw deliberately and inspect $e->response

<?php

use IlluminateHttpClientConnectionException;
use IlluminateHttpClientRequestException;
use IlluminateSupportFacadesHttp;

try {
    $response = Http::timeout(15)->get($url)->throw();
} catch (RequestException $e) {
    $response = $e->response;
    $status = $response->status();
    $body = $response->body();
    report($e);
} catch (ConnectionException $e) {
    // A connection failure has no response to inspect.
    report($e);
}

Use throw() when your service layer is designed around exceptions; otherwise, keep the default response-oriented style. In either style, inspect the status before assuming the body is JSON.

Read first, decode second

Error bodies are not guaranteed to match the success schema. A proxy may return HTML, a rate limiter may return plain text, and a server may send malformed JSON while reporting 500. Preserve the raw body before decoding and treat these as separate outcomes:

  • HTTP status: what the remote server reported.
  • Body availability: whether bytes were returned.
  • Body format: whether those bytes decode as the expected JSON or other format.
  • Application meaning: whether the decoded error fields are safe and useful to expose to your caller.

When parsing JSON in PHP, check the return value or use JSON_THROW_ON_ERROR in a narrowly scoped try/catch. Do not discard the original string when parsing fails; it is often the only clue that an intermediary, authentication gateway, or maintenance page responded.

Build a predictable error result

A small internal result object or associative structure keeps callers from knowing which HTTP library was used. Include a nullable status, a transport flag, the raw body (subject to retention policy), and a parsed payload only when decoding succeeded.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
<?php
function inspectJsonBody(?int $status, string $body): array
{
    $decoded = json_decode($body, true);

    return [
        'status' => $status,
        'transport_failure' => ($status === null),
        'body' => $body,
        'json' => (json_last_error() === JSON_ERROR_NONE) ? $decoded : null,
        'json_error' => (json_last_error() === JSON_ERROR_NONE)
            ? null
            : json_last_error_msg(),
    ];
}

Map this internal result to your public API deliberately. Upstream bodies can contain stack traces, user data, or credentials; expose only fields your consumers are allowed to see.

Troubleshooting common symptoms

“I caught an exception, but there is no response”

You likely have a transport failure or an exception type that does not carry a response. In Guzzle, test hasResponse() before getResponse(). In Symfony and Laravel, catch the transport category separately. Check DNS, firewall rules, proxy settings, TLS certificates, endpoint reachability, and timeout values.

“Reading the body throws again”

In Symfony, the default getContent() behavior throws for error statuses. Use getContent(false), then evaluate getStatusCode() yourself. In Guzzle, verify whether http_errors is enabled and choose either exception handling or status inspection intentionally.

“Laravel returned a 500 but no exception was caught”

That is the default Laravel behavior. Check failed() or serverError(), then call body(). Add throw() only if you want a RequestException for status failures.

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

“The body is empty”

First confirm that an HTTP response exists. A transport failure has no body. If a response exists, check whether a middleware or prior read consumed a stream, whether the server legitimately sent an empty body, and whether content was compressed or truncated by an intermediary. Record status and selected headers alongside the body length.

“JSON decoding fails even though the status is 200”

Status does not guarantee format. Capture the raw response, inspect its content type and first bytes, and check for an HTML login page, proxy message, or malformed JSON. Report decoding as its own failure rather than converting it into a generic HTTP error.

“Retries made the incident worse”

Retry only failures that are plausibly transient and safe for the operation. A connection timeout on an idempotent GET is different from a 400 validation error or a POST whose server may have processed the request. Bound attempts, use backoff, and honor upstream rate-limit signals. None of these policies can create a response body when the original failure occurred before HTTP headers were received.

Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Version and maintenance checks

Confirm the installed major versions before copying signatures. Guzzle’s current quickstart documents the exception and http_errors behavior, but older releases can differ. Symfony’s current documentation includes version-specific material, and the getContent(bool $throw = true) contract comes from the installed Symfony Contracts package. Laravel’s documented HTTP client behavior is for its 13.x documentation; verify the framework version in your application.

Free tools Windows power users keep installed

One-click scans. No signup required.

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

Pin dependencies, run an integration test against a known 4xx and 5xx response, and separately test DNS or connection failure. Those tests verify that your code does not accidentally assume every exception contains a response.

Or skip the browser setup

If the PHP workflow that led you here also needs webpage screenshots, ScreenshotNeo can return a screenshot or PDF from one request instead of maintaining a headless-browser setup. It accepts cookie and consent banners like a visitor and removes more than 60 known consent platforms, newsletter popups, and chat widgets before capture. Bot checks and CAPTCHAs, blank pages, timeouts, failed loads, and cache hits are not billed, and each response identifies the page verdict and billing result in headers. Its MCP server provides take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients.

See the ScreenshotNeo documentation for request options. A direct call looks like this:

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

The same request from 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)

And from 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 a month are free with no card; paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account to get an API key.

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

Frequently Asked Questions

Should I log the complete HTTP error body?

Usually not in production. Keep a bounded, redacted diagnostic copy and remove credentials, authorization data, personal information, and other secrets before storage.

Can a successful HTTP status still represent an application error?

Yes. A 2xx status only describes the HTTP exchange. Your application must still validate the response schema and any API-level error fields.

Which client should own retry decisions?

Put retry policy at one deliberate layer, close to the operation that understands idempotency and business consequences. Avoid stacking independent retries in a client, middleware, and job queue.

What is the safest first diagnostic value to preserve?

Preserve the status when a response exists and the raw body before attempting JSON decoding. If no response exists, preserve the transport exception details instead.

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

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.