skip to content

Outbound HTTP

PHP calls other services through the bundled cURL extension or the Guzzle library, handling timeouts, TLS checks, errors and parallel requests. Interviewers probe timeouts and failure handling.

part ofPHPoverview, primer and where to startread it →
on this pageshow

explore

questions

10

In PHP's cURL extension, what does CURLOPT_RETURNTRANSFER change about curl_exec(), and how do you tell a failed transfer from an HTTP error?

level: juniorimportance: must knowfreq 62%

answer

  1. print versus return the body
  2. curl_exec returns string|bool
  3. false means the transfer broke
  4. curl_errno and curl_error after false
  5. CURLINFO_RESPONSE_CODE for the status

basics

~20 s

CURLOPT_RETURNTRANSFER makes curl_exec() return the body as a string instead of printing it. curl_exec() returns false only when the transfer itself fails (read curl_errno() and curl_error()); a 404 or 500 still succeeds, so check CURLINFO_RESPONSE_CODE.

solid answer

~40 s

By default `curl_exec()` writes the response body straight to output and returns `true`; with `CURLOPT_RETURNTRANSFER => true` it returns the body as a string. Either way it returns `false` only when the **transfer** fails: DNS, connection refused, a timeout, a TLS error. Then `curl_errno()` gives the `CURLE_*` code and `curl_error()` the message. An HTTP error status is not a transfer failure. A 404 or 503 comes back as a normal string, so after a successful exec I read `curl_getinfo($ch, CURLINFO_RESPONSE_CODE)` and decide myself. I compare with `=== false`, because an empty 204 body is `""`, which is falsy. Since PHP 8.0 `curl_init()` returns a `CurlHandle` object that is freed when it goes out of scope. `curl_close()` does nothing and is deprecated in PHP 8.5.

code

php · 22 lines
php
<?php
declare(strict_types=1);

$ch = curl_init('https://rates.example.test/v1/quote?zip=10115');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => ['Accept: application/json'],
]);

$body = curl_exec($ch);
if ($body === false) {
    throw new RuntimeException(sprintf(
        'Transfer failed (%d): %s', curl_errno($ch), curl_error($ch)
    ));
}

$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status !== 200) {
    throw new RuntimeException("Rates API answered HTTP $status");
}

$quote = json_decode($body, true, flags: JSON_THROW_ON_ERROR);

go deeper

for a junior

Recall the four calls and what RETURNTRANSFER does. Say clearly that curl_exec() returns false only on a transfer failure and that the status code comes from curl_getinfo().

for a middle

Explain the two failure layers and where each is reported: curl_errno/curl_error for the transfer, CURLINFO_RESPONSE_CODE for the HTTP status. Mention why the check is === false, and what FAILONERROR trades away.

for a senior

Show a checked wrapper that keeps the error body, logs curl_errno with timings from curl_getinfo, and never lets a 5xx page reach json_decode. Point out PHP 8.0's CurlHandle and the 8.5 curl_close deprecation during upgrades.

for a principal

Frame the choice between raw cURL and a client library around who owns error mapping: a single shared wrapper that turns transfer and status failures into typed exceptions keeps every caller from reinventing the checks.

## The four calls every cURL request makes PHP's **cURL extension** wraps libcurl, the C transfer library. Every request follows the same lifecycle: 1. `curl_init(?string $url = null)` creates a handle. Since PHP 8.0 that handle is a `CurlHandle` object; the function returns `CurlHandle|false`. 2. `curl_setopt($ch, $option, $value)` or `curl_setopt_array($ch, $options)` configures it with `CURLOPT_*` constants. `curl_setopt_array()` stops at the first option that fails and returns `false`, ignoring the rest. 3. `curl_exec($ch)` performs the transfer. Its declared return type is `string|bool`. 4. `curl_getinfo($ch, ?int $option = null)` reads what happened: the status code, timings, the final URL. With no option it returns an array with keys such as `http_code`, `total_time` and `primary_ip`. ## What CURLOPT_RETURNTRANSFER changes Without the option, libcurl hands the body to PHP's default write callback, which **prints it**, just like `echo`. That is almost never what an application wants: the body ends up in the page or the CLI output and your variable holds only `true`. | Setting | `curl_exec()` on success | on transfer failure | |---|---|---| | `CURLOPT_RETURNTRANSFER` not set | prints the body, returns `true` | `false` | | `CURLOPT_RETURNTRANSFER => true` | returns the body as a `string` (possibly `""`) | `false` | The manual shows the difference in its return-value notes: `curl_exec()` returns the result when the option is set, and `true` otherwise. ## Two different kinds of failure This is the part interviewers probe. There are two layers, and cURL reports them in different places: - **Transfer failure**: the request never produced an HTTP response. Examples are a DNS failure, a refused connection, a timeout or a certificate that does not verify. `curl_exec()` returns `false`, `curl_errno($ch)` returns a non-zero `CURLE_*` code (`CURLE_OPERATION_TIMEDOUT`, `CURLE_COULDNT_CONNECT`) and `curl_error($ch)` returns a readable message. When nothing failed, `curl_error()` returns an empty string and `curl_errno()` returns `0`. - **HTTP error status**: the server answered with 4xx or 5xx. At the transfer layer this is a **success**. The manual says it directly: status codes that indicate errors, such as 404, are not regarded as failure. `curl_exec()` returns the error page body, and you must read `curl_getinfo($ch, CURLINFO_RESPONSE_CODE)` (the older alias is `CURLINFO_HTTP_CODE`). `CURLOPT_FAILONERROR => true` changes that. A status of 400 or above then becomes a transfer failure with `CURLE_HTTP_RETURNED_ERROR`, but you lose the error body, which often carries the API's explanation. Most code leaves it off and checks the status itself. ## Checking a request properly The order matters, and the comparison must be strict: 1. Call `curl_exec()` and compare the result with `=== false`. A `!$body` test misreads a legitimate empty body (`""`, as a 204 returns) as an error. 2. On `false`, read `curl_errno()` and `curl_error()` straight away, before reusing the handle. The next transfer overwrites them. 3. On success, read `CURLINFO_RESPONSE_CODE` and treat anything outside the range you expect as an application error. 4. Only then decode the body, for example with `json_decode()`. Wrapping these four steps in one small function, used by every caller, stops each call site from inventing its own partial version of the checks. ## CurlHandle since PHP 8.0, and curl_close() in 8.5 Before PHP 8.0, `curl_init()` returned a `resource`, and `curl_close()` released it. PHP 8.0 replaced the resource with the final class `CurlHandle` (also `CurlMultiHandle` and `CurlShareHandle`). Consequences: - `is_resource($ch)` is now `false` for a valid handle. Old guard code built on it silently breaks; check `$ch instanceof CurlHandle` or `=== false` instead. - The handle is freed like any object, when its last reference goes away. `curl_close()` has had no effect since 8.0, and PHP 8.5 marks it `#[\Deprecated]`, so calling it now raises an `E_DEPRECATED` notice. Let the variable go out of scope, or `unset()` it. - Type declarations can say `CurlHandle $ch`, and passing a wrong value is a `TypeError`, not a warning. ## Common mistakes - Forgetting `CURLOPT_RETURNTRANSFER` and then wondering why `$response` is `true` and the JSON appeared in the page. - Treating a returned string as proof that the call worked, without looking at the status code. - Using `if (!$response)`, which confuses an empty body with a failure. - Reading `curl_error()` as a success test: it is a message, and an empty string means no transfer error, not a 2xx status. - Keeping `curl_close()` calls in PHP 8.5 code, which now emit deprecation notices for no benefit.

  • What does CURLOPT_FAILONERROR change, and why do many teams leave it off?
    With `CURLOPT_FAILONERROR => true`, a status of 400 or above makes `curl_exec()` return `false` with `CURLE_HTTP_RETURNED_ERROR`, so one `=== false` check catches both layers. The price is the response body: the API's error JSON, which often explains what was wrong, is discarded. Most code keeps it off and checks `CURLINFO_RESPONSE_CODE` itself.
  • Why does `is_resource($ch)` return false for a working cURL handle in PHP 8?
    Since PHP 8.0 `curl_init()` returns a `CurlHandle` object, not a resource, so `is_resource()` is false even for a valid handle. Guards written for PHP 7 must become `$ch instanceof CurlHandle`, or a `=== false` check on the return of `curl_init()`.
  • Should PHP 8.5 code still call curl_close()?
    No. `curl_close()` has had no effect since PHP 8.0, because the `CurlHandle` object is freed when its last reference disappears. PHP 8.5 deprecates it, so each call emits an `E_DEPRECATED` notice. Drop the call, or `unset($ch)` if you want the handle freed before the end of the scope.

saying these in an interview costs you the question

  • By default, curl_exec() returns false when the server answers 404 or 500.
  • Without CURLOPT_RETURNTRANSFER, curl_exec() still returns the body as a string.
  • An empty curl_error() string means the request got a 2xx status.
  • Checking if (!$response) is a safe failure test for curl_exec().
  • is_resource() is the right way to check a cURL handle in PHP 8.
  • curl_close() is required to free the handle in PHP 8.5.
open as a page

When you build a Guzzle Client with base_uri and default options, how are relative paths resolved, and what timeout applies if you set none?

level: juniorimportance: must knowfreq 58%

basics

~20 s

Guzzle resolves a request URI against base_uri by RFC 3986: 'rates' after 'https://api.test/v2/' gives /v2/rates, but '/rates' or a base without the trailing slash drops v2. The timeout option defaults to 0, which waits indefinitely.

open as a page

In PHP's cURL extension, how do CURLOPT_CONNECTTIMEOUT and CURLOPT_TIMEOUT differ, and what happens when neither is set?

level: middleimportance: must knowfreq 54%

basics

~20 s

CURLOPT_CONNECTTIMEOUT caps only connection setup (default 300 seconds); CURLOPT_TIMEOUT caps the whole transfer (default 0, meaning never). Without both, a hanging API can hold a PHP worker for minutes; set a short connect timeout and a total timeout.

open as a page

In Guzzle 7, what does the http_errors option control, and which exceptions do a 404, a 503 and a timed-out request raise?

level: middleimportance: must knowfreq 55%

basics

~20 s

With http_errors true (the default), a 404 throws ClientException and a 503 ServerException, both BadResponseExceptions carrying the response. A timeout or refused connection throws ConnectException, which since Guzzle 7 extends TransferException, not RequestException, and has no response.

open as a page

How do you unit-test code that uses a Guzzle client with MockHandler, and why wrap it in HandlerStack::create()?

level: middleimportance: should knowfreq 40%

basics

~20 s

Inject a Client whose handler is HandlerStack::create(new MockHandler([...])); queued responses and exceptions are returned in order without network access. The stack matters because http_errors and other options are middleware; a bare MockHandler never throws on 4xx or 5xx.

open as a page

How do PHP's curl_multi_* functions run several HTTP requests concurrently, and how do you read each transfer's result and error?

level: seniorimportance: should knowfreq 32%

basics

~10 s

Add configured CurlHandles to a curl_multi_init() handle, then loop curl_multi_exec() and curl_multi_select() until nothing is running. curl_multi_info_read() reports each finished handle with its CURLE_* result; curl_multi_getcontent() returns its body when RETURNTRANSFER was set.

open as a page

A PHP cURL call fails with a certificate error and a colleague sets CURLOPT_SSL_VERIFYPEER to false; what does that break, and what is the right fix?

level: seniorimportance: should knowfreq 44%

basics

~20 s

Disabling CURLOPT_SSL_VERIFYPEER accepts any certificate, so anyone on the path can impersonate the API and read credentials. Fix the trust store instead: install or point to a current CA bundle (curl.cainfo or CURLOPT_CAINFO) and keep VERIFYPEER true and VERIFYHOST 2.

open as a page

How do you send several requests concurrently with Guzzle using requestAsync() promises or a Pool, and how do you collect partial failures?

level: seniorimportance: should knowfreq 36%

basics

~20 s

getAsync() or requestAsync() return promises; the transfers run concurrently while you wait. Promise\Utils::settle() collects fulfilled and rejected results without throwing, unlike unwrap(). For many or unbounded requests, Pool limits in-flight requests (concurrency, default 25) and reports each via fulfilled/rejected callbacks.

open as a page

How do you add retries to a Guzzle client with Middleware::retry, and what does the decider see when pushed onto HandlerStack::create()?

level: seniorimportance: should knowfreq 38%

basics

~20 s

Push Middleware::retry($decider, $delay) onto a HandlerStack. The decider receives the retry count (starting at 0), the request, and a response or exception, and returns true to retry. Pushed after HandlerStack::create(), it sits inside http_errors, so a 5xx arrives as a response.

open as a page

In PHP 8.5, what does curl_share_init_persistent() let cURL reuse across requests, and why does it reject CURL_LOCK_DATA_COOKIE?

level: seniorimportance: nice to knowfreq 14%

basics

~20 s

curl_share_init_persistent() (PHP 8.5) returns a CurlSharePersistentHandle that outlives the request, so a worker can reuse DNS results, TLS sessions and open connections. Cookies are rejected with a ValueError because sharing them across requests could mix one user's cookies into another's calls.

open as a page