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.
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.
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:
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:
use Pop\Http\Client;
$client = new Client('https://api.example.com/widgets', ['method' => 'POST']);
$response = $client->send();
Sending data is the data option:
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-urlencoded — name=Widget&count=123. Setting
type to Request::JSON sends the same array as a JSON body with a Content-Type: application/json header
instead:
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:
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:
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:
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:
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:
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();
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:
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:
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:
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:
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:
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:
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:
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.
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:
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:
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:
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:
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:
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.
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:
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:
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:
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.
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.
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:
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:
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:
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:
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:
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:
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:
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();
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
LoggingMiddlewarewrites 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