becklyn/ddd-symfony-bridge
Symfony bridge for becklyn/ddd-core and becklyn/ddd-doctrine-bridge. Wires SimpleBus command and event buses into your app via bundles, auto-registers subscribers and handlers, and sets up the Doctrine-backed event store.
The honest first step for a Laravel developer: this package is a Symfony bundle, not a Laravel package. It has no service provider, no config/becklyn_ddd.php, and no facade. You have three realistic paths:
becklyn/ddd-core and becklyn/ddd-doctrine-bridge directly and write your own EventServiceProvider/AppServiceProvider glue. Skip this bridge entirely; it saves you nothing outside a Symfony kernel.bundles.php registration pain and should budget a day for it.Assuming path 1 or 2, the minimal sequence:
composer require becklyn/ddd-symfony-bridge
Register the three bundles (SimpleBusCommandBusBundle, SimpleBusEventBusBundle, BecklynDddBundle) in config/bundles.php, then wire the convention-based auto-registration — this is the one thing worth understanding before writing any code:
# config/services.yaml
event_subscribers:
namespace: App\
resource: '../src/**/*Subscriber.php'
tags: [{ name: event_subscriber, register_public_methods: true }]
command_handlers:
namespace: App\
resource: '../src/**/*Handler.php'
tags: [{ name: command_handler, register_public_methods: true }]
Run php bin/console doctrine:migrations:migrate to create the event store tables (the migration ships with ddd-doctrine-bridge), then add the three serializer normalizers (BackedEnumNormalizer, PropertyNormalizer, DateTimeNormalizer) if your events carry enums, value objects, or timestamps.
Where to look first: the naming convention. Files ending in Handler.php become command handlers; files ending in Subscriber.php become event subscribers. Everything else (bus routing, middleware, persistence) is invisible.
First use case: a controller dispatches PlaceOrderCommand; PlaceOrderHandler loads the Order aggregate from the repository, calls $order->place(...), the aggregate records OrderPlaced; on commit, the event store persists the event and SimpleBus fan-outs to every subscriber with a matching typed method — e.g. SendOrderConfirmationSubscriber::handle(OrderPlaced $event). That round trip is the whole package in one sentence.
Handler/subscriber method shape is the contract. Any public method with exactly one typed parameter and a void return is auto-registered as a handler for that type. Method name is irrelevant.
final class PlaceOrderHandler
{
public function handle(PlaceOrderCommand $command): void { /* ... */ }
public function handleRefundOrder(RefundOrderCommand $command): void { /* ... */ }
}
The handleXxx style for multi-type classes is the pattern you'll actually use day-to-day, because one-class-per-command gets noisy fast. Keep each handler short — it should load an aggregate, invoke one domain method, and return.
Commands are 1:1, events are 1:N. Multiple subscribers per event is the intended design (write a read-model projection in one, send mail in another, emit to a queue in a third). Multiple handlers per command is undefined behavior — no exception at boot, just whichever wins. Treat command uniqueness as an invariant you enforce in code review.
The command queueing behavior will bite you if you don't know it. SimpleBus defers commands dispatched during command handling until the outer command finishes:
# config/packages/command_bus.yaml — only if you truly need reentrancy
command_bus:
middlewares:
finishes_command_before_handling_next: false
Default (deferred) is the safer choice and matches how you'd expect DB::transaction nesting to behave. Leave it alone unless you have a documented ordering requirement.
Correlation and causation IDs. Since ddd-core 3.x/4.x, the CommandBus has separate dispatch() and correlated-dispatch methods, and the event store records correlationId/causationId. This is the feature that makes the event store worth its complexity — you can trace an entire saga from the originating command. Wire it from your request ID if you want end-to-end traceability. Note the 4.0.0 BC break here: the separate with/without-correlation methods are the API you should target.
Controller side: use AggregateIdParamConverter to hydrate aggregate IDs from route params rather than manually new OrderId($uuid). (4.0.1 fixed a TypeError in this path; pin ≥ 4.0.1.)
Laravel adaptation, if you go that route: reproduce the tag registration with a container tag plus a compiler pass analogue — $this->app->tag([...], 'event_subscriber') bound to a resolver that reflects over public methods and maps argument types to a handler list. Then a single EventBus implementation that iterates handlers whose first parameter type matches get_class($event). It's ~100 lines and you lose the Symfony bundle's event-store wiring, which you'll need to port from ddd-doctrine-bridge yourself.
src/ ending in Handler or Subscriber gets registered. In a Laravel codebase this is a real hazard — queue job classes named *Handler, HTTP middleware, anything with Subscriber. Audit before enabling, or narrow the resource globs to ../src/Domain/**/*Handler.php.use_event_store defaults to true. If you register events but never ran the migration, you get an exception at transaction commit, not at dispatch. That's a confusing failure point — the stack trace lands in your Doctrine flush, not in your handler. Either run migrations or explicitly set the flag false in config/packages/becklyn_ddd.yaml.datetime_immutable type override is app-wide. Mapping datetime_immutable to DateTimeImmutableMicrosecondsType in doctrine.yaml changes behavior for every immutable datetime column in your schema, not just event store fields. Test your existing entities before deploying; some DB platforms and drivers don't round-trip .u cleanly.DateTimeImmutable will fail to persist if the normalizers aren't tagged. PropertyNormalizer also means your event properties need to be reachable — no constructor-only immutable objects with hidden state. Design event classes as flat, serializable DTOs.void is mandatory. A handler returning a value (e.g. : self for fluent APIs, or a result object) breaks registration semantics. PHPStan rule worth adding.^6.4 || ^7.4, doctrine/doctrine-bundle ^2.11 || ^3.0, and pulls becklyn/ddd-doctrine-bridge ^3.0 + becklyn/ddd-core ^4.0 — which means Doctrine ORM 3.x and DBAL 4.x. If your app is still on ORM 2.x, this package forces an upgrade that is bigger than the package itself.SELECT * FROM event_store ORDER BY raised_ts DESC LIMIT 20 tells you immediately whether the problem is "event not raised" or "event not persisted" — those have very different root causes (aggregate bug vs. serializer/migration/flush bug).How can I help you explore Laravel packages today?