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");
Source: RFC 9457 — Problem Details for HTTP APIs, hash_hmac and hash_equals
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"}
Source: PHP Manual — cURL, curl_setopt and curl_getinfo
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
}
Source: PSR-18: HTTP Client, Guzzle documentation and Symfony — HTTP Client
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 withhash_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.
References
-
PHP Manual — cURL: curl_setopt, curl_getinfo, curl_share_init_persistent
-
hash_hmac and hash_equals
-
RFC: Persistent curl share handles (PHP 8.5)
-
Andrew Beak — PHP 7 Zend Certification Study Guide (Apress, 2017), Frank M. Kromann — Beginning PHP and MySQL, 5th ed. (Apress, 2018) and Robin Nixon — Learning PHP, MySQL & JavaScript, 5th ed. (O’Reilly, 2018)