dmytrof/doctrine-modification-events-bundle
A Symfony 7/8 bundle that helps you create and handle events when Doctrine entities are updated, making it easier to react to entity modifications in your application. Install via Composer and enable the bundle.
First, calibrate expectations: this is a Symfony 7/8 bundle for Doctrine ORM, not a Laravel package. dmytrof/doctrine-modification-events-bundle is registered through config/bundles.php and hooks Doctrine's unit-of-work (preUpdate/postUpdate/postFlush) to dispatch modification events onto Symfony's dispatcher. Laravel has no Kernel::registerBundles(), no Doctrine unit of work by default, and no container hook this bundle can bind to. composer require in a Laravel app installs files that will never be executed.
So your day-to-day decision is one of two paths:
Path A — you're on a Symfony service (or a hybrid org): use it directly.
composer require dmytrof/doctrine-modification-events-bundle// config/bundles.php
return [
// ...
Dmytrof\DoctrineModificationEventsBundle\DmytrofDoctrineModificationEventsBundle::class => ['all' => true],
];
vendor/dmytrof/doctrine-modification-events-bundle/src/ before writing a line of config. The README stops at installation — there is no configuration reference, no event-class documentation, no listener example. Find the dispatched event class, the Doctrine subscriber, and ForceFlushPreviousModificationsInterface.User modifications. Update a user in a controller, assert a row lands in your audit table.Path B — you're on Laravel (the common case): skip installation and port the pattern. This is where the real value is, and it takes an afternoon, not a sprint:
// app/Observers/UserObserver.php
class UserObserver
{
public function updated(User $user): void
{
$changes = $user->getChanges();
unset($changes['updated_at']);
if ($changes === []) {
return;
}
DB::afterCommit(fn () => UserModified::dispatch($user, $changes, auth()->id()));
}
}
Register with #[ObservedBy(UserObserver::class)] on the model (Laravel 11+) or User::observe(UserObserver::class) in AppServiceProvider. That snippet — change-set capture plus post-commit dispatch — is the entire functional core of the bundle.
Day-1 checklist: pick one high-value entity, write the observer, write one test asserting the payload (changed fields, old values), and only then generalize. Do not start with a shared "audit everything" trait.
1. Post-commit dispatch is the whole point.
Doctrine fires lifecycle callbacks inside the unit of work, before the transaction commits. The bundle queues modification events during flush and dispatches after, and ForceFlushPreviousModificationsInterface (v1.5.3) exists specifically to stop duplicate dispatch on forced flushes. The Laravel mirror is DB::afterCommit:
public function saved(Order $order): void
{
DB::afterCommit(function () use ($order) {
OrderSaved::dispatch($order, $order->getOriginal(), $order->getChanges());
});
}
Without this you notify queues, webhooks, and Slack about writes that later roll back. This is the single most important line in the whole port.
2. Capture before/after, not just "something changed."
The bundle's events carry the entity and its dirty set. In Laravel, getChanges() gives you new values only — pair it with originals for a useful audit row:
$changed = $order->getChanges();
$before = collect($order->getOriginal())->only(array_keys($changed));
OrderModified::dispatch($order, $before->all(), $changed);
An audit log with only new values is nearly worthless a year later.
3. Thin observers, fat listeners. Mirror the bundle's shape: modification event → N independent listeners. The observer does nothing but build a typed payload and dispatch. Audit persistence, notification, cache busting, and search indexing each get their own listener. You can then disable one side effect without touching the write path, and test them in isolation.
4. Queue everything slow, and be explicit about it. The bundle is synchronous in-request; every listener is latency the write pays. In Laravel:
class SendOrderConfirmation implements ShouldQueue
{
public string $queue = 'events';
public int $tries = 3;
public array $backoff = [10, 60, 300];
public function handle(OrderSaved $event): void { /* ... */ }
}
Keep audit-to-database synchronous if it must be transactional with the write; push email, webhooks, and index updates to a queue. Never let a hot model (orders, sessions, anything written per-request) accumulate sync listeners — measure p95 write latency before and after adding one.
5. Batch/import work gets an aggregate event, not 50,000 dispatches.
Per-row events are correct for interactive writes and pathological for imports. Either wrap the batch in Model::withoutEvents(fn () => ...) and emit one summary event, or buffer rows into a queued job that fans out deliberately sized chunks. This is the pattern equivalent of the Symfony side's nested-flush care.
6. Register observers where they're greppable.
Prefer #[ObservedBy(...)] on the model over a AppServiceProvider block full of Model::observe() calls nobody reads. For shared behaviour (tenant stamping, soft-delete audit), extract a trait and observe per-model rather than building a catch-all listener that switches on class name.
7. If you're genuinely on the Symfony side:
One EventSubscriber per concern, thin bodies, explicit priorities (audit must persist before notifications fan out). The bundle dispatches through Symfony's dispatcher, so doctrine:query:sql plus debug:event-dispatcher are your inspection tools.
8. Testing — assert the payload, not the fire.
Event::fake([OrderSaved::class]) for the dispatch contract; a separate integration test that dispatches for real and asserts the audit row. Payload regressions (a field silently falling out of getChanges()) are the real bugs you'll hit.
The Laravel install is a dead end, full stop. No service provider, no auto-discovery, no publishable config, no container bindings. Wiring this in means booting a Symfony kernel or reimplementing its dispatcher inside Laravel. Don't.
Maturity is a real risk even on the Symfony side. Zero stars, zero dependents, single maintainer, docs that stop after installation. Version pins are tight: PHP ^8.4, Symfony 7/8, Doctrine ORM ^3.5, DoctrineBundle ^3.0. Routine PHP or framework upgrades become coordinated work on someone else's schedule. If you adopt it, budget for owning it — reading src/ is the only documentation.
The release notes themselves are a warning. v1.5.3 fixes duplicate dispatch of forced flushes; v1.6 adds processing of events added during processing of other events. That's churn at the transaction boundary, which is exactly where correctness bugs hide. Expect the analogous hazards when you port: nested transactions, savepoints, and explicit DB::transaction() calls inside an observer.
Strip updated_at from every change set. getChanges() includes it, so your "did anything meaningful change" guard is otherwise always true and your audit log is noise.
Observers do not fire for mass updates, raw SQL, or the query builder. Order::where('status','x')->update([...]), DB::table(...)->update(), and migrations silently bypass model events — this is the #1 production surprise and it means your audit log will gap. Ban mass updates by convention plus a static-analysis rule, and consider a DB-level trigger as a compliance backstop.
DB::afterCommit doesn't fire inside a test transaction. With RefreshDatabase everything rolls back, so after-commit listeners never run and tests pass for the wrong reason. Call handle() directly for unit coverage, or disable transactions (DatabaseTruncation) for the specific tests that need real commits.
Half-failed listeners are normal — design for it. A listener that writes an audit row and calls an external API can do one without the other. Make it idempotent with an event_id UUID as a dedupe key and push external calls to a queue with retries and a dead-letter path.
Infinite loops are one line away. An updated observer that saves the same model re-enters forever. Guard with a static flag, or better: never write to a model from its own observer.
Debugging toolkit: php artisan event:list shows listeners but not observers — grep for ObservedBy and ::observe( instead. Event::fake() hides ordering bugs, so run the side-effect listeners for real locally at least once. On the Symfony side, debug:event-dispatcher is the equivalent.
Extension points are yours to define. The bundle has no plugin surface beyond Symfony's dispatcher. Your three deliberate seams in a Laravel port are: the domain event class (grow fields here), the listener set (add/remove side effects freely), and the queue connection (route slow work away). Everything else is replaceable.
How can I help you explore Laravel packages today?