rasuvaeff/yii3-outbox-db

Database-backed outbox storage for Yii3

Maintainers

Package info

github.com/rasuvaeff/yii3-outbox-db

pkg:composer/rasuvaeff/yii3-outbox-db

Transparency log

Statistics

Installs: 97

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

v2.0.2 2026-07-31 22:52 UTC

This package is auto-updated.

Last update: 2026-08-01 23:53:18 UTC


README

Stable Version Total Downloads Build Static analysis Psalm Level License Русская версия

Database-backed storage for rasuvaeff/yii3-outbox. Durably persists outbox messages in a yiisoft/db table so a worker can publish or export them asynchronously — surviving process restarts and downstream outages.

Using an AI coding assistant? llms.txt has a compact API reference you can use.

Requirements

  • PHP 8.3+
  • rasuvaeff/yii3-outbox ^1.0
  • yiisoft/db ^2.0, yiisoft/db-migration ^2.0

Installation

composer require rasuvaeff/yii3-outbox-db

Usage

Migration

Register the bundled migration by namespace — no vendor paths:

// config/common/di/migration.php
use Yiisoft\Db\Migration\Service\MigrationService;

return [
    MigrationService::class => [
        'setSourceNamespaces()' => [[
            'App\\Migration',
            'Rasuvaeff\\Yii3OutboxDb\\Migration',
        ]],
    ],
];
./yii migrate:up

Heads up: the snippet above does not find the migration yet. It is the correct configuration and will start working with no change on your side once the upstream bug below is fixed — but today ./yii migrate:up reports "Your system is up-to-date", exits 0 and creates no tables.

yiisoft/db-migration (2.0.x) resolves a namespace to a directory by taking the first entry in composer/autoload_psr4.php that the namespace starts with, comparing against the key with its trailing separator trimmed but cutting the remainder with the untrimmed length. Trimming the separator destroys the segment boundary, so Rasuvaeff\Yii3Outbox\ matches Rasuvaeff\Yii3OutboxDb\Migration as if it were its parent — and this package depends on that one, so the collision is always present. The resolved directory does not exist, discovery skips missing directories silently, and nothing is applied.

Until that is fixed upstream, apply the bundled migration yourself:

// src/Console/MigrateCommand.php (excerpt)
use Rasuvaeff\Yii3OutboxDb\Migration\M260611000000CreateOutboxTable;
use Yiisoft\Db\Migration\Informer\ConsoleMigrationInformer;
use Yiisoft\Db\Migration\MigrationBuilder;
use Yiisoft\Injector\Injector;

$builder = new MigrationBuilder($db, new ConsoleMigrationInformer());
$injector = new Injector($container);

foreach ([
    M260611000000CreateOutboxTable::class,
] as $class) {
    $injector->make($class)->up($builder);
}

Injector::make() is required rather than new: it resolves the table-name value object from your configuration. Keep the loop idempotent (skip when the table already exists) — it has no migration history of its own.

Set the table name in params — the same value reaches the migration and DbOutboxStorage:

// config/common/params.php
'rasuvaeff/yii3-outbox-db' => [
    'table' => 'my_outbox',
    'table_prefix' => '',   // prepended to `table`; e.g. 'rsv_' → rsv_my_outbox
],

Index names follow the table name (idx_my_outbox_pending), so two installations can share one PostgreSQL schema — index names are unique per schema there, not per table.

Do not configure the migration through the DI container. M...::class => ['__construct()' => ['table' => ...]] does not work: the migration is built by Injector::make(), which resolves arguments by type and never reads a container definition keyed by the migration's own class. Worse, adding that definition makes the container fatal at build time in every request, because the class is not autoloadable until the migration runner requires it. That recipe was documented in 1.x; it never worked.

Recording and processing

use Rasuvaeff\Yii3Outbox\Outbox;
use Rasuvaeff\Yii3OutboxDb\DbOutboxStorage;

$storage = new DbOutboxStorage(db: $connection);          // ConnectionInterface
$outbox = new Outbox(storage: $storage, clock: $clock);

// request path — durable, no network call to the sink
$outbox->record(type: 'ab.exposure', payload: '{"experiment":"checkout"}');

// worker — atomically claim a batch of one consumer's types and process them
$claimed = $storage->claim(types: ['ab.exposure', 'ab.conversion'], limit: 1000);

Storage API

Method Purpose
save(OutboxMessage) upsert by id (initial record or retry re-save)
claim(array $types = [], int $limit = 1000) what a worker calls. Atomically flips up to limit Pending rows to Processing and returns them, created_at ASC
findPending(array $types = [], int $limit = 1000) read-only listing of pending rows, optional type filter, created_at ASC
markPublished(OutboxMessage) re-save with Published status
markFailed(OutboxMessage) re-save with Failed status
getById(string $id) single message or null
deleteByStatus(OutboxStatus) housekeeping (e.g. purge Published)

claim() vs findPending()

claim() is the primitive a worker must use, and the one Processor calls. It runs inside a transaction: it selects the pending ids, stamps them Processing with a random claimed_by token, then re-reads exactly the rows carrying that token. Two workers polling concurrently therefore never receive the same message.

findPending() is a plain read. Nothing is locked or marked, so two workers polling it both get the same rows and publish the same message twice. Use it for dashboards, admin screens and diagnostics — never as a worker's fetch.

Every claimed message must reach a terminal state: markPublished(), markFailed(), or save($message->withStatus(OutboxStatus::Pending)) to release it. A worker that crashes mid-batch leaves rows in Processing; they stay there until something puts them back, so treat a growing Processing count as an alert.

The $types filter lets several consumers — a generic Processor and a specialized exporter — share one outbox. Because claim() hands each message to exactly one caller, their type sets must not overlap: a message matching both is delivered only to whichever worker claimed it first.

Yii3 DI

The config-plugin binds StorageInterface to DbOutboxStorage from config/di.php. Core yii3-outbox binds nothing, so this backend (or the application) is the single source of StorageInterface. Set the table name in params:

// config/params.php
'rasuvaeff/yii3-outbox-db' => ['table' => 'outbox'],

Security

  • All values are written through yiisoft/db parameterized commands.
  • OutboxRowMapper validates every column and rejects corrupt rows with InvalidOutboxRowException — no silent coercion.
  • Payloads may contain PII; retention/purging is the application's responsibility (deleteByStatus helps).

Examples

Runnable scripts live in examples/.

Development

make build        # full gate: validate + normalize + require-checker + cs + psalm + test
make cs-fix
make psalm
make test
make test-coverage
make mutation

Core yii3-outbox is consumed via a path repository while unpublished — see AGENTS.md for the monorepo-root Docker invocation.

License

BSD-3-Clause. See LICENSE.md.