Skip to content
System nominalUTC --:--:--Articles 080
MODULE 06 Lesson 18 / 18 45 MIN

Services and persistence

Outbound HTTP and a Runnable Service

Make an outbound call that cannot hang the request, retry only what is safe to retry, and assemble the module's pieces into a service you can run and curl.

A checkout handler calls an exchange-rate API to convert a total. The vendor has an incident, the connection is accepted and then never answers, and the handler sits there holding a PHP worker and a database connection until the request times out somewhere upstream. Nothing in the code is wrong in the sense of raising an exception. There is no upper bound in it at all on how long it is willing to wait.

The fix is three lines of curl options and one decision that most retry code gets wrong. A timeout converts a hang into a failure you can handle, and a retry policy decides which failures deserve another attempt. Get the policy wrong in the other direction and you turn a slow vendor into duplicate orders.

The outputs below came from two php -S processes on this machine: a fixture server with deliberately bad endpoints, and the service built at the end of the lesson, both on PHP 8.5.6 with MySQL 9.6.0.

cURL gives you two clocks, and file_get_contents gives you one

file_get_contents() can make an HTTP request. With a stream context you can set a timeout, read the body on an error status, and even get the headers. What it cannot do is tell you which phase ran out of time, and that distinction is the difference between tuning a firewall rule and tuning a slow endpoint.

<?php
declare(strict_types=1);

$context = stream_context_create([
    'http' => [
        'method'        => 'GET',
        'timeout'       => 2,
        'ignore_errors' => true,
        'header'        => "Accept: application/json\r\n",
    ],
]);

$started = microtime(true);
$body = @file_get_contents('http://127.0.0.1:8140/slow', false, $context);
printf("file_get_contents on /slow returned %s after %.2fs\n", get_debug_type($body), microtime(true) - $started);
printf("error_get_last: %s\n", error_get_last()['message'] ?? '(none)');
file_get_contents on /slow returned bool after 2.00s
error_get_last: file_get_contents(http://127.0.0.1:8140/slow): Failed to open stream: HTTP request failed!

The timeout worked. The diagnosis did not: the message says the request failed, not that a read stalled, and the only place the real information lives is the $http_response_header array, which the call populates as a side effect and which is empty when the request never got a response at all.

The status code is the sharpest example. Point the same function at an upstream that returns 503 with a body, and ignore_errors hands you that body with no indication of what the status was.

<?php
$context = stream_context_create(['http' => ['timeout' => 2, 'ignore_errors' => true]]);
$body = @file_get_contents('http://127.0.0.1:8143/', false, $context);

printf("body = %s\n", var_export($body, true));
printf("status is only in the header array: %s\n", json_encode($http_response_header ?? null));
body = 'upstream is down'
status is only in the header array: ["HTTP\/1.1 503 Service Unavailable","Content-Length: 20"]

The body of a failed request looks exactly like the body of a successful one. Any code that treats a returned string as success is one vendor incident away from storing an error page as a currency rate. cURL returns the status as a value you can compare before you look at the body, and it separates the clock in two.

OptionWhat it boundsWhat happens when it fires
CURLOPT_CONNECTTIMEOUT DNS, TCP and the TLS handshake, up to the first byte of the response. curl_errno() 28 with a connect-phase message, or 7 if the connection was refused.
CURLOPT_TIMEOUT The whole transfer, connect included. curl_errno() 28, and the status is whatever arrived before the stall.

A connect timeout of two seconds and a total timeout of thirty is the pairing that catches both kinds of incident: a box that has gone away fails fast, and an endpoint that starts answering and then stops fails in bounded time. Set only the total and you keep a worker busy for the full thirty seconds on a connection that was never going to be established.

curl_close is deprecated in PHP 8.5

It has had no effect since PHP 8.0, because the handle is released when the last reference goes away, and PHP 8.5 now prints a deprecation for calling it. Drop the call; there is nothing to leak.

The status code is the first thing you read, and the body is the second

A response object should carry the status and the body separately, because the meaning of the body depends on the status. This is the function everything else in the lesson is built on.

<?php
declare(strict_types=1);

final class HttpFailure extends RuntimeException
{
}

/** @return array{status: int, body: string} */
function fetchOnce(string $url, int $connectSeconds = 2, int $totalSeconds = 3): array
{
    $handle = curl_init($url);

    curl_setopt_array($handle, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_CONNECTTIMEOUT => $connectSeconds,
        CURLOPT_TIMEOUT        => $totalSeconds,
        CURLOPT_FOLLOWLOCATION => true,
        CURLOPT_MAXREDIRS      => 3,
        CURLOPT_USERAGENT      => 'wpsifero-lab/1.0',
        CURLOPT_HTTPHEADER     => ['Accept: application/json'],
    ]);

    $body  = curl_exec($handle);
    $errno = curl_errno($handle);

    if ($errno !== 0) {
        throw new HttpFailure(sprintf('curl error %d: %s', $errno, curl_error($handle)), $errno);
    }

    return [
        'status' => (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE),
        'body'   => (string) $body,
    ];
}

Three things in that function are deliberate. curl_exec() returning false is checked through curl_errno() rather than compared, because an empty body and a failed transfer both look false-ish. The status is read with curl_getinfo() after the transfer, on both the success and failure paths. And the transport failure is converted into an exception here, so no caller ever has to remember that curl_exec() has two failure channels.

<?php
// A refusal is immediate and has no status at all.
$handle = curl_init('http://127.0.0.1:9/');
curl_setopt_array($handle, [CURLOPT_RETURNTRANSFER => true, CURLOPT_CONNECTTIMEOUT => 2, CURLOPT_TIMEOUT => 5]);
$body = curl_exec($handle);
printf("curl_exec returned %s, errno=%d, error=%s, status=%d\n",
    get_debug_type($body), curl_errno($handle), curl_error($handle),
    (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE));
curl_exec returned bool, errno=7, error=Failed to connect to 127.0.0.1 port 9 after 0 ms: Could not connect to server, status=0

Status 0 is not an HTTP status, and treating it as one is a classic way to write a bug that only appears when the network is broken. Check curl_errno() first, always.

null is both a parse failure and a valid document

The obvious way to check a decode is to compare the result against null, and it is wrong in a way that takes a specific kind of body to expose. Run both cases:

<?php
printf("valid JSON null decodes to %s\n", get_debug_type(json_decode('null', true, 32)));
printf("an HTML error page decodes to %s\n", get_debug_type(json_decode('<html></html>', true, 32)));
valid JSON null decodes to null
an HTML error page decodes to null

Two different events, one return value. The function that separates them is json_last_error(), and it is the reason the retry client checks the error code rather than the decoded value:

Bodyjson_decode($body, true)json_last_error()
{"reference":"ORD-1001"} an array 0, no error
{"totalCents":12 null 4, syntax error
an HTML error page null 4, syntax error
an empty body null 4, syntax error
null null 0, no error
12900 an int 0, no error

Notice the last two rows as well. A JSON document can be a bare number, and a successful decode does not promise the object you expected. A rate endpoint that returns 1.09 instead of {"rate":1.09} parses cleanly, and the isset($payload['rate']) check in the provider is what catches it. JSON_THROW_ON_ERROR moves the same information into an exception, which is easier to attach a cause to and impossible to forget to check:

<?php
try {
    json_decode('{"totalCents":12', true, 32, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
    printf("%s: %s (code %d)\n", $e::class, $e->getMessage(), $e->getCode());
}
JsonException: Syntax error (code 4)

Both forms are fine. The version that breaks is the one that treats any non-array return as "empty" and moves on, because a proxy error page then becomes a valid empty response.

Retrying is safe only when the request can be repeated without a second effect

The flaky upstream in the fixture returns 503 for its first two calls in each five-second window. Here is a client that retries it, with a doubling delay and a hard limit on attempts.

<?php
declare(strict_types=1);

/** GET only. Retrying a POST here would send the order twice. */
function fetchJson(string $url, int $attempts = 3): array
{
    $delayMs = 200;

    for ($attempt = 1; ; $attempt++) {
        try {
            $response = fetchOnce($url);
        } catch (HttpFailure $e) {
            if ($attempt >= $attempts) {
                throw $e;
            }
            printf("  attempt %d: transport failure (%s), retrying in %dms\n", $attempt, $e->getMessage(), $delayMs);
            usleep($delayMs * 1000);
            $delayMs *= 3;
            continue;
        }

        if ($response['status'] >= 500 && $attempt < $attempts) {
            printf("  attempt %d: HTTP %d, retrying in %dms\n", $attempt, $response['status'], $delayMs);
            usleep($delayMs * 1000);
            $delayMs *= 3;
            continue;
        }

        if ($response['status'] >= 400) {
            throw new HttpFailure("upstream returned HTTP {$response['status']}: " . substr($response['body'], 0, 80));
        }

        try {
            $decoded = json_decode($response['body'], true, 32, JSON_THROW_ON_ERROR);
        } catch (JsonException $e) {
            throw new HttpFailure(sprintf('HTTP %d carried a body that is not JSON: %s',
                $response['status'], $e->getMessage()), previous: $e);
        }

        if (!is_array($decoded)) {
            throw new HttpFailure('expected a JSON object, got ' . get_debug_type($decoded));
        }

        return $decoded;
    }
}
$ php retry_demo.php
  attempt 1: HTTP 503, retrying in 200ms
  attempt 2: HTTP 503, retrying in 600ms
  final response after 0.81s: {"attempt":3,"status":"ok"}

Four rules are encoded in that loop, and each one is a bug avoided.

Only transport failures and 5xx statuses are retried. A 418 is not going to become a 200 on the second attempt, and retrying it wastes the caller's deadline:

$ php retry_4xx.php
threw HttpFailure: upstream returned HTTP 418: {"error":"short and stout"}

A 200 whose body is not JSON is a failure, not a success with a strange body. The fixture's /not-json endpoint returns an HTML error page with a success status, which is what a reverse proxy looks like when it is misconfigured:

threw HttpFailure: HTTP 200 carried a body that is not JSON: Syntax error
previous cause: JsonException: Syntax error

Attempts are capped, and the whole thing still fails when the upstream never recovers:

  attempt 1: transport failure (curl error 7: Failed to connect to 127.0.0.1 port 9 after 0 ms: Could not connect to server), retrying in 200ms
  attempt 2: transport failure (curl error 7: Failed to connect to 127.0.0.1 port 9 after 0 ms: Could not connect to server), retrying in 600ms
threw after 0.81s: curl error 7: Failed to connect to 127.0.0.1 port 9 after 0 ms: Could not connect to server

And the function is named fetchJson for a reason. It only ever issues GET. The moment you need to retry a POST, the question stops being about HTTP and becomes about the operation: did the first attempt reach the server and get applied? A retried POST /orders without an idempotency key creates two orders, and backoff does not make that correct. If the endpoint must be retried, the server has to support a key the client sends with every attempt so the second one is recognised as the same operation. If it does not, do not retry it: fail the request and let the caller decide.

Treat the two directions separately when you write the client. A transport failure before the request was sent is safe to repeat for any method, because nothing was applied. A 503 after the request arrived is not, since the upstream may have performed the write and failed while writing the response. The error code cannot tell you which happened, which is why idempotency has to come from the protocol, not from the retry loop.

Assembling the service puts the transaction after the outbound call

The pieces from this module fit together in a small front controller: a PDO connection with the options from the first lesson, a repository that inserts inside a transaction, a mapper that returns an object, and the rate client above. What separates a working version from a fragile one is the order of the four steps.

Validation comes first, before any I/O, because a request that cannot be valid should not cost a database connection or a network round trip. The outbound call comes second. The transaction opens third, wraps the insert, and commits. That ordering means a vendor that takes ten seconds cannot hold row locks or an open transaction for ten seconds, and it means a rate lookup that fails leaves nothing to clean up. Every failure path in the handler is then a mapping from an exception type to a status code, which is the part you can actually test.

Lab 45 minutes

Build the service. It needs a public/index.php front controller, a PDO connection with the options from the first lesson of this module, a repository that inserts an order inside a transaction, and the rate lookup above. Serve it with php -S 127.0.0.1:8142 -t svc/public svc/public/index.php and expose GET /health, POST /orders and GET /orders/{reference}.

The order of operations in POST /orders is the exercise. Decide where the validation goes, where the outbound call goes, and where the transaction opens, then answer: what breaks if the transaction is opened first and the outbound call happens inside it, and which of those consequences hurts the other requests on the same table?

Show the solution and the reasoning

The transaction opens after the outbound call, not before it. That ordering is the whole answer to the question, and it is worth stating the mechanism: a transaction holds locks, and a transaction held open across a network call holds those locks for as long as the vendor is slow. On a busy table that is a queue of requests waiting on one slow third party, which turns a vendor incident into your own outage. The cost of the ordering is that the rate you fetched can be stale by the time you commit, and a rate that is seconds old is a better trade than a lock held for seconds.

<?php
declare(strict_types=1);

namespace App;

use InvalidArgumentException;
use PDO;
use Throwable;

/** @param array<string, mixed> $input */
function createOrder(PDO $pdo, OrderRepository $orders, RateProvider $rates, array $input): array
{
    $customer = trim((string) ($input['customer'] ?? ''));
    $cents    = filter_var($input['totalCents'] ?? null, FILTER_VALIDATE_INT);
    $currency = strtoupper(trim((string) ($input['currency'] ?? 'EUR')));

    if ($customer === '') {
        throw new InvalidArgumentException('customer is required');
    }
    if ($cents === false || $cents < 1) {
        throw new InvalidArgumentException('totalCents must be a positive integer');
    }
    if (preg_match('/^[A-Z]{3}$/', $currency) !== 1) {
        throw new InvalidArgumentException('currency must be a three-letter code');
    }

    // Outbound first, so a slow vendor never holds a transaction open.
    $converted = $currency === 'EUR'
        ? $cents
        : (int) round($cents * $rates->rateFor('EUR', $currency));

    $pdo->beginTransaction();

    try {
        $order = $orders->insert($customer, $converted, $currency, $input['note'] ?? null);
        $pdo->commit();
    } catch (Throwable $e) {
        if ($pdo->inTransaction()) {
            $pdo->rollBack();
        }

        throw $e;
    }

    return $order;
}

Validation comes before both, and it is validation of shape only. filter_var() with FILTER_VALIDATE_INT rejects 12abc rather than truncating it, which is the difference between a 422 and a wrong total. The currency is normalised to upper case before it is checked, so gbp and GBP reach the same branch.

The front controller maps each failure to a status. InvalidArgumentException is 422, HttpFailure from the rate provider is 502, a PDOException on a uniqueness conflict is 409, and anything else is 500 with the class and message written to error_log() and nothing but a generic body returned. Handlers that leak exception messages to clients leak schema names, file paths and sometimes credentials.

Here is the service serving the three routes, against the fixture rate provider on port 8141:

$ curl -si http://127.0.0.1:8142/health | sed -n '1p;$p'
HTTP/1.1 200 OK
{"status":"ok"}

$ curl -si -X POST http://127.0.0.1:8142/orders -H 'Content-Type: application/json' \
    -d '{"customer":"Ana Ferreiro","totalCents":12900,"currency":"EUR"}' | sed -n '1p;$p'
HTTP/1.1 200 OK
{"id":20,"reference":"ORD-9020","customer":"Ana Ferreiro","cents":12900,"currency":"EUR"}

$ curl -si -X POST http://127.0.0.1:8142/orders -H 'Content-Type: application/json' \
    -d '{"customer":"Tomas Lindqvist","totalCents":12900,"currency":"GBP","note":"gift wrap"}' | sed -n '1p;$p'
HTTP/1.1 200 OK
{"id":21,"reference":"ORD-9021","customer":"Tomas Lindqvist","cents":11094,"currency":"GBP"}

$ curl -si -X POST http://127.0.0.1:8142/orders -H 'Content-Type: application/json' \
    -d '{"customer":"","totalCents":-5}' | sed -n '1p;$p'
HTTP/1.1 422 Unknown Status Code
{"error":"customer is required"}

$ curl -si http://127.0.0.1:8142/orders/ORD-1003 | sed -n '1p;$p'
HTTP/1.1 200 OK
{"id":3,"reference":"ORD-1003","customer":"Priya Raghunathan","total_cents":289900,"currency":"EUR","note":null,"placed_at":"2026-03-02 16:41:00"}

$ curl -si http://127.0.0.1:8142/orders/ORD-9999 | sed -n '1p;$p'
HTTP/1.1 404 Not Found
{"error":"not found"}

The second order shows the conversion working: 12,900 cents at 0.86 is 11,094. The rate came from the fixture, and if that fixture is not running the same request produces a different outcome entirely:

$ curl -s -w ' [HTTP %{http_code} in %{time_total}s]\n' -X POST http://127.0.0.1:8142/orders \
    -H 'Content-Type: application/json' -d '{"customer":"Priya Raghunathan","totalCents":5000,"currency":"GBP"}'
{"error":"rate lookup failed","detail":"curl error 7: Failed to connect to 127.0.0.1 port 8141 after 0 ms: Could not connect to server"} [HTTP 502 in 0.810215s]

$ mysql -u root m6lab -e "SELECT COUNT(*) FROM orders;"
7

Read the two lines together. The handler returned 502, not 500, and the row count did not change. Because the outbound call runs before beginTransaction(), there was no transaction to roll back and no row to clean up. Had the call been inside the transaction, that failed request would have written a partial row and then either rolled it back in a catch block or left it for the next handler to find.

Two limits worth keeping in mind. The reference is derived from the row id rather than from a count, because COUNT(*) is not unique under concurrency and an earlier draft of this service picked the same reference twice in a row, which the unique index refused. And none of this caches the rate, so every order pays a round trip; a short-lived cache in front of RateProvider is the next change to make, and it is a change to one class.

Official reference

The PHP manual for what this lesson uses.

Progress

Mark this lesson done and it stays marked in this browser.

Course complete

That is the last lesson of Modern PHP, from Syntax to Services. If the material was useful, the practice is what makes it stick: rebuild the final project from an empty directory without looking back at the lesson.