Skip to content

HTTP client

naf/client implements PSR-18 for outbound HTTP calls. It selects the first available transport: cURL, then PHP streams. cURL supports bounded-memory request and response bodies; the stream-wrapper transport buffers bodies as strings.

Use this chapter in an existing NAF application. The cURL transport requires ext-curl; the fallback needs PHP URL streams enabled.

Required packages

In an existing NAF application:

composer require naf/client

Sending a request

use Nyholm\Psr7\Request;
use function Naf\Client\client;

$response = client()->sendRequest(new Request('GET', 'https://api.example.com/status'));
$status = $response->getStatusCode();
$body = (string) $response->getBody();

Inspect the status before using the body. HTTP 4xx and 5xx responses are returned to the caller; they do not by themselves trigger an exception or retry. Transport failures raise Naf\Client\Exception\ClientException. Casting the body to a string loads it into memory.

Send JSON

use Nyholm\Psr7\Request;
use function Naf\Client\client;

$request = new Request('POST', 'https://api.example.com/orders', [
    'Content-Type' => 'application/json',
    'Accept'       => 'application/json',
], json_encode(['sku' => 'ABC-1', 'quantity' => 2], JSON_THROW_ON_ERROR));

// Creating an order is not safe to repeat, so this call must not be retried.
$response = client()->withOptions(['retries' => 0])->sendRequest($request);

if ($response->getStatusCode() !== 201) {
    throw new RuntimeException('Order rejected with HTTP ' . $response->getStatusCode());
}

$order = json_decode((string) $response->getBody(), true, flags: JSON_THROW_ON_ERROR);

Headers and the body belong to the PSR-7 request; the client sends them unchanged. Add an Authorization header the same way, with a secret read from configuration. Check the status and decode the body yourself: the client does not interpret JSON or throw for 4xx and 5xx responses.

Configuration

Add a client array to app/config.php. Per-call withOptions() values override that configuration on a cloned client, leaving the shared instance unchanged.

Key under client Type Default Effect
retries integer 1 Additional attempts for recognized transient transport failures
retry_delay_ms integer 150 Delay between attempts in milliseconds
timeout number 20 Transfer timeout in seconds
connect_timeout number 8 cURL connection timeout in seconds
ssl_verify boolean true TLS certificate verification
cacert string or null automatic Explicit CA bundle file
http_version string auto cURL negotiation: auto, 1.1 or 2
max_redirects integer 5 cURL redirect limit; 0 disables following. The stream fallback never follows redirects (0.2.3+)
decode_content boolean true cURL response content decoding
streaming boolean false Require a streaming-capable transport

Transport-specific options may not apply to the stream-wrapper fallback. Keep TLS verification enabled and check the host's CA configuration when certificate verification fails.

Retries are on by default

The client recognizes selected TLS/network error messages, including timeouts and connection resets, for retry. It does not retry every failure. A timed-out POST may already have reached the remote service; repeating it can duplicate an operation.

Disable retries for operations that must not be replayed unless the remote API provides an appropriate idempotency mechanism:

$once = client()->withOptions(['retries' => 0]);
$response = $once->sendRequest($request);

Transport selection

Transports implement Naf\Client\Transports\TransportInterface. Its send() returns a pair: a response body string and a list of raw response header lines, including the HTTP status line. isAvailable() determines whether the client can select the transport.

Pass an ordered array of transport instances to new Client($transports) for custom selection. Fallback is based on availability; a failed cURL request does not automatically switch to the stream transport.

Use the default cURL transport for requests carrying credentials. Its redirect handling removes Authorization and Cookie when the destination origin changes. The PHP stream wrapper does not provide the same redirect controls. Restrict outbound destinations in the application when URLs can come from untrusted input; transport selection does not provide an SSRF policy.

Client 0.2.3 accepts absolute HTTP and HTTPS URLs with a host. Other schemes and relative URLs raise ClientException before transport selection or body consumption. Both shipped transports enforce this rule for direct calls; cURL also restricts libcurl protocols and redirect protocols to HTTP(S). A response without an HTTP status line is rejected.

This restriction does not decide which HTTP hosts, addresses or ports an application may contact. Keep the application's destination allow-list or SSRF policy, including redirects and DNS behavior.

In Client 0.2.3+, the PHP stream fallback returns the original redirect response, including its Location header and body, without following it. Use cURL when following redirects is required; max_redirects does not enable redirects in the stream fallback.

Streaming large bodies

The cURL transport reads the request body from its current position and spools the response to a temporary file before sendRequest() returns. The operation is synchronous and requires enough temporary disk space for the response. Close the body when finished:

$response = client()->sendRequest($request);
$body = $response->getBody();

try {
    while (!$body->eof()) {
        fwrite($target, $body->read(8192));
    }
} finally {
    $body->close();
}

This fragment assumes $target is an open writable resource owned by the caller. The client does not close request streams opened by application code. (string) $body still reads the entire response into memory.

Situation Behavior
Retried streaming request Seek back to the original request-body position
Non-seekable streaming request No automatic retry
decode_content => false Preserve encoded response bytes
Redirect Retain only the final response's headers and body
streaming => true Reject a transport without streaming support

A custom transport can implement StreamingTransportInterface in addition to the basic interface. Set streaming when buffering is unacceptable.

TLS and HTTP versions

Leave cacert unset to use automatic CA-bundle resolution. When the host needs an explicit bundle, configure its readable file path. The bundle is resolved once per call. Keep http_version on auto unless the remote service requires a specific version.

Testing without the network

The following complete example supplies a fake transport, simulates one timeout and verifies the retry and JSON response without making a network connection. Create the two files:

tests/FakeTransport.php
<?php

declare(strict_types=1);

namespace Tests;

use Naf\Client\Transports\TransportInterface;

final class FakeTransport implements TransportInterface
{
    public int $attempts = 0;

    public function isAvailable(): bool
    {
        return true;
    }

    public function send(string $url, string $method, array $headerLines, string $body, array $config): array
    {
        $this->attempts++;
        if ($this->attempts === 1) {
            throw new \RuntimeException('Connection timed out (simulated).');
        }

        return ['{"ok":true}', ['HTTP/1.1 200 OK', 'Content-Type: application/json']];
    }
}
bin/http-client-demo.php
<?php

declare(strict_types=1);

require dirname(__DIR__) . '/bootstrap.php';
require dirname(__DIR__) . '/tests/FakeTransport.php';

use Naf\Client\Core\Client;
use Nyholm\Psr7\Request;
use Tests\FakeTransport;

$transport = new FakeTransport();
$client = (new Client([$transport]))->withOptions(['retries' => 1, 'retry_delay_ms' => 0]);
$response = $client->sendRequest(new Request('GET', 'https://example.invalid/status'));

if ($transport->attempts !== 2 || $response->getStatusCode() !== 200
    || json_decode((string) $response->getBody(), true, flags: JSON_THROW_ON_ERROR) !== ['ok' => true]) {
    throw new RuntimeException('Unexpected transport result.');
}

echo "HTTP transport test passed.\n";

Run php bin/http-client-demo.php from the application root. Expect HTTP transport test passed. See Testing applications for service and HTTP tests.

If the result is different

What you see What to check
HTTP request failed: … The message comes from the transport: DNS, TLS, timeout or connection errors
No HTTP transport available Every transport passed to new Client([...]) reports isAvailable() === false; the default list always has the stream fallback
Requests fail without ext-curl The stream fallback needs allow_url_fopen; enable it or install ext-curl
The selected HTTP transport does not support streaming. streaming => true needs the cURL transport
A 404 or 500 response does not throw Expected: inspect getStatusCode() yourself
A POST arrived twice Set retries to 0 for operations that must not be repeated