sumantasam1990 / phpoutbox
Transactional Outbox Pattern for PHP — guaranteed at-least-once event delivery for Laravel, Symfony, and vanilla PHP
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.0
- orchestra/testbench: ^9.0
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^11.0
- symfony/config: ^7.0
- symfony/console: ^7.0
- symfony/dependency-injection: ^7.0
- symfony/http-kernel: ^7.0
- symfony/messenger: ^7.0
- symfony/yaml: ^7.0
Suggests
- ext-pdo_mysql: Required for MySQL outbox store
- ext-pdo_pgsql: Required for PostgreSQL outbox store
- orchestra/testbench: Required for Laravel adapter testing
- symfony/http-kernel: Required for Symfony bundle
- symfony/messenger: Required for Symfony Messenger publisher
This package is auto-updated.
Last update: 2026-04-08 07:58:30 UTC
README
Stop losing events. PHPOutbox implements the Transactional Outbox Pattern for PHP — guaranteed at-least-once event delivery for Laravel, Symfony, and vanilla PHP applications.
The Problem
// ❌ DANGEROUS — dual-write problem DB::transaction(function () use ($order) { $order->save(); }); // If the app crashes here, or the queue is down... event(new OrderCreated($order)); // 💀 Event lost forever
The Solution
// ✅ SAFE — atomic outbox write DB::transaction(function () use ($order) { $order->save(); Outbox::store('Order', $order->id, 'OrderCreated', $order->toArray()); // Both written in the SAME transaction — both succeed or both fail }); // Background relay publishes to your queue — guaranteed delivery // php artisan outbox:relay
Features
- Atomic writes — Events stored in the same DB transaction as business data
- Background relay — Polls outbox table, publishes to your queue
- Retry with backoff — Failed publishes retry automatically
- Dead letter queue — Messages moved to dead-letter after max retries
- Concurrent workers — Multiple relays via
SELECT FOR UPDATE SKIP LOCKED - Framework-agnostic — Core works with raw PDO, zero framework deps
- Laravel adapter — ServiceProvider, Facade, Artisan commands
- Symfony adapter — Bundle, Console commands, Messenger integration
- Observability — PSR-3 logging, relay metrics per cycle
- Housekeeping — Auto-prune old messages
Requirements
- PHP 8.2+
- MySQL 8.0+ or PostgreSQL 9.5+ (SQLite for testing)
- PDO extension
Installation
composer require sumantasam1990/phpoutbox
Quick Start
Laravel
1. Publish config:
php artisan vendor:publish --tag=outbox-config
2. Create the outbox table:
php artisan outbox:migrate
3. Store events (inside your DB transaction):
use PhpOutbox\Outbox\Laravel\Facades\Outbox; use Illuminate\Support\Facades\DB; DB::transaction(function () { $order = Order::create([ 'customer_id' => 42, 'total' => 199.99, ]); Outbox::store( aggregateType: 'Order', aggregateId: (string) $order->id, eventType: 'OrderCreated', payload: $order->toArray(), headers: ['correlation-id' => request()->header('X-Correlation-ID')], ); });
4. Run the relay daemon:
php artisan outbox:relay
Or for cron-based relay:
php artisan outbox:relay --once
5. Schedule pruning in routes/console.php:
Schedule::command('outbox:prune --days=30')->daily();
Symfony
1. Register the bundle:
// config/bundles.php return [ // ... PhpOutbox\Outbox\Symfony\OutboxBundle::class => ['all' => true], ];
2. Configure:
# config/packages/outbox.yaml outbox: table_name: outbox_messages relay: batch_size: 100 poll_interval_ms: 1000 max_attempts: 5
3. Run the relay:
bin/console outbox:relay
Vanilla PHP (No Framework)
use PhpOutbox\Outbox\Outbox; use PhpOutbox\Outbox\OutboxConfig; use PhpOutbox\Outbox\Store\PdoOutboxStore; use PhpOutbox\Outbox\Store\Schema; use PhpOutbox\Outbox\Relay\OutboxRelay; // Setup $pdo = new PDO('mysql:host=localhost;dbname=myapp', 'user', 'pass'); $config = new OutboxConfig(batchSize: 50, maxAttempts: 3); $store = new PdoOutboxStore($pdo, $config); $outbox = new Outbox($store); // Create table (one-time) $pdo->exec(Schema::mysql()); // Store an event (inside your transaction) $pdo->beginTransaction(); $pdo->exec("INSERT INTO orders (id, total) VALUES (1, 99.99)"); $outbox->store('Order', '1', 'OrderCreated', ['total' => 99.99]); $pdo->commit(); // Run relay (implement OutboxPublisher for your broker) $publisher = new MyRabbitMQPublisher(); $relay = new OutboxRelay($store, $publisher, $config); $relay->run(); // Blocks forever — run in a supervisor
Configuration
Laravel Config (config/outbox.php)
| Key | Env Variable | Default | Description |
|---|---|---|---|
table_name |
OUTBOX_TABLE |
outbox_messages |
Outbox table name |
connection |
OUTBOX_CONNECTION |
null (default) |
Database connection |
relay.batch_size |
OUTBOX_BATCH_SIZE |
100 |
Messages per relay cycle |
relay.poll_interval_ms |
OUTBOX_POLL_INTERVAL |
1000 |
Ms between polls |
relay.max_attempts |
OUTBOX_MAX_ATTEMPTS |
5 |
Max retries before dead-letter |
publisher.queue_connection |
OUTBOX_QUEUE_CONNECTION |
null |
Queue connection |
publisher.queue_name |
OUTBOX_QUEUE_NAME |
outbox |
Queue name |
prune_after_days |
OUTBOX_PRUNE_DAYS |
30 |
Days to keep published messages |
delete_on_publish |
OUTBOX_DELETE_ON_PUBLISH |
false |
Delete vs mark as published |
id_generator |
OUTBOX_ID_GENERATOR |
uuid7 |
ID strategy: uuid7 or ulid |
Custom Publisher
Implement OutboxPublisher for your message broker:
use PhpOutbox\Outbox\Contracts\OutboxPublisher; use PhpOutbox\Outbox\Exception\PublishException; use PhpOutbox\Outbox\Message\OutboxMessage; class RabbitMQPublisher implements OutboxPublisher { public function __construct(private AMQPChannel $channel) {} public function publish(OutboxMessage $message): void { try { $this->channel->basic_publish( new AMQPMessage($message->payload), 'events', $message->eventType, ); } catch (\Throwable $e) { throw PublishException::failed($message->id, $e); } } public function publishBatch(array $messages): void { foreach ($messages as $message) { $this->publish($message); } } }
Monitoring
The relay returns metrics per cycle:
$metrics = $relay->runOnce(); echo $metrics->summary(); // "Cycle #42: 100 processed (98 published, 1 failed, 1 dead-lettered) in 45.2ms" $metrics->processed; // Total messages handled $metrics->published; // Successfully published $metrics->failed; // Failed (will retry) $metrics->deadLettered; // Exhausted retries $metrics->durationMs; // Cycle duration
Architecture
See docs/ARCHITECTURE.md for detailed flow diagrams, concurrency model, and extension points.
Testing
# All tests ./vendor/bin/phpunit # Unit tests only ./vendor/bin/phpunit --testsuite=unit # Integration tests (SQLite in-memory) ./vendor/bin/phpunit --testsuite=integration # Static analysis ./vendor/bin/phpstan analyse
Contributing
Contributions are welcome! Please read our Contributing Guide for details on our code of conduct, how to submit pull requests, and our development process.
- Fork the repository
- Create a feature branch
- Write tests for your changes
- Ensure all tests pass and PHPStan is clean
- Submit a pull request
License
MIT License. See LICENSE for details.