https://github.com/tiny-blocks/outbox
Implements the Transactional Outbox pattern. Persists domain events atomically with aggregate state changes through a customizable table schema, reflection-based payload serialization, and built-in support for event schema versioning.
https://github.com/tiny-blocks/outbox
ddd domain-events event-driven event-persistence event-serialization event-sourcing event-store event-versioning integration-events open-source outbox php tiny-blocks transactional-outbox
Last synced: 4 days ago
JSON representation
Implements the Transactional Outbox pattern. Persists domain events atomically with aggregate state changes through a customizable table schema, reflection-based payload serialization, and built-in support for event schema versioning.
- Host: GitHub
- URL: https://github.com/tiny-blocks/outbox
- Owner: tiny-blocks
- License: mit
- Created: 2026-05-08T12:10:33.000Z (3 months ago)
- Default Branch: main
- Last Pushed: 2026-07-06T12:46:53.000Z (about 1 month ago)
- Last Synced: 2026-07-06T14:14:24.852Z (about 1 month ago)
- Topics: ddd, domain-events, event-driven, event-persistence, event-serialization, event-sourcing, event-store, event-versioning, integration-events, open-source, outbox, php, tiny-blocks, transactional-outbox
- Language: PHP
- Homepage: https://packagist.org/packages/tiny-blocks/outbox
- Size: 215 KB
- Stars: 2
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- License: LICENSE
- Security: SECURITY.md
Awesome Lists containing this project
README
# Outbox
[](https://github.com/tiny-blocks/outbox/blob/main/LICENSE)
* [Overview](#overview)
* [Installation](#installation)
* [How to use](#how-to-use)
+ [Expected table schema](#expected-table-schema)
+ [Wiring the repository](#wiring-the-repository)
+ [Producing events from an aggregate](#producing-events-from-an-aggregate)
+ [Declaring integration events and translators](#declaring-integration-events-and-translators)
+ [Customizing the table layout](#customizing-the-table-layout)
+ [Writing a custom payload serializer](#writing-a-custom-payload-serializer)
+ [Event schema versioning](#event-schema-versioning)
* [FAQ](#faq)
* [License](#license)
* [Contributing](#contributing)
## Overview
The **Transactional Outbox** pattern solves the dual-write problem: persisting an aggregate state change and publishing
an integration event must happen atomically. Doing both independently risks a crash leaving one side committed and the
other lost. The outbox pattern records both in the same database transaction, delegating event delivery to a separate
relay process.
This library is the write-side adapter. It persists every domain fact atomically with aggregate state via Doctrine
DBAL. Domain events with a registered translator follow the pipeline
`DomainEvent → IntegrationEventTranslator → IntegrationEvent → PayloadSerializer → INSERT` and are persisted as
integration events. Domain events without a translator are persisted as well, carrying the domain event itself: the
row takes the domain event's own type and revision, and its payload is produced by reflection over the event's public
properties. Every record produces a row, so the unique constraint over `(aggregate_type, aggregate_id,
aggregate_version)` can detect lost updates on every state transition.
The library is opinionated on correctness: transactions are always required, JSON validity is always checked, and every
schema decision is left to you: table name, column names, and identity column storage type are all configurable.
The library composes with [`tiny-blocks/building-blocks`](https://github.com/tiny-blocks/building-blocks), which
contributes `DomainEvent`, `EventRecord`, `EventRecords`, `IntegrationEvent`, `IntegrationEventBehavior`,
`IntegrationEventRecord`, `IntegrationEventTranslator`, `IntegrationEventTranslators`, `EventType`, `Revision`,
`AggregateVersion`, and the `EventualAggregateRoot` family. This library provides the persistence step only.
## Installation
```
composer require tiny-blocks/outbox
```
## How to use
### Expected table schema
The library does not create or manage the outbox table. Add it in your own migration.
**Default schema (BINARY(16) identity columns, recommended for UUID-based aggregates):**
```sql
CREATE TABLE outbox_events
(
id BINARY(16) NOT NULL COMMENT 'The event identifier in UUID format (v7 by default, e.g. 018f8e94-1c2a-7c3d-9b4e-5f6a7b8c9d0e).',
payload JSON NOT NULL COMMENT 'The event payload serialized as a JSON object (e.g. {"transaction_id":"..."}).',
revision INT NOT NULL COMMENT 'The positive integer indicating the schema revision of the persisted event payload (e.g. 1).',
event_type VARCHAR(255) NOT NULL COMMENT 'The event type in CamelCase (e.g. PaymentConfirmed). Carries the integration event type when a translator matches, otherwise the domain event type.',
occurred_at TIMESTAMP(6) NOT NULL COMMENT 'The UTC date and time when the event occurred in ISO 8601 format (e.g. 2026-02-13T08:49:44.931408+00:00).',
aggregate_id BINARY(16) NOT NULL COMMENT 'The aggregate root identifier in UUID format (e.g. 018f8e94-1c2a-7c3d-9b4e-5f6a7b8c9d0e).',
aggregate_type VARCHAR(255) NOT NULL COMMENT 'The aggregate root class name that produced the event in CamelCase (e.g. Transaction).',
aggregate_version BIGINT NOT NULL COMMENT 'The version of the aggregate at the moment the event was emitted, used to detect duplicate or out-of-order events per aggregate (e.g. 1).',
created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT 'The UTC date and time when the record was inserted in ISO 8601 format (e.g. 2026-02-13T08:49:44.931408+00:00).',
PRIMARY KEY (id),
CONSTRAINT unq_outbox_events_aggregate_type_aggregate_id_aggregate_version UNIQUE (aggregate_type, aggregate_id, aggregate_version)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_0900_ai_ci COMMENT ='Table used to persist append-only outbox events for atomic event publication.';
```
The library writes to `id`, `aggregate_id`, `aggregate_type`, `event_type`, `revision`, `aggregate_version`, `payload`,
and `occurred_at`. It never writes to `created_at`. The database fills it automatically.
For aggregates whose identities are not UUID strings, use VARCHAR columns and configure `IdentityColumnType::STRING`
(see [Customizing the table layout](#customizing-the-table-layout)):
```sql
CREATE TABLE outbox_events
(
-- For non-UUID identities: VARCHAR(36) or wider.
id VARCHAR(36) NOT NULL,
aggregate_id VARCHAR(36) NOT NULL
-- All other columns are the same as the default schema.
)
```
### Wiring the repository
`DoctrineOutboxRepository` requires a Doctrine DBAL `Connection`, a `PayloadSerializers` collection, and an
`IntegrationEventTranslators` collection. The table layout defaults to table `outbox_events` with BINARY(16) identity
columns.
```php
beginTransaction();
try {
$order = Order::place(orderId: 'order-123');
$orderRepository->save(order: $order);
$outboxRepository->push(records: $order->recordedEvents());
$connection->commit();
} catch (Throwable $exception) {
$connection->rollBack();
throw $exception;
}
```
The aggregate instance is use-once: its recorded-events buffer is never cleared by the library. Discard the instance
after `push()`. Re-saving the same instance pushes the same records again and throws `DuplicateOutboxEvent`.
### Declaring integration events and translators
#### Declaring an integration event
An integration event is the stable public contract that flows across bounded contexts. Declare it with
`IntegrationEventBehavior`, which provides a default `revision()` returning revision 1. Override `revision()` only
when the event's payload structure changes in a backward-incompatible way.
```php
event instanceof OrderPlaced;
}
public function translate(EventRecord $record): IntegrationEvent
{
return new OrderShipped(orderId: $record->event->orderId);
}
}
```
#### Registering translators
Pass an `IntegrationEventTranslators` collection to the repository constructor. Lookup follows first-match-wins
semantics: the first translator whose `supports()` returns `true` is used.
```php
withColumns(columns: Columns::builder()
->withId(name: 'id', type: IdentityColumnType::STRING)
->withEventType(name: 'kind')
->withAggregateId(name: 'aggregate_id', type: IdentityColumnType::STRING)
->withAggregateType(name: 'entity_class')
->withAggregateVersion(name: 'position')
->build())
->withTableName(tableName: 'my_outbox')
->build();
$repository = new DoctrineOutboxRepository(
connection: $connection,
serializers: PayloadSerializers::createFrom(elements: [new PayloadSerializerReflection()]),
translators: IntegrationEventTranslators::createFrom(elements: [new OrderPlacedTranslator()]),
tableLayout: $tableLayout
);
```
All `Columns::builder()` methods are optional. Omit any method to keep its default. `withId` and `withAggregateId`
require both `name:` and `type:`, all other methods require only `name:`.
| Method | Default column name | Default type | Description |
|---------------------------------|---------------------|:------------:|------------------------------------------------------------------|
| `withId(name:, type:)` | `id` | `BINARY` | Renames the event id column and/or changes its storage type. |
| `withPayload(name:)` | `payload` | | Renames the event payload column. |
| `withRevision(name:)` | `revision` | | Renames the schema revision column. |
| `withEventType(name:)` | `event_type` | | Renames the event type column. |
| `withOccurredAt(name:)` | `occurred_at` | | Renames the event timestamp column. |
| `withAggregateId(name:, type:)` | `aggregate_id` | `BINARY` | Renames the aggregate id column and/or changes its storage type. |
| `withAggregateType(name:)` | `aggregate_type` | | Renames the aggregate type column. |
| `withAggregateVersion(name:)` | `aggregate_version` | | Renames the aggregate version column. |
| `withCreatedAt(name:)` | `created_at` | | Renames the record creation timestamp column. |
`TableLayout::builder()` controls the table name, columns, and unique constraint name.
| Method | Default | Description. |
|-------------------------------|-------------------------------------------------------------------|--------------------------------------------------------------------|
| `withColumns(columns:)` | Default column names. | Provides a custom `Columns` configuration. |
| `withTableName(tableName:)` | `outbox_events` | Sets the outbox table name. |
| `withUniqueConstraint(name:)` | `unq_outbox_events_aggregate_type_aggregate_id_aggregate_version` | Sets the unique constraint name used to detect duplicate versions. |
The DDL example uses `unq_outbox_events_aggregate_type_aggregate_id_aggregate_version` as the unique constraint name.
The library expects this name by default, if you rename it in your DDL, configure it via
`TableLayout::builder()->withUniqueConstraint(name: 'your_name')->build()`.
Constraint violation detection works with MySQL, MariaDB, PostgreSQL, and SQL Server. These DBMSs include the
constraint name in their violation messages. SQLite is not supported because it omits the constraint name. All unique
violations with SQLite fall under `DuplicateOutboxEvent`.
### Writing a custom payload serializer
`PayloadSerializerReflection` covers integration events that `tiny-blocks/mapper` maps by reflection: scalars,
nested value objects, backed and pure enums, and date-times, with single-property wrappers unwrapped to their inner
value. Implement `PayloadSerializer` explicitly for integration events that need a payload shape the mapper does not
produce by default. Payload serializers apply to translated records only: rows persisted without a translator
serialize the domain event by reflection and never consult the `PayloadSerializers` collection.
Both `supports()` and `serialize()` receive the full `IntegrationEventRecord`, giving access to `$record->event`
(the `IntegrationEvent`), `$record->aggregateType`, `$record->aggregateId`, `$record->aggregateVersion`, and all
other envelope fields when routing or shaping the payload.
Use `match (true)` in `serialize()` to handle multiple integration event types from the same aggregate in a single
serializer:
```php
event instanceof OrderShipped || $record->event instanceof OrderCanceled;
}
public function serialize(IntegrationEventRecord $record): SerializedPayload
{
return match (true) {
$record->event instanceof OrderShipped => SerializedPayload::from(
payload: json_encode(['orderId' => $record->event->orderId], JSON_THROW_ON_ERROR)
),
$record->event instanceof OrderCanceled => SerializedPayload::from(
payload: json_encode(
['orderId' => $record->event->orderId, 'reason' => $record->event->reason],
JSON_THROW_ON_ERROR
)
)
};
}
}
# Register custom serializers before PayloadSerializerReflection.
# PayloadSerializerReflection always returns true from supports(), so it must come last.
$repository = new DoctrineOutboxRepository(
connection: $connection,
serializers: PayloadSerializers::createFrom(elements: [
new OrderEventSerializer(),
new PayloadSerializerReflection()
]),
translators: IntegrationEventTranslators::createFrom(elements: [
new OrderPlacedTranslator(),
new OrderCanceledTranslator()
])
);
```
`SerializedPayload::from()` validates the JSON string at construction time and throws `InvalidPayloadJson` if the JSON
is malformed, before the INSERT is attempted. When building the JSON from an array, prefer
`SerializedPayload::fromArray($array)` over `SerializedPayload::from(json_encode($array, JSON_THROW_ON_ERROR))`. The
library handles encoding internally.
### Event schema versioning
Each integration event declares its schema revision via `IntegrationEvent::revision()`. `IntegrationEventBehavior`
provides the default implementation, returning revision 1. Override `revision()` when the integration event's payload
structure changes in a backward-incompatible way:
```php
aggregateType === 'Order'`), or the payload
shaping may vary based on the aggregate version (`$record->aggregateVersion`). Receiving the full
`IntegrationEventRecord` in both `supports()` and `serialize()` gives serializers access to all available envelope
context without requiring any additional indirection.
### 09. How does the library handle transient database errors?
The library catches `UniqueConstraintViolationException` to differentiate `DuplicateAggregateVersion` from
`DuplicateOutboxEvent`. All other DBAL exceptions, including transient errors like deadlocks (`DeadlockException`),
lock wait timeouts (`LockWaitTimeoutException`), and connection failures (`ConnectionLost`), propagate unchanged to the
caller.
The consumer is responsible for any retry policy. A common pattern is to wrap the unit of work (aggregate save +
outbox push) in a retry loop that catches transient exceptions and re-executes the entire transaction.
### 10. Why are domain events without a translator persisted instead of skipped?
The outbox table registers every domain fact. The unique constraint over `aggregate_type`, `aggregate_id`, and
`aggregate_version` can only detect lost updates when every state transition writes a row. Skipping untranslated
events would leave gaps in the aggregate's history and let two concurrent producers commit the same aggregate version
unnoticed. Untranslated events are therefore persisted carrying the domain event itself, with its own type, revision,
and a reflection-produced payload. Registering a translator remains the explicit opt-in for shaping the public
contract that crosses the bounded-context boundary.
### 11. Why doesn't the library accept a DomainEvent directly for publication?
The Anti-Corruption Layer rationale established in `tiny-blocks/building-blocks` applies here (see Vaughn Vernon,
*Implementing Domain-Driven Design*, Addison-Wesley, 2013, Chapter 3, "Context Maps"). The public contract must evolve
independently of the internal model. Accepting a domain event directly reintroduces the coupling that the integration
event abstraction exists to eliminate: every change to the domain event's shape would affect external consumers. The
cost of writing a translator is small and the gain in contract stability is the entire reason for the design. See
the `IntegrationEventTranslator` documentation in `tiny-blocks/building-blocks` for the full rationale.
## License
Outbox is licensed under [MIT](LICENSE).
## Contributing
Please follow the [contributing guidelines](https://github.com/tiny-blocks/tiny-blocks/blob/main/CONTRIBUTING.md) to
contribute to the project.