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.

In PHP, handle an unsuccessful HTTP status separately from a failed network transfer. A 404 or 500 means the server returned an HTTP response; a DNS failure, connection refusal, or timeout may leave you with no response to inspect. Then account for your client library’s behavior: cURL, Guzzle, Symfony HttpClient, and PHP’s HTTP stream wrapper do not report these conditions in the same way.

First identify what kind of failure occurred

“HTTP client error” can mean three different things. Keeping them distinct helps you preserve the information needed to respond or diagnose the problem.

  • HTTP status response: The request reached a server and received a status such as 404, 401, 429, or 500. The status may be unsuccessful for your application, but the response can still contain useful headers and a body.
  • Transport failure: DNS resolution, connecting, TLS negotiation, or waiting for a response failed. There may be no usable HTTP status, headers, or body.
  • Decoding or parsing failure: A response arrived, but its content could not be decoded into the form your code requested—for example, JSON decoding failed.

Symfony documents distinct HTTP, transport, and decoding exception categories; Guzzle likewise separates HTTP client/server exceptions from connection exceptions. See the Symfony HttpClient documentation and Guzzle quickstart. Do not turn all three into an empty result: that hides whether the request needs correction, retrying, or a change to response handling.

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

Choose handling based on your PHP HTTP client

PHP HTTP streams: retain the error response body

For the HTTP stream wrapper, the ignore_errors context option defaults to false. Set it to true when you need to read a body returned with an unsuccessful status, then inspect the response status rather than treating any returned text as success. PHP also documents response headers being available through $http_response_header when calls such as file_get_contents() encounter 4xx or 5xx responses. Redirects can produce multiple status lines, so use the relevant response metadata rather than assuming the first line is the final response. References: HTTP context options and HTTP wrapper.

<?php
$url = 'https://example.com/api/items/123';
$context = stream_context_create([
    'http' => [
        'ignore_errors' => true,
        'timeout' => 10,
    ],
]);

$body = file_get_contents($url, false, $context);
$headers = $http_response_header ?? [];

if ($body === false) {
    // The stream operation failed; inspect available metadata and logs.
    throw new RuntimeException('Could not retrieve an HTTP response.');
}

$status = null;
foreach ($headers as $header) {
    if (preg_match('~^HTTP/S+s+(d{3})b~', $header, $matches)) {
        $status = (int) $matches[1];
    }
}

if ($status === null) {
    throw new RuntimeException('No HTTP status line was available.');
}

if ($status < 200 || $status >= 300) {
    // Keep $status, $headers, and $body for logging or application handling.
    throw new RuntimeException("HTTP request returned status $status: $body");
}

The example is deliberately explicit about missing response data versus a non-2xx response. In production, use an application-specific exception or result type so callers can access the status, headers, and body without parsing an exception message. The stream wrapper has a simpler error model than dedicated HTTP clients; if you need structured transport exceptions, retries, or richer request configuration, consider a client library.

cURL: a completed transfer can still be an HTTP error

curl_exec() returning a body does not establish that the HTTP request succeeded. The PHP manual states: “Note that response status codes which indicate errors (such as 404 Not found) are not regarded as failure. curl_getinfo() can be used to check for these.” See PHP’s curl_exec() manual.

<?php
$url = 'https://example.com/api/items/123';
$ch = curl_init($url);
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HEADER => true,
    CURLOPT_TIMEOUT => 15,
]);

$result = curl_exec($ch);
if ($result === false) {
    $message = curl_error($ch);
    $errno = curl_errno($ch);
    curl_close($ch);
    throw new RuntimeException("cURL transfer failed ($errno): $message");
}

$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$headerSize = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE);
curl_close($ch);

$rawHeaders = substr($result, 0, $headerSize);
$body = substr($result, $headerSize);
if ($status < 200 || $status >= 300) {
    throw new RuntimeException("HTTP status $status; response body: $body");
}

Test explicitly for false, not with if (!$result): an empty response body can be valid. This example requests headers in the transfer so it can separate them from the body; for redirects or more involved header handling, use a header callback or otherwise account for multiple response blocks. The key distinction remains the same: first detect a transfer failure, then inspect the status code for an HTTP response.

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

Guzzle: decide whether statuses should throw

Guzzle’s http_errors option governs whether unsuccessful HTTP statuses produce exceptions. With it enabled, 4xx responses can raise a ClientException and 5xx responses a server exception; network problems use connection exceptions. The documented exception classes and behavior should be checked against the installed Guzzle major version. The stable quickstart describes the model at docs.guzzlephp.org.

When you want to inspect status and body directly, disable http_errors for that request and branch on the response:

<?php
use GuzzleHttpClient;
use GuzzleHttpExceptionConnectException;

$client = new Client(['timeout' => 15]);
try {
    $response = $client->request('GET', 'https://example.com/api/items/123', [
        'http_errors' => false,
    ]);
    $status = $response->getStatusCode();
    $headers = $response->getHeaders();
    $body = (string) $response->getBody();

    if ($status < 200 || $status >= 300) {
        // Decide using status and response details; do not discard the body.
        throw new RuntimeException("HTTP status $status: $body");
    }
} catch (ConnectException $e) {
    // No normal HTTP response was received. Log context; do not invent a status.
    throw new RuntimeException('Connection failed: ' . $e->getMessage(), 0, $e);
}

If instead you keep http_errors enabled, catch the HTTP exception when you need the attached response, and handle connection exceptions separately. Avoid catching only a broad exception and treating every case alike. For an HTTP exception, inspect whether it has a response before reading status, headers, or body; a connection exception may not have one.

Symfony HttpClient: handle status before reading content

Symfony HttpClient exposes status, headers, content, and decoded arrays through response methods. For 300–599 responses, getHeaders(), getContent(), and toArray() throw by default. Pass false to those methods when your code deliberately handles an unsuccessful status. Symfony distinguishes HttpExceptionInterface, TransportExceptionInterface, and DecodingExceptionInterface; consult the current Symfony documentation for the version you use.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
<?php
use SymfonyComponentHttpClientHttpClient;
use SymfonyContractsHttpClientExceptionDecodingExceptionInterface;
use SymfonyContractsHttpClientExceptionHttpExceptionInterface;
use SymfonyContractsHttpClientExceptionTransportExceptionInterface;

$client = HttpClient::create(['timeout' => 15]);
try {
    $response = $client->request('GET', 'https://example.com/api/items/123');
    $status = $response->getStatusCode();
    $headers = $response->getHeaders(false);
    $body = $response->getContent(false);

    if ($status < 200 || $status >= 300) {
        // Application decides what this status means; retain body and headers.
        throw new RuntimeException("HTTP status $status: $body");
    }

    // Decode only when the successful response is expected to contain JSON.
    $data = $response->toArray();
} catch (TransportExceptionInterface $e) {
    throw new RuntimeException('Transport failure: ' . $e->getMessage(), 0, $e);
} catch (DecodingExceptionInterface $e) {
    throw new RuntimeException('Response could not be decoded: ' . $e->getMessage(), 0, $e);
} catch (HttpExceptionInterface $e) {
    // Relevant if another response method triggered Symfony's HTTP exception.
    throw new RuntimeException('Unhandled HTTP response: ' . $e->getMessage(), 0, $e);
}

Symfony responses are lazy: the request call may return before the transfer is fully performed, and a transport failure can surface when a response method is called. Keep the try block around both request creation and response access if handling transport failures locally. If using toArray(false) to accept non-success statuses, remember that decoding can still fail independently of status handling.

Read the response without losing diagnostic detail

For an HTTP response your application rejects, retain the status, relevant headers, and body long enough to make a decision and record a useful diagnostic. The body might explain an invalid parameter, expired credential, or rate limit, but it may also contain sensitive data. Redact secrets and personal data before logging; do not expose raw upstream error bodies to end users by default.

  • Use the status to choose application behavior, not as a substitute for the body or headers.
  • Preserve response metadata in a structured error/result object when an upstream caller needs it.
  • For a transport failure, record the exception and request context that is safe to log; do not fabricate a status or empty success result.
  • For decoding failures, distinguish “response arrived but was not valid in the requested format” from a failed transfer.

Retry only when repeating the request is safe

An error is not automatically a reason to retry. A malformed request or missing authorization typically needs correction; repeating it unchanged will not fix it. A temporary server or throttling response may be retryable, but repeating a write can create duplicate effects unless the operation is idempotent or protected by an idempotency mechanism.

Before adding retries, decide which failures qualify, cap the number of attempts, use backoff, and establish whether repeating the operation is safe. Check your installed library’s configured retry policy: defaults are not shared across PHP clients. Symfony’s current documentation describes retries for selected status codes, with the set varying by method and some statuses limited to idempotent methods; consult the docs for the Symfony version in use before relying on exact behavior. Do not assume Guzzle, cURL, or streams share Symfony’s policy.

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

Troubleshoot common handling mistakes

“The call succeeded” but the API returned 404 or 500

Likely cause: code equates transfer success with HTTP success. With cURL, curl_exec() can return a response body for an HTTP error. Check the status separately with curl_getinfo(); with other clients, inspect whether their status methods throw by default.

The error body is missing

Likely cause: the client threw before your code read content, or PHP’s stream wrapper was not configured to retrieve the error body. For Guzzle, disable http_errors when you want to branch manually, or obtain the response attached to the HTTP exception. For Symfony, use getContent(false) after checking status. For streams, set ignore_errors to true and inspect available headers.

The code reports an empty body as a cURL failure

Likely cause: it tests the result’s truthiness. Test $result === false for transfer failure. An empty body and a failed transfer are not equivalent.

A catch block does not catch the network error

Likely cause: the failure category or timing differs from what the code expects. Guzzle connection errors are distinct from HTTP-status exceptions. Symfony may perform work lazily, so a transport error can emerge during a later response method call; include that access inside the relevant try block.

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

Retries produce duplicate writes or keep repeating a bad request

Likely cause: all failures are retried without considering status, transience, method safety, or attempt limits. Restrict retries to plausible transient cases, use bounded backoff, and ensure non-idempotent operations cannot be applied twice unintentionally.

Or skip the browser setup

For a website screenshot rather than a general-purpose PHP API response, ScreenshotNeo provides a one-request screenshot API. This is a separate use case from handling arbitrary HTTP client errors: it returns a screenshot or PDF for a URL. The API documentation is at ScreenshotNeo docs.

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

ScreenshotNeo removes cookie and consent banners, newsletter popups, and chat widgets before capture; those steps can be turned off. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, with response headers indicating the page verdict and billing status. Its MCP server lets AI agents use take_screenshot, get_page_info, and capture_pdf. The free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000 shots. See ScreenshotNeo and sign up free for 1,000 screenshots a month with no card.

Quick checklist

  • Did you distinguish an HTTP status from a transfer failure?
  • Does this library throw on unsuccessful statuses, or must you inspect the status yourself?
  • Can you preserve the status, headers, and body without leaking sensitive data?
  • Could the failure instead be a decoding problem?
  • Is retrying both plausibly useful and safe for this operation?

Frequently Asked Questions

Does a 404 mean PHP could not connect to the server?

No. A 404 is an HTTP status response, which means an HTTP response was received; a connection failure may occur before any response exists.

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.

Why can cURL return a body for an error status?

cURL transfer success and HTTP application success are separate checks. `curl_exec()` reports transfer failure with `false`; inspect the HTTP response code independently.

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.