Reliably Integrating PHP Services with Transactional Outbox and Inbox Patterns
The dangerous moment in service integration is not the HTTP request. It is the commit immediately before or after it. If an order is committed but its event is never sent, downstream systems remain permanently unaware. If the event is sent before the commit and the transaction later rolls back, consumers act on an order that does not exist.
The transactional outbox pattern closes that gap by storing the business change and an event in one database transaction. A separate relay delivers committed events. The inbox pattern completes the design by making the consumer’s database effect idempotent.
This tutorial builds two PHP 8.3 services backed by PostgreSQL: an Orders service with an outbox and a Billing service with an inbox. Delivery is deliberately described as at least once. Retries can produce duplicate requests, but they cannot produce duplicate invoices.
Prerequisites and architecture
You need PHP 8.3 with the PDO PostgreSQL, cURL, and JSON extensions; PostgreSQL 14 or later; and command-line access to both databases. Production HTTP traffic should use TLS, although the local verification commands use loopback HTTP.
The flow is:
- The Orders API inserts an order and an
OrderCreatedevent in one transaction. - A relay briefly claims an outbox row, commits the claim, and then performs the network request.
- The Billing API inserts the event ID into its inbox and creates the invoice in one transaction.
- The relay marks the event published only after Billing returns success.
The relay never holds a database transaction open during HTTP. Its lease permits recovery after a crash, while FOR UPDATE SKIP LOCKED allows multiple relay processes to claim different rows.
Project structure
reliable-integration/
config.php
orders.php
inbox.php
relay.php
orders.sql
billing.sql
Create the databases
Use separate databases and credentials in production so neither service can modify the other service’s tables. The following schemas belong in orders.sql and billing.sql, respectively.
-- orders.sql
CREATE TABLE orders (
id uuid PRIMARY KEY,
amount_cents bigint NOT NULL CHECK (amount_cents > 0),
currency char(3) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE outbox (
id uuid PRIMARY KEY,
aggregate_id uuid NOT NULL REFERENCES orders(id),
event_type text NOT NULL,
payload jsonb NOT NULL,
occurred_at timestamptz NOT NULL DEFAULT now(),
available_at timestamptz NOT NULL DEFAULT now(),
attempts integer NOT NULL DEFAULT 0,
claimed_by text,
lease_until timestamptz,
published_at timestamptz
);
CREATE INDEX outbox_ready_idx
ON outbox (available_at, occurred_at)
WHERE published_at IS NULL;
-- billing.sql
CREATE TABLE processed_events (
event_id uuid PRIMARY KEY,
processed_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE invoices (
id uuid PRIMARY KEY,
order_id uuid NOT NULL UNIQUE,
amount_cents bigint NOT NULL CHECK (amount_cents > 0),
currency char(3) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
Create two PostgreSQL databases using your normal administrative process, then apply each file with an appropriately privileged account:
psql 'postgresql://[email protected]/orders' -f orders.sql
psql 'postgresql://[email protected]/billing' -f billing.sql
php -m | grep -E 'curl|json|pdo_pgsql'
The application accounts need only connection rights, schema usage, sequence usage where applicable, and SELECT, INSERT, and UPDATE on their own tables. They should not own the databases or receive schema-creation privileges.
Shared configuration and bounded database calls
Put connection creation and UUID generation in config.php. A connection timeout does not bound queries, so the code also configures PostgreSQL statement and lock timeouts.
<?php
declare(strict_types=1);
function requiredEnv(string $name): string
{
$value = getenv($name);
if ($value === false || $value === '') {
throw new RuntimeException("Missing environment variable: {$name}");
}
return $value;
}
function database(string $dsnName): PDO
{
$pdo = new PDO(
requiredEnv($dsnName),
requiredEnv($dsnName . '_USER'),
requiredEnv($dsnName . '_PASSWORD'),
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
PDO::ATTR_PERSISTENT => false,
]
);
$pdo->exec("SET statement_timeout = '3000ms'");
$pdo->exec("SET lock_timeout = '1000ms'");
return $pdo;
}
function uuidV4(): string
{
$bytes = random_bytes(16);
$bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40);
$bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80);
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4));
}
function jsonResponse(int $status, array $body = []): never
{
http_response_code($status);
header('Content-Type: application/json');
echo json_encode($body, JSON_THROW_ON_ERROR);
exit;
}
Include connect_timeout=3 in each PostgreSQL DSN, for example pgsql:host=127.0.0.1;port=5432;dbname=orders;connect_timeout=3. The three-second statement timeout is independent and explicit.
Write the order and event atomically
The Orders endpoint validates a small JSON command and writes both records in one transaction. Save it as orders.php.
<?php
declare(strict_types=1);
require __DIR__ . '/config.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Allow: POST');
jsonResponse(405, ['error' => 'method_not_allowed']);
}
try {
$input = json_decode(file_get_contents('php://input'), true, 32,
JSON_THROW_ON_ERROR);
$amount = filter_var($input['amount_cents'] ?? null, FILTER_VALIDATE_INT);
$currency = strtoupper((string) ($input['currency'] ?? ''));
if ($amount === false || $amount < 1 ||
preg_match('/^[A-Z]{3}$/', $currency) !== 1) {
jsonResponse(422, ['error' => 'invalid_order']);
}
$db = database('ORDERS_DSN');
$orderId = uuidV4();
$eventId = uuidV4();
$payload = json_encode([
'order_id' => $orderId,
'amount_cents' => $amount,
'currency' => $currency,
], JSON_THROW_ON_ERROR);
$db->beginTransaction();
$stmt = $db->prepare(
'INSERT INTO orders (id, amount_cents, currency) VALUES (?, ?, ?)'
);
$stmt->execute([$orderId, $amount, $currency]);
$stmt = $db->prepare(
'INSERT INTO outbox
(id, aggregate_id, event_type, payload)
VALUES (?, ?, ?, CAST(? AS jsonb))'
);
$stmt->execute([$eventId, $orderId, 'OrderCreated', $payload]);
$db->commit();
jsonResponse(201, ['order_id' => $orderId, 'event_id' => $eventId]);
} catch (JsonException) {
jsonResponse(400, ['error' => 'invalid_json']);
} catch (Throwable $error) {
if (isset($db) && $db->inTransaction()) {
$db->rollBack();
}
error_log($error->getMessage());
jsonResponse(500, ['error' => 'internal_error']);
}
A database outage fails the request rather than accepting an order without an event. Conversely, a relay outage does not block order creation; committed events remain available for later delivery.
Make Billing idempotent with an inbox
The relay authenticates the exact request body with HMAC-SHA256. Billing records the event ID before applying its effect, in the same transaction. Save this as inbox.php.
<?php
declare(strict_types=1);
require __DIR__ . '/config.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Allow: POST');
jsonResponse(405, ['error' => 'method_not_allowed']);
}
$body = file_get_contents('php://input');
$provided = $_SERVER['HTTP_X_EVENT_SIGNATURE'] ?? '';
$expected = hash_hmac('sha256', $body, requiredEnv('EVENT_SECRET'));
if (!hash_equals($expected, $provided)) {
jsonResponse(401, ['error' => 'invalid_signature']);
}
try {
$event = json_decode($body, true, 32, JSON_THROW_ON_ERROR);
$id = (string) ($event['id'] ?? '');
$type = (string) ($event['type'] ?? '');
$data = $event['data'] ?? [];
if ($type !== 'OrderCreated' ||
preg_match('/^[0-9a-f-]{36}$/D', $id) !== 1 ||
preg_match('/^[0-9a-f-]{36}$/D', $data['order_id'] ?? '') !== 1 ||
!is_int($data['amount_cents'] ?? null) ||
($data['amount_cents'] ?? 0) < 1 ||
preg_match('/^[A-Z]{3}$/D', $data['currency'] ?? '') !== 1) {
jsonResponse(422, ['error' => 'invalid_event']);
}
$db = database('BILLING_DSN');
$db->beginTransaction();
$insert = $db->prepare(
'INSERT INTO processed_events (event_id)
VALUES (?) ON CONFLICT DO NOTHING RETURNING event_id'
);
$insert->execute([$id]);
if ($insert->fetchColumn() !== false) {
$invoice = $db->prepare(
'INSERT INTO invoices
(id, order_id, amount_cents, currency)
VALUES (?, ?, ?, ?)'
);
$invoice->execute([
uuidV4(),
$data['order_id'],
$data['amount_cents'],
$data['currency'],
]);
}
$db->commit();
http_response_code(204);
} catch (JsonException) {
jsonResponse(400, ['error' => 'invalid_json']);
} catch (Throwable $error) {
if (isset($db) && $db->inTransaction()) {
$db->rollBack();
}
error_log($error->getMessage());
jsonResponse(500, ['error' => 'internal_error']);
}
If invoice creation fails, the inbox insertion rolls back too. If Billing commits but its response is lost, the relay sends the event again; the inbox conflict turns that retry into a successful no-op. The unique constraint on invoices.order_id provides an additional invariant, not a replacement for the inbox.
Build a short-transaction relay
Save the worker as relay.php. Its 20-second lease is comfortably longer than the one-second connection timeout and five-second total HTTP timeout.
<?php
declare(strict_types=1);
require __DIR__ . '/config.php';
pcntl_async_signals(true);
$stopping = false;
pcntl_signal(SIGTERM, function () use (&$stopping): void {
$stopping = true;
});
pcntl_signal(SIGINT, function () use (&$stopping): void {
$stopping = true;
});
$db = database('ORDERS_DSN');
$worker = gethostname() . ':' . getmypid();
$url = requiredEnv('BILLING_URL');
$secret = requiredEnv('EVENT_SECRET');
while (!$stopping) {
$db->beginTransaction();
$claim = $db->prepare(
"WITH picked AS (
SELECT id FROM outbox
WHERE published_at IS NULL
AND available_at <= now()
AND (lease_until IS NULL OR lease_until < now())
ORDER BY occurred_at
FOR UPDATE SKIP LOCKED
LIMIT 1
)
UPDATE outbox AS o
SET claimed_by = ?, lease_until = now() + interval '20 seconds',
attempts = attempts + 1
FROM picked
WHERE o.id = picked.id
RETURNING o.id, o.event_type, o.payload::text, o.attempts"
);
$claim->execute([$worker]);
$event = $claim->fetch();
$db->commit();
if ($event === false) {
usleep(250000);
continue;
}
if ($stopping) {
$release = $db->prepare(
'UPDATE outbox SET claimed_by = NULL, lease_until = NULL
WHERE id = ? AND claimed_by = ? AND published_at IS NULL'
);
$release->execute([$event['id'], $worker]);
break;
}
$body = json_encode([
'id' => $event['id'],
'type' => $event['event_type'],
'data' => json_decode($event['payload'], true, 32,
JSON_THROW_ON_ERROR),
], JSON_THROW_ON_ERROR);
$curl = curl_init($url);
curl_setopt_array($curl, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-Event-Signature: ' . hash_hmac('sha256', $body, $secret),
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 1,
CURLOPT_TIMEOUT => 5,
]);
curl_exec($curl);
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
$ok = curl_errno($curl) === 0 && $status >= 200 && $status < 300;
curl_close($curl);
if ($ok) {
$done = $db->prepare(
'UPDATE outbox
SET published_at = now(), claimed_by = NULL, lease_until = NULL
WHERE id = ? AND claimed_by = ? AND lease_until > now()'
);
$done->execute([$event['id'], $worker]);
} else {
$delay = min(60, 2 ** min((int) $event['attempts'], 6));
$retry = $db->prepare(
"UPDATE outbox
SET claimed_by = NULL, lease_until = NULL,
available_at = now() + CAST(? AS integer) * interval '1 second'
WHERE id = ? AND claimed_by = ?"
);
$retry->execute([$delay, $event['id'], $worker]);
}
}
The ownership conditions matter. If a request outlives its lease, another worker may reclaim the event. The original worker must not mark the newer worker’s claim complete. Duplicate delivery remains safe because Billing owns the idempotency boundary.
Run and test the complete path
Use separate terminals for these commands. Environment variables keep credentials out of source control, although a production secret manager or protected environment file is preferable to shell history.
export ORDERS_DSN='pgsql:host=127.0.0.1;port=5432;dbname=orders;connect_timeout=3'
export ORDERS_DSN_USER='orders_app'
export ORDERS_DSN_PASSWORD='replace-locally'
export BILLING_DSN='pgsql:host=127.0.0.1;port=5432;dbname=billing;connect_timeout=3'
export BILLING_DSN_USER='billing_app'
export BILLING_DSN_PASSWORD='replace-locally'
export EVENT_SECRET='replace-with-a-long-random-secret'
export BILLING_URL='http://127.0.0.1:8081/inbox.php'
php -S 127.0.0.1:8080
php -S 127.0.0.1:8081
php relay.php
curl --fail-with-body \
-H 'Content-Type: application/json' \
--data '{"amount_cents":2599,"currency":"EUR"}' \
http://127.0.0.1:8080/orders.php
Query both databases and confirm one published outbox row, one inbox row, and one invoice. To test recovery, stop Billing, create another order, and observe retries with increasing available_at. Restart Billing and confirm eventual processing. To test deduplication, temporarily set the event’s published_at to null in a disposable test database, run the relay again, and verify that the invoice count does not increase.
Production security, observability, and performance
Replace PHP’s development server with a supported web server and PHP-FPM. Expose neither PostgreSQL nor the Billing endpoint publicly. Permit only the required service-to-service path in host and cloud firewalls, use TLS, rotate the HMAC secret, and consider mutual TLS where service identity warrants it. Apply request-size limits before PHP and never log secrets or complete sensitive payloads.
Run the relay under a dedicated unprivileged account. Configure the service manager with a stop timeout greater than the five-second HTTP budget plus database cleanup time. Multiple instances are safe, but increase them only after measuring downstream capacity and database contention.
Useful metrics include unpublished row count, age of the oldest ready event, attempts by event type, lease expirations, delivery latency, response status, inbox conflicts, and processing failures. Alerts should emphasize oldest-event age rather than queue depth alone: a small queue containing one permanently stuck event is operationally significant.
For higher throughput, claim a small batch in one short transaction, then process it outside the transaction with bounded concurrency. Keep leases longer than the worst permitted request duration, add jitter to retry delays, and define a policy for poison events. Do not retry permanent 4xx validation failures forever; quarantine them with their error metadata for controlled investigation. Continue retrying transient network failures and appropriate 5xx responses.
Common failure modes
- Publishing inside the order transaction: network latency extends locks, and rollback can leave an already-delivered event.
- Holding claims while calling Billing: slow HTTP turns the outbox into a lock-contention system.
- Marking success without checking ownership: an expired worker can overwrite the state of a newer lease holder.
- Deduplicating outside the consumer transaction: a crash between the inbox insert and business effect can suppress unfinished work.
- Assuming retries imply exactly-once effects: external effects such as email or payment calls require their own idempotency keys and durable state machines.
- Using one timeout everywhere: connection, query, lock, and HTTP timeouts protect different boundaries and must fit beneath lease and shutdown budgets.
Final verification checklist
- The order and outbox event commit or roll back together.
- Claims commit before any network or business work begins.
- Expired leases are reclaimable, and updates verify ownership.
- A signal received during reservation causes immediate release without new delivery work.
- Connection, lock, query, and HTTP operations have separate bounded timeouts.
- Billing’s inbox record and invoice share one transaction.
- Replaying an event returns success without duplicating its effect.
- Credentials are least-privileged, traffic is restricted, and production HTTP uses TLS.
- Dashboards expose backlog age, retries, lease expiry, and consumer failures.
- Deployment shutdown budgets exceed the worker’s bounded in-flight operation.
Reliable integration does not come from pretending failures can be eliminated. It comes from deciding exactly where durable truth lives, making every uncertain boundary retryable, and ensuring every retry is harmless. With an outbox, a leased relay, and a transactional inbox, crashes stop being mysterious edge cases and become ordinary state transitions your system is designed to survive.