Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Event Store Dbal Laravel Package

broadway/event-store-dbal

Deep Wiki
Context7

Getting Started

broadway/event-store-dbal is the Doctrine DBAL persistence layer for Broadway's EventStore interface. It gives you one class — Broadway\EventStore\DBAL\DBALEventStore — that writes serialized domain events into a plain SQL table and rebuilds aggregates by replaying them. There is no ORM, no repository magic, no migration tooling: two tables and a constructor.

Minimal steps

  1. Install (pin the version). The package is archived, so treat versions as frozen and lock them:

    composer require broadway/event-store-dbal:^1.0
    

    Check the resolved doctrine/dbal constraint before you start — this package predates DBAL 3 and expects the 2.x Doctrine\DBAL\Connection API.

  2. Create the schema first. The package ships raw SQL under vendor/broadway/event-store-dbal/schema/. Do not invent your own — the uuid column type must match the useBinary flag you pass later:

    CREATE TABLE event_store (
        uuid        CHAR(36)    NOT NULL,
        playhead    INT         NOT NULL,
        metadata    LONGTEXT    NOT NULL,
        payload     LONGTEXT    NOT NULL,
        recorded_on VARCHAR(32) NOT NULL,
        PRIMARY KEY (uuid, playhead)
    ) ENGINE=InnoDB;
    
    -- optional but recommended: optimistic concurrency guard
    CREATE TABLE event_store_aggregates (
        uuid     CHAR(36) NOT NULL,
        playhead INT      NOT NULL,
        PRIMARY KEY (uuid)
    ) ENGINE=InnoDB;
    

    In Laravel, put this in a migration using DB::statement(); in Doctrine, generate the migration from the shipped DDL. Either way it becomes a normal, reviewable migration rather than a runtime createSchema() call.

  3. Instantiate the store. Two collaborators: a DBAL connection and a Broadway serializer.

    use Broadway\EventStore\DBAL\DBALEventStore;
    use Broadway\Serializer\SimpleInterfaceSerializer;
    use Doctrine\DBAL\DriverManager;
    
    $connection = DriverManager::getConnection(['url' => config('database.connections.pgsql.url')]);
    
    $eventStore = new DBALEventStore(
        $connection,
        new SimpleInterfaceSerializer(),
        'event_store',
        'event_store_aggregates',   // null = skip concurrency table
        false                        // useBinary
    );
    
  4. Wire it into Broadway. The store is inert on its own; it plugs into EventSourcedAggregateRootRepository behind an EventBus:

    use Broadway\EventHandling\SimpleEventBus;
    use Broadway\EventSourcing\EventSourcedAggregateRootRepository;
    
    $eventBus   = new SimpleEventBus();
    $repository = new EventSourcedAggregateRootRepository(Order::class, $eventStore, $eventBus);
    

Your first use case: append, then replay. Open a terminal, place one order, save it, kill the process, reload it in a fresh request. If the reload works, the whole package is working — everything else is volume and tuning.

$order = Order::place(OrderId::generate(), $lines);
$repository->save($order);          // -> DBALEventStore::append()

$order = $repository->load($orderId); // -> DBALEventStore::load()

Where to look first: DBALEventStore::load(), loadFromPlayhead(), and append() — that's the entire surface area. Read those three methods before anything else; there are no other moving parts.

Implementation Patterns

Container binding (Laravel example). The store should be a singleton built once from the default connection, with table names from config so tests can point elsewhere:

$this->app->singleton(EventStore::class, function ($app) {
    return new DBALEventStore(
        $app['db']->connection()->getDoctrineConnection(),
        $app->make(Serializer::class),
        config('eventstore.table', 'event_store'),
        config('eventstore.aggregate_table', 'event_store_aggregates'),
        config('eventstore.binary_uuid', false)
    );
});

Note the getDoctrineConnection() hop — Broadway needs a DBAL Connection, not Laravel's Connection wrapper. Keep that adapter in exactly one place.

Serializer selection is an architectural decision, not a config tweak. SimpleInterfaceSerializer (JSON-ish, class name in the payload) is fine for a greenfield project and terrible for long-lived ones, because renaming or moving an event class silently breaks replay. If you expect the codebase to outlive a year, use a serializer that does not embed PHP FQCNs — Kryo (kryo/kryo) or an explicit payload => event class map in your own serializer. Whatever you pick, write it down: you can never change it without a data migration.

Use the aggregate table. Passing null as the fourth argument makes the store check concurrency by re-reading the stream inside the append — correct, but a read per write and racy under load. Pass the aggregate table name and the store tracks the playhead in event_store_aggregates, so a conflicting append fails fast with Broadway\EventStore\Exception\ConcurrencyException. Catch it at the command-handler boundary and translate it into a domain-level "someone else changed this" response:

try {
    $repository->save($order);
} catch (ConcurrencyException $e) {
    throw new OrderModifiedElsewhere($orderId);
}

Snapshots. The package stores events only; snapshot storage is yours. The pattern is: keep snapshots in a separate table, and on load, fetch the latest snapshot, rehydrate the aggregate, then hand the playhead to loadFromPlayhead():

$snapshot = $this->snapshots->find($aggregateId);      // nullable
$order    = $snapshot?->rehydrate() ?? Order::empty($aggregateId);
$events   = $eventStore->loadFromPlayhead($aggregateId, $snapshot?->playhead() ?? 0);
$order->replay($events);

Do this only when a stream regularly exceeds a few hundred events — snapshots are a second source of truth and a second thing to get wrong.

Read models / projections. Broadway's EventStore interface has no loadAll(), and this implementation doesn't add one. If you need projections, subscribe an event listener to the bus and persist your read model in the same transaction, or maintain your own ordered cursor over event_store (there is recorded_on but no monotonic sequence column — order by (recorded_on, uuid, playhead) at your peril). This is the single biggest reason teams outgrow the package.

Decorators for observability. Don't fork the class; wrap it. An EventStore decorator that logs aggregate id, event class, and elapsed time costs ten lines and makes production debuggable:

final class LoggingEventStore implements EventStore
{
    public function __construct(private EventStore $inner, private LoggerInterface $log) {}

    public function append($events): void
    {
        $this->log->info('append', ['count' => iterator_count($events)]);
        $this->inner->append($events);
    }
    // delegate load/loadFromPlayhead
}

Metadata. metadata is a serialized blob, populated only if you wrap the store in a metadata-enriching decorator (Broadway ships MetadataEnricherEventStore / MetadataEnricher in the core package — verify the class names against your version). Use it for correlation_id, causation_id, and the authenticated user, not for domain data.

Testing. Swap in Broadway\EventStore\InMemoryEventStore (shipped here) for aggregate and handler tests, and keep one integration test against a real database running the shipped schema. The integration test is what catches the useBinary mismatch nobody notices until production.

Gotchas and Tips

It is archived. No security fixes, no DBAL 3 support in the foreseeable future, no PHP minor-version guarantees. That is acceptable for a mature, small surface area — but it means: pin doctrine/dbal to 2.x, pin this package, and add a note in your ADR. If your platform team forces DBAL 3, this package is a blocker and you should be shopping for a replacement (patchlevel/event-sourcing, spatie/laravel-event-sourcing, or a hand-rolled store) rather than patching a fork.

useBinary must match the schema exactly. true writes 16-byte binary UUIDs (via pack('H*', ...)) and requires BINARY(16)/BYTEA columns; false writes CHAR(36). Flipping this after data exists means two incompatible encodings in one table and queries that return nothing instead of erroring. Decide at design time, record it in config, and assert it in a test.

recorded_on is a string, not a timestamp. It's written as a formatted string, loses sub-second precision, and carries no timezone. Use it for rough forensics — never for ordering, pagination, or "events since" queries. If you need those, add your own event_id BIGSERIAL column via a migration and a partial index; the store won't mind an extra column it never touches.

Table names are interpolated into SQL. $tableName is concatenated into the query strings. Never accept it from a request or an unvalidated config source.

Transactions. append() checks whether the connection is already in a transaction and only opens one if needed. The practical implication: wrapping $repository->save($order) plus a read-model write in a single $connection->beginTransaction() block gives you atomicity for free — and if you don't, expect orphaned events every time a downstream listener throws.

Replay is all-or-nothing. load() pulls the entire stream into memory. A 50k-event aggregate will OOM your worker, not throw a nice exception. Alert on stream length; at ~500 events, add snapshots.

Debugging serialized payloads. SELECT payload FROM event_store WHERE uuid = ? returns the serializer's raw output — readable with SimpleInterfaceSerializer, opaque with Kryo unless you use the matching decoder. Keep a one-off artisan/tinker command in the repo that decodes a stream by aggregate id; you will use it during every incident review.

SQLite for tests needs a different schema. LONGTEXT, CHAR(36) and BINARY(16) do not exist there. Use TEXT/BLOB variants and run the same integration test against both, because "works on SQLite" is not evidence of anything.

Extension points, in order of preference: a decorator implementing Broadway\EventStore\EventStore (logging, metrics, metadata, tenant switching); a custom Serializer; a separate snapshots table. Subclassing DBALEventStore is the last resort — its internals are small but the archived status means a subclass can break on a patch bump with no upstream fix.

Upgrading an existing stream format. Because the serializer is baked into every row, a "let's rename OrderPlaced to OrderWasPlaced" refactor is a data migration. Keep old event classes as thin aliases (or explicit class_alias() shims) at the old FQCN forever, and only write new events with the new names. It's ugly; it's also the reason event sourcing works.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
mrnewport/laravel-plaid
mrnewport/laravel-repo
mrnewport/laravel-docsign
mrnewport/laravel-stow
mrnewport/laravel-flow
mrnewport/laravel-priceable
mrnewport/laravel-nestedterms
fredmeng/laravel-apidoc-generator
itstructure/laravel-multilingual-tools
pdustdar/elasticsearch
markupai/php-sdk
rlacerda83/lumem-microservice-shipment
uni-method/symfony-lib
tacticmedia/scim-server-bundle
neika-ai/symfony-bundle
mattleads/agent-publishing-bundle
glavweb/framework-bundle
glavweb/extra-bundle
glavweb/api-bundle
glavweb/admin-bundle