Pop PHP
The Toolkit

HTTP Client

pop-http covers both directions of HTTP. The server half — the inbound request and the response you send back — is in Requests & Responses. This page is the outbound half: calling somebody else's API from inside your application. Pop\Http\Client wraps curl or PHP streams behind one interface, and adds authentication, middleware, promises and a mock handler for tests.

BASH
composer require popphp/pop-http

Making a Request#

The shortest form is a static call named after the verb. It returns a fully populated response object.

PHP
use Pop\Http\Client;

$response = Client::get('https://api.example.com/widgets');

That's the same thing as building a client and calling the verb on it, which is what you want as soon as the request needs any configuration:

PHP
use Pop\Http\Client;

$client   = new Client('https://api.example.com/widgets');
$response = $client->get();

get(), post(), put(), patch() and delete() all exist in both forms. A third form puts the method in the options array and dispatches with send(), which is what you reach for when the verb itself is a variable:

PHP
use Pop\Http\Client;

$client   = new Client('https://api.example.com/widgets', ['method' => 'POST']);
$response = $client->send();

Sending data is the data option:

PHP
use Pop\Http\Client;

$response = Client::post('https://api.example.com/widgets', [
    'data' => [
        'name'  => 'Widget',
        'count' => 123,
    ],
]);

With no type set, that goes out as application/x-www-form-urlencodedname=Widget&count=123. Setting type to Request::JSON sends the same array as a JSON body with a Content-Type: application/json header instead:

PHP
use Pop\Http\Client;
use Pop\Http\Client\Request;

$response = Client::post('https://api.example.com/widgets', [
    'data' => ['name' => 'Widget'],
    'type' => Request::JSON,
]);

Request::XML and Request::MULTIPART are the other two, and Request::URLENCODED names the default explicitly.

The verb methods go through __call and __callStatic, so an IDE will not autocomplete them.

The constructor takes its arguments in any order and sorts them by type — a URI string, an options array, a Pop\Http\Client\Request, a Pop\Http\Auth, or a handler. That's why the authentication and handler examples further down can drop an object into the same call without a named parameter.

Request Options and Headers#

Everything about a request that is not the URI goes in the options array. These are the keys worth knowing first:

Key Value Effect
method 'GET', 'POST', … The verb, when you dispatch with send() rather than a named method
base_uri a scheme and host Prefixed to the path passed to each verb call
headers array Request headers, as 'Name' => 'Value' pairs or 'Name: Value' strings
user_agent string Sets the User-Agent header
query array URL query string, always, whatever the verb
data array The request body, formatted per type
files array of paths Files read from disk and sent
type a Request::* constant URLENCODED, JSON, XML or MULTIPART
auto bool Return parsed content from the verb call instead of a response object
async bool send() returns a promise instead of a response
verify_peer bool Whether to verify the peer's TLS certificate
allow_self_signed bool Whether to accept a self-signed certificate

base_uri is what turns one client into a small API client. Construct it with no URI at all and pass a path to each call:

PHP
use Pop\Http\Client;

$client = new Client(['base_uri' => 'https://api.example.com']);

$client->get('/widgets');
$client->get('/widgets/42');
$client->delete('/widgets/42');

Query Strings and Bodies#

query and data are not interchangeable. query always becomes a URL query string; data becomes the body on a request that has one. Setting both on a POST sends the query in the URL and the data in the body, which is exactly what a paginated write endpoint tends to want.

Headers take either shape, and mixing them in one array works:

PHP
use Pop\Http\Client;

$client = new Client('https://api.example.com/widgets', [
    'headers'    => [
        'X-Request-Id'    => 'abc-123',
        'Accept-Language: en-GB',
    ],
    'user_agent' => 'my-app/1.0',
]);

File Uploads#

files reads each path from disk and sends it as an upload part, numbered file1, file2 and so on unless you key the array yourself. Pair it with Request::MULTIPART:

PHP
use Pop\Http\Client;
use Pop\Http\Client\Request;

$client = new Client('https://api.example.com/import', [
    'method' => 'POST',
    'files'  => ['/path/to/report.csv'],
    'type'   => Request::MULTIPART,
]);

$response = $client->send();

For anything the options array does not reach, build the request yourself. Pop\Http\Client\Request is the object the client wraps, and a client accepts one in place of a URI:

PHP
use Pop\Http\Client;
use Pop\Http\Client\Request;

$request = new Request('https://api.example.com/widgets', 'POST');
$request->createAsJson();
$request->addHeaders(['X-Custom-Header: Custom-Value']);
$request->setData(['name' => 'Widget']);

$response = (new Client($request))->send();

Request Factories#

createAsJson(), createAsXml(), createAsUrlEncoded() and createAsMultipart() set the Content-Type and format the data together — the same job the type option does, on the request object. Each has a static counterpart that builds the request in one call: Request::createJson($uri, 'POST', $data), and the matching createXml(), createUrlEncoded() and createMultipart().

Before a request goes anywhere, render() prints exactly what will be sent, which is the fastest way to settle an argument with an API's documentation:

PHP
use Pop\Http\Client;
use Pop\Http\Client\Request;

$client = new Client('https://api.example.com/widgets', [
    'method'  => 'POST',
    'data'    => ['foo' => 'bar'],
    'headers' => ['Authorization' => 'Bearer 123456789'],
    'type'    => Request::URLENCODED,
]);

echo $client->render();
TEXT
POST /widgets HTTP/1.1
Host: api.example.com
Authorization: Bearer 123456789
Content-Type: application/x-www-form-urlencoded
Content-Length: 7

foo=bar

Authentication#

Pop\Http\Auth builds the authorization header. Because the client constructor sorts its arguments by type, an Auth object drops straight into the call:

PHP
use Pop\Http\Auth;
use Pop\Http\Client;

$response = Client::get('https://api.example.com/me', Auth::createBasic('username', 'password'));

That sends Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ=. The other two common forms are the same shape:

PHP
use Pop\Http\Auth;
use Pop\Http\Client;

$bearer = Auth::createBearer('MY_AUTH_TOKEN');  // Authorization: Bearer MY_AUTH_TOKEN
$apiKey = Auth::createKey('MY_API_KEY');        // Authorization: MY_API_KEY

$response = Client::get('https://api.example.com/me', $bearer);

createKey() differs from the other two in that it takes the header name and an optional scheme, for the many APIs that want their key somewhere other than Authorization:

PHP
use Pop\Http\Auth;

Auth::createKey('MY_API_KEY', 'X-Api-Key');           // X-Api-Key: MY_API_KEY
Auth::createKey('MY_API_KEY', 'X-Api-Key', 'Token '); // X-Api-Key: Token MY_API_KEY

Include the trailing space in a custom scheme — createKey($key, 'Token ') — since it is concatenated with the token. Basic and Bearer carry their separator internally.

An Auth object with nothing to send throws Pop\Http\Exception when the header is built: "Error: The username and password values must be set for basic authorization" for an incomplete Basic, and "Error: The token is not set" for a bearer or key with no token. getAuthHeaderAsString() renders the header without sending anything, which is the quickest way to check what you have built.

Digest is the fourth form, and it needs the server's challenge parameters rather than a single secret:

PHP
use Pop\Http\Auth;
use Pop\Http\Client;

$digest = new Auth\Digest('test@example.com', 'username', 'password', '/', $serverNonce);

$response = Client::get('http://localhost/', Auth::createDigest($digest));

The five constructor arguments are realm, username, password, URI and the nonce the server issued. The request method defaults to GET; setMethod() changes it, and it has to match the request you actually send, because it goes into the response hash.

For a qop="auth" challenge, set the client's own counter and nonce with setNonceCount() and setClientNonce() before rendering the header.

For the other direction — verifying credentials someone sends to you — see Authentication.

Handling the Response#

Every verb call returns a Pop\Http\Client\Response. The status line, the headers and the body are all on it:

PHP
use Pop\Http\Client;

$response = Client::get('https://api.example.com/widgets');

$response->getCode();                            // 200
$response->getMessage();                         // 'OK'
$response->hasHeader('Content-Type');            // true
$response->getHeaderValueAsString('Content-Type'); // 'application/json'
$response->getBodyContent();                     // the raw body as a string

getHeaders() returns every header as an array of arrays — a header name maps to a list of values, because a header may legitimately appear more than once. getHeaderValueAsString() is the one you want for the usual single-valued case.

Reading a JSON body by hand is rarely what you want. Three methods parse it for you, and they differ in what they trust:

PHP
use Pop\Http\Client;

$response = Client::get('https://api.example.com/widgets');

$response->getParsedResponse(); // parses according to the Content-Type header
$response->json();              // parses as JSON regardless of Content-Type
$response->collect();           // the same, wrapped in a Pop\Utils\Collection

getParsedResponse() reads the Content-Type and picks a parser, so a text/plain response comes back as the raw string. json() ignores the header and tries anyway, which is what you want against an API that mislabels its responses — a body that is not JSON gives an empty array. collect() returns a Pop\Utils\Collection.

Setting auto to true moves that parsing into the client, so the verb call returns the data directly and the response object is still reachable when you need it:

PHP
use Pop\Http\Client;

$client = new Client('https://api.example.com/widgets', ['auto' => true]);

$data     = $client->get();          // an array
$response = $client->getResponse();  // the full Response object

A large family of is*() methods reads the status code without you writing a comparison — isSuccess(), isError(), isOk(), isCreated(), isNoContent(), isRedirect(), isUnauthorized(), isForbidden(), isNotFound(), isUnprocessableEntity(), isTooManyRequests(), isServerError() and more.

PHP
use Pop\Http\Client;

$response = Client::post('https://api.example.com/widgets', ['data' => ['name' => 'Widget']]);

if ($response->isCreated()) {
    $widget = $response->json();
} elseif ($response->isUnprocessableEntity()) {
    $errors = $response->json();
}

A 404, 422 or 500 comes back as an ordinary response, so check the status yourself — the body is parsed the same way a success is.

Handlers#

The handler is what actually moves the bytes. Four ship, and swapping one for another changes nothing above it:

Handler Uses Reach for it when
Curl ext-curl The default. Nothing to do.
Stream PHP stream wrappers ext-curl is unavailable
CurlMulti curl's multi interface Several requests should run at once
Mock nothing Tests — no network traffic at all

Pass one to the constructor, or set it afterward:

PHP
use Pop\Http\Client;
use Pop\Http\Client\Handler\Stream;

$client = new Client('https://api.example.com/widgets', new Stream());

$client->setHandler(new Stream());
$client->getHandler();

Each handler exposes its own low-level knobs, and getHandler() is how you reach them. Curl takes curl options; Stream takes stream context options. The client sets the ones that follow from the request — the URL, the method, the headers — so what you set here is the remainder:

PHP
use Pop\Http\Client;
use Pop\Http\Client\Handler\Curl;

$curl = new Curl();
$curl->setOptions([CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_0]);

$client = new Client('https://api.example.com/widgets');
$client->setHandler($curl);

Concurrent Requests#

CurlMulti runs several requests concurrently. Client::createMulti() builds one from a list of URLs or Request objects, and you pump it until it reports nothing still running:

PHP
use Pop\Http\Client;

$multiHandler = Client::createMulti([
    'https://api.example.com/widgets',
    'https://api.example.com/gadgets',
    'https://api.example.com/sprockets',
]);

$running = null;

do {
    $multiHandler->send($running);
} while ($running);

foreach ($multiHandler->getAllResponses() as $result) {
    echo $result['client_uri'] . ': ' . $result['code'];
}

getAllResponses() returns an array of arrays, each carrying client_uri, method, code and response — that last key holds the response object.

Mocking in Tests#

Mock is the handler to reach for in tests. It sends nothing, answers from a queue you fill, and records what it was asked for:

PHP
use Pop\Http\Client;
use Pop\Http\Client\Handler\Mock;
use Pop\Http\Client\Response;

$mock = new Mock();
$mock->queue(new Response(['code' => 200, 'body' => '{"result":"ok"}']));
$mock->queue(new Response(['code' => 404]));

$client = new Client('https://api.example.com/widgets', $mock);

$client->send(); // 200
$client->send(); // 404

$mock->getRequests();    // every Client\Request dispatched, in order
$mock->getLastRequest(); // the most recent one, or null

The queue is first-in, first-out, one response per request. when() registers a matcher that is consulted before the queue, so a test can pin one particular request's answer and leave the rest to the queue:

PHP
use Pop\Http\Client\Handler\Mock;
use Pop\Http\Client\Response;

$mock = new Mock();

$mock->when(
    fn($request) => $request->getMethod() === 'POST',
    new Response(['code' => 201])
);

Matchers are checked in registration order and the first match wins. Queue a \Throwable instead of a response to test the failure path — $mock->queue(new Pop\Http\Client\Handler\Exception('Simulated connection failure.')) makes the next send() throw it.

Running out of answers is itself an error, not a silent default: a request with no matching when() and an empty queue throws Pop\Http\Client\Handler\Exception with "Error: No matching handler or queued response for GET https://api.example.com/widgets." — so a test that makes one more call than you expected fails loudly and names the call.

Client Middleware#

Middleware wraps the outbound dispatch. Each layer sees the request on the way out and the response on the way back, and it applies to send(), sendAsync() and sendRequest() alike.

PHP
use Pop\Http\Client;

$client = new Client('https://api.example.com/widgets');

$client->addMiddleware(function ($request, $handler) {
    $request->addHeader('X-Trace-Id', 'abc-123');

    $response = $handler->handle($request);

    return $response;
});

$response = $client->send();

Registration order is wrap order, so the first middleware registered is outermost: it runs first on the way out and last on the way back. A closure of the shape function ($request, $handler) works directly; implement Pop\Http\Client\Middleware\MiddlewareInterface for a reusable class.

A middleware that never calls $handler->handle() short-circuits everything below it, including the handler, which is how a cache layer answers without touching the network:

PHP
use Pop\Http\Client;
use Pop\Http\Client\Response;

$client = new Client('https://api.example.com/widgets');

$client->addMiddleware(function ($request, $handler) use ($cache) {
    $key = md5($request->getUriAsString());

    if ($cache->hasItem($key)) {
        return new Response(['code' => 200, 'body' => $cache->getItem($key)]);
    }

    return $handler->handle($request);
});

The URI is hashed rather than used directly because / and : are reserved in a cache key — see Cache.

Retry and Logging#

Two middlewares ship ready to use. RetryMiddleware retries transient failures with exponential backoff and jitter:

PHP
use Pop\Http\Client;
use Pop\Http\Client\Middleware\RetryMiddleware;

$client = new Client('https://api.example.com/widgets');
$client->addMiddleware(new RetryMiddleware(3));

$response = $client->send();

Out of the box that retries up to three times on a network exception or a 429, 502, 503 or 504 — and only for GET, HEAD, PUT, DELETE and OPTIONS. POST and PATCH are not retried, because resending a request that may already have created something is how a customer gets charged twice. Every part of that is configurable, and setOnRetry() gives you a hook that fires once per attempt, before the sleep:

PHP
use Pop\Http\Client\Middleware\RetryMiddleware;

$retry = new RetryMiddleware();

$retry->setMaxRetries(5)
    ->setRetryableStatusCodes([429, 500, 502, 503, 504])
    ->setBaseDelay(0.1)
    ->setMaxDelay(10.0)
    ->setOnRetry(function ($attempt, $request, $response, $exception, $delaySeconds) {
        // record the attempt
    });

A Retry-After header on the response wins over the computed backoff for that attempt, capped by setMaxDelay(). A request whose body is not seekable is never retried at all, since a partly-consumed stream cannot be resent intact.

LoggingMiddleware writes one line per dispatch attempt to any PSR-3 logger, choosing the level from the outcome: info for a success, warning for a 4xx, error for a 5xx or a thrown exception.

PHP
use Pop\Http\Client;
use Pop\Http\Client\Middleware\LoggingMiddleware;
use Pop\Http\Client\Middleware\RetryMiddleware;
use Pop\Log\Logger;
use Pop\Log\Writer\File;

$logger = new Logger(new File(__DIR__ . '/../logs/app.log'));

$client = new Client('https://api.example.com/widgets');

$client->addMiddleware((new RetryMiddleware(3))->setOnRetry(LoggingMiddleware::logRetriesTo($logger)))
    ->addMiddleware(new LoggingMiddleware($logger));

The two compose purely by registration order. LoggingMiddleware registered after RetryMiddleware sits closer to the dispatch and logs every individual attempt; registered before, it logs only the final outcome. logRetriesTo() adapts the same logger onto the retry hook, so the log carries the reason and computed delay alongside each attempt.

Authorization, Cookie, Set-Cookie, X-Api-Key and Proxy-Authorization are logged as [REDACTED], and only the request's headers are logged — never the response's. Bodies are excluded entirely until you ask for them with setIncludeBody(true), which pairs with setMaxBodyLength().

Middleware runs for a single client; CurlMulti drives its own request loop, so put retry and logging inside the handler when you use Client::createMulti().

Promises#

An Async suffix on any verb stages the request instead of sending it, and hands back a Pop\Http\Promise. You never construct one — the client makes it, and you decide when it runs.

PHP
use Pop\Http\Client;

$promise = Client::getAsync('https://api.example.com/widgets');

$client->getAsync(), $client->sendAsync(), and ['async' => true] with a plain send() all produce the same thing.

From there, two ways to finish, and they are not interchangeable. wait() blocks until the request completes and returns the response:

PHP
use Pop\Http\Client;

$promise = Client::getAsync('https://api.example.com/widgets');

try {
    $response = $promise->wait();
    print_r($response->getParsedResponse());
} catch (Pop\Http\Promise\Exception $e) {
    // the response came back an error status
}

wait() throws Pop\Http\Promise\Exception when the response is an error status — this is the one place in the client where a 4xx or 5xx becomes an exception rather than a response you inspect. Pass false to suppress that and get null for a failure instead:

PHP
use Pop\Http\Client;

$promise  = Client::getAsync('https://api.example.com/widgets');
$response = $promise->wait(false); // a Response, or null on an error status

wait(false) suppresses the promise's error-status exception. A request that never reaches the server still throws Pop\Http\Client\Handler\Exception, so catch that too.

The other way is callbacks. then(), catch() and finally() register them, and resolve() is what actually runs the request:

PHP
use Pop\Http\Client;
use Pop\Http\Client\Response;
use Pop\Http\Promise;

$promise = Client::getAsync('https://api.example.com/widgets');

$promise->then(function (Response $response) {
    // a successful response
})->catch(function (Response $response) {
    // an error status — the response is still handed to you
})->finally(function (Promise $promise) {
    // always, after either branch
});

$promise->resolve();

Call resolve() on a promise you registered a then() against — wait() runs the request and returns the response, and resolve() is what runs the callback.

Passing true as a second argument to then(), catch() or finally() forces the resolve, for a promise whose whole configuration is one callback:

PHP
use Pop\Http\Client;
use Pop\Http\Client\Response;

Client::getAsync('https://api.example.com/widgets')->then(function (Response $response) {
    // runs immediately — no separate resolve() call
}, true);

Returning a promise from a then() forwards to it, so a second then() in the chain receives the second request's response:

PHP
use Pop\Http\Client;
use Pop\Http\Client\Response;

$promise1 = Client::getAsync('https://api.example.com/widgets');
$promise2 = Client::getAsync('https://api.example.com/gadgets');

$promise1->then(function (Response $response) use ($promise2) {
    return $promise2;
})->then(function (Response $response) {
    // the response from $promise2
});

$promise1->resolve();

setCancel() registers a callback that fires if the promise is canceled. And a client set to auto hands its promise the parsed content rather than a response object, so the callback signature changes with it.

CurlMulti has an async form of its own. $multiHandler->sendAsync() returns a promise whose wait() gives back the whole getAllResponses() array rather than a single response — the concurrency lives in the handler, and the promise wraps the batch.

Converting to and from curl Commands#

APIs document themselves in curl, and bug reports arrive the same way. Client::fromCurlCommand() turns one into a client you can send:

PHP
use Pop\Http\Client;

$client = Client::fromCurlCommand('curl -i -X POST -d"foo=bar&baz=123" http://localhost/post.php');

$response = $client->send();

The method, the URL, -d data and -H headers all survive the trip, so pasting a vendor's example into your code is a paste rather than a translation.

toCurlCommand() goes the other way, which is what you want in a bug report — a reproduction the other side can run without your application:

PHP
use Pop\Http\Client;
use Pop\Http\Client\Request;

$client = new Client('http://localhost/post.php', [
    'method' => 'POST',
    'data'   => ['foo' => 'bar', 'baz' => 123],
    'type'   => Request::URLENCODED,
]);

echo $client->toCurlCommand();
BASH
curl -i -X POST --data "foo=bar&baz=123" "http://localhost/post.php"

See Also#

  • Requests & Responses — the inbound half of pop-http
  • Authentication — verifying credentials that arrive at your application
  • Logging — the PSR-3 loggers LoggingMiddleware writes to
  • Cache — what a short-circuiting middleware reads from
  • pop-http README — the full API surface, including PSR-7/17/18 conformance and the server side