HTTP Clients, APIs and Email

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against PHP 8.5 (8.5.11, libcurl 8.14), checked on 2026-10-05, with Guzzle 7.15, Symfony HttpClient 8.1 and Symfony Mailer 8.1. Every output was captured from Bookshelf Lite’s small JSON API running under php -S, the clients below, and a Mailpit SMTP server. In Laravel, the Http facade (built on Guzzle) and the mail and notification system do the same work; see HTTP Client and Mail and Notifications.

Building a small JSON API

The API that the clients on this page call returns JSON with the right status codes, uses problem details (RFC 9457, application/problem+json) for errors, and accepts a signed webhook:

<?php
declare(strict_types=1);

$books = [1 => ['id' => 1, 'title' => 'Kindred', 'year' => 1979], 2 => ['id' => 2, 'title' => 'Dawn', 'year' => 1987]];
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);

function json(mixed $data, int $status = 200, string $type = 'application/json'): never
{
    http_response_code($status);
    header("Content-Type: $type");
    echo json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES), "\n";
    exit;
}

function problem(int $status, string $title, string $detail): never      // RFC 9457 problem details
{
    json(['type' => 'about:blank', 'title' => $title, 'status' => $status, 'detail' => $detail], $status, 'application/problem+json');
}

if ($path === '/api/books') {
    header('Cache-Control: public, max-age=60');
    json(['data' => array_values($books)]);
}
if (preg_match('#^/api/books/(\d+)$#', $path, $m)) {
    json(['data' => $books[(int) $m[1]] ?? problem(404, 'Not Found', "No book with id {$m[1]}")]);
}
if ($path === '/api/limited') {
    header('Retry-After: 30');
    problem(429, 'Too Many Requests', 'Slow down');
}
if ($path === '/api/webhooks/payment' && $_SERVER['REQUEST_METHOD'] === 'POST') {
    // Verify the sender: HMAC-SHA256 of "timestamp.body" with a shared secret, within 5 minutes
    $body = file_get_contents('php://input');
    $timestamp = (int) ($_SERVER['HTTP_X_TIMESTAMP'] ?? 0);
    $expected = 'sha256=' . hash_hmac('sha256', "$timestamp.$body", getenv('WEBHOOK_SECRET'));
    if (abs(time() - $timestamp) > 300 || !hash_equals($expected, $_SERVER['HTTP_X_SIGNATURE'] ?? '')) {
        problem(401, 'Unauthorized', 'Invalid signature');
    }
    json(['received' => json_decode($body, true, flags: JSON_THROW_ON_ERROR)['event']]);
}
problem(404, 'Not Found', "No route for $path");

The webhook check is the pattern used by payment providers and Git hosting services: the sender signs the raw body (plus a timestamp) with a shared secret, the receiver recomputes the HMAC over the raw bytes from php://input (not re-encoded JSON, which may differ byte for byte) and compares with hash_equals(), which takes the same time whether the first or the last character differs. The timestamp window stops an attacker from replaying a captured request later. The secret comes from the environment. A larger API (versioning, authentication with tokens, pagination, resources) is on Building APIs and API Resources.

Calling APIs with cURL

The curl extension wraps libcurl. A request is a CurlHandle configured with curl_setopt_array() and run with curl_exec(). Set timeouts on every request (the default is to wait forever), check for transport errors and HTTP status separately, and decode JSON with exceptions:

<?php
declare(strict_types=1);

/** GET or POST JSON with cURL, with timeouts and error handling. */
function request(string $method, string $url, ?array $json = null, array $headers = [], int $timeout = 5): array
{
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST => $method,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_CONNECTTIMEOUT => 2,
        CURLOPT_TIMEOUT => $timeout,
        CURLOPT_HTTPHEADER => [...$headers, 'Accept: application/json', ...($json !== null ? ['Content-Type: application/json'] : [])],
        CURLOPT_POSTFIELDS => $json !== null ? json_encode($json, JSON_THROW_ON_ERROR) : null,
    ]);
    $body = curl_exec($ch);
    if ($body === false) {
        throw new RuntimeException(sprintf('cURL error %d: %s', curl_errno($ch), curl_error($ch)));
    }
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    $type = curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
    return [$status, $type, json_decode($body, true, flags: JSON_THROW_ON_ERROR)];
    // no curl_close(): CurlHandle objects are freed automatically (curl_close() is deprecated since 8.5)
}

$base = 'http://localhost:8000';
[$status, $type, $data] = request('GET', "$base/api/books");
echo "$status $type ", json_encode($data), "\n";
[$status, $type, $data] = request('GET', "$base/api/books/9");
echo "$status $type ", json_encode($data), "\n";
try {
    request('GET', "$base/api/slow", timeout: 1);              // the server sleeps for 3 s
} catch (RuntimeException $e) {
    echo $e->getMessage(), "\n";
}
try {
    request('GET', 'http://localhost:1/');                     // nothing listens there
} catch (RuntimeException $e) {
    echo $e->getMessage(), "\n";
}

// Signed webhook call
$payload = ['event' => 'payment.succeeded', 'amount' => 1200];
$timestamp = time();
$signature = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . json_encode($payload), getenv('WEBHOOK_SECRET'));
[$status, , $data] = request('POST', "$base/api/webhooks/payment", $payload, ["X-Timestamp: $timestamp", "X-Signature: $signature"]);
echo "$status ", json_encode($data), "\n";
[$status, , $data] = request('POST', "$base/api/webhooks/payment", $payload, ["X-Timestamp: $timestamp", 'X-Signature: sha256=forged']);
echo "$status ", json_encode($data), "\n";
200 application/json {"data":[{"id":1,"title":"Kindred","year":1979},{"id":2,"title":"Dawn","year":1987}]}
404 application/problem+json {"type":"about:blank","title":"Not Found","status":404,"detail":"No book with id 9"}
cURL error 28: Operation timed out after 1002 milliseconds with 0 bytes received
cURL error 7: Failed to connect to localhost port 1 after 0 ms: Could not connect to server
200 {"received":"payment.succeeded"}
401 {"type":"about:blank","title":"Unauthorized","status":401,"detail":"Invalid signature"}

Since PHP 8.0, curl_init() returns a CurlHandle object instead of a resource and the handle is freed when it goes out of scope, so curl_close() does nothing; PHP 8.5 deprecated it. PHP 8.5 also added curl_share_init_persistent(), which creates a share handle that survives between requests in the same PHP-FPM worker, so DNS lookups, TLS sessions and open connections to an API are reused across requests instead of being rebuilt each time. Do not follow redirects (CURLOPT_FOLLOWLOCATION) for URLs that users supply; see the SSRF section of Web Security Fundamentals.

HTTP with stream contexts

PHP’s http:// stream wrapper makes simple requests with file_get_contents(), configured by a stream context. By default a 4xx or 5xx response makes it return false with a warning; ignore_errors returns the body instead, and http_get_last_response_headers() (8.4) gives the status line and headers:

<?php
declare(strict_types=1);

$context = stream_context_create(['http' => [
    'method' => 'GET',
    'header' => "Accept: application/json\r\n",
    'timeout' => 5,
    'ignore_errors' => true,                 // return the body of 4xx/5xx responses instead of false
]]);
$body = file_get_contents('http://localhost:8000/api/limited', context: $context);
echo http_get_last_response_headers()[0], ' | ', trim($body), "\n";
// HTTP/1.1 429 Too Many Requests | {"type":"about:blank","title":"Too Many Requests","status":429,"detail":"Slow down"}

Streams are fine for an occasional request without dependencies; for an application that talks to APIs, use cURL or a client library, which handle connection reuse, retries and asynchronous requests. Below HTTP, fsockopen() (or stream_socket_client()) opens a raw TCP connection with a timeout; writing a HEAD /up request to the local Bookshelf server through it returned HTTP/1.1 200 OK. Older books use it for hand-written HTTP; today it is only worth it for protocols that have no client library.

PSR-18 clients: Guzzle and Symfony HttpClient

PSR-18 defines Psr\Http\Client\ClientInterface::sendRequest(RequestInterface): ResponseInterface, so code (and SDKs) can depend on the interface and let the application choose the implementation. The two main libraries both implement it:

<?php
declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

use GuzzleHttp\Client;
use GuzzleHttp\Psr7\Request;
use Symfony\Component\HttpClient\HttpClient;

// Guzzle as a PSR-18 client: code depends on Psr\Http\Client\ClientInterface, not on Guzzle
$client = new Client(['base_uri' => 'http://api1:8000', 'timeout' => 5, 'http_errors' => false]);
$response = $client->sendRequest(new Request('GET', '/api/books/1', ['Accept' => 'application/json']));
var_dump($client instanceof Psr\Http\Client\ClientInterface, $response->getStatusCode(), (string) $response->getBody());
// bool(true)  int(200)  string(48) "{"data":{"id":1,"title":"Kindred","year":1979}}\n"

// Guzzle's own API throws for 4xx/5xx when http_errors is on (the default)
try {
    (new Client(['base_uri' => 'http://api1:8000']))->get('/api/books/9');
} catch (GuzzleHttp\Exception\ClientException $e) {
    echo get_class($e), ': ', $e->getResponse()->getStatusCode(), "\n";   // GuzzleHttp\Exception\ClientException: 404
}

// Symfony HttpClient: lazy responses, so requests run concurrently until you read them
$http = HttpClient::create(['timeout' => 5]);
$responses = [];
foreach ([1, 2] as $id) {
    $responses[$id] = $http->request('GET', "http://api1:8000/api/books/$id");
}
foreach ($responses as $id => $r) {
    echo $id, ': ', $r->toArray()['data']['title'], "\n";      // 1: Kindred  2: Dawn
}

Guzzle is the most used PHP HTTP client and the engine behind Laravel’s Http facade; it has middleware, retries and promises for concurrent requests. Symfony HttpClient returns lazy responses: request() starts the transfer and returns immediately, and the requests above ran concurrently until toArray() read them. For a production client, add retries with backoff for 429 and 5xx (respecting Retry-After), circuit breaking for a failing service, and logging of failures without secrets.

Sending email

mail() hands a message to the local sendmail program. It knows nothing about MIME, attachments, HTML alternatives, encodings or SMTP authentication, and with string headers it is open to header injection (see Forms, Validation and Output Escaping). Use a mail library: Symfony Mailer (which Laravel uses) or PHPMailer. The transport is configured by a DSN from the environment (an smtp:// DSN with the host, port and credentials, or an API transport for a provider):

<?php
declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

use Symfony\Component\Mailer\Mailer;
use Symfony\Component\Mailer\Transport;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Email;

$mailer = new Mailer(Transport::fromDsn(getenv('MAILER_DSN') ?: 'smtp://mailpit:1025'));

$email = (new Email())
    ->from(new Address('reviews@bookshelf.test', 'Bookshelf'))
    ->to(new Address('editor@bookshelf.test'))
    ->replyTo(new Address('ada@example.com', 'Ada Reader'))       // user input: validated by Address
    ->subject('New review of "Kindred"')
    ->text("Ada Reader rated Kindred 5/5:\nUnforgettable.")
    ->html('<p><strong>Ada Reader</strong> rated <em>Kindred</em> 5/5:</p><blockquote>Unforgettable.</blockquote>');

$mailer->send($email);
$ docker run -d -p 1025:1025 -p 8025:8025 axllent/mailpit     # SMTP on 1025, web UI and API on 8025
$ php mail.php
$ curl -s localhost:8025/api/v1/messages | jq -r '.messages[0] | "\(.Subject) | from \(.From.Address) | reply-to \(.ReplyTo[0].Address)"'
New review of "Kindred" | from reviews@bookshelf.test | reply-to ada@example.com
# headers of the stored message: Content-Type: multipart/alternative; Mime-Version: 1.0; Message-Id: <…@bookshelf.test>

The library built a multipart/alternative message with both the text and the HTML part, and the Address class rejects addresses containing line breaks (Email address contains control characters.), which closes header injection. In development, send to a local catcher such as Mailpit (as here) instead of real recipients. In production, configure SPF, DKIM and DMARC for the sending domain, send through a provider, and send from a queue so a slow SMTP server does not slow down the request (see Queues and Jobs).

Where older books and tutorials are outdated

  • curl_close() after every request, as in all PHP 7-era cURL code (Beak ch. 9, Kromann ch. 18), has done nothing since PHP 8.0 and is deprecated since 8.5; cURL handles are objects.

  • HMAC signatures compared with == and computed over the wrong (undefined) variable (Kromann ch. 18): compute over the raw body and compare with hash_equals().

  • mail() with hand-built headers (Kromann’s networking chapters, Nixon): use Symfony Mailer or PHPMailer, which Kromann already introduces.

  • SOAP and hand-rolled XML-RPC (Beak ch. 7) are rare in new integrations; JSON over HTTP with an OpenAPI description is the norm, and PSR-18 clients replace hand-written cURL wrappers.