ibrows/loggable-bundle
Symfony bundle that automatically logs changes to Doctrine entities, tracking who created, updated, or deleted records. It hooks into Doctrine listeners and the Symfony event system to build an auditable history of your application's data without repetitive logging code in every service.
Reality check before anything else. ibrows/loggable-bundle is a Symfony bundle, not a Laravel package. It wires itself into Doctrine ORM's unit of work via event listeners (onFlush / postFlush-style) and into Symfony's DI container. There is no ServiceProvider, no Eloquent integration, and no artifact you can composer require into a Laravel app that "just works." If your stack is Laravel, treat this repo as a reference implementation you read once and reimplement in ~100–300 lines of Eloquent observer code. That is genuinely a 1–2 day job and removes all dependency risk (0 stars, 0 dependents, no maintenance signal).
Minimal steps (Symfony + Doctrine project):
composer require ibrows/loggable-bundleconfig/bundles.php:
return [
// ...
Ibrows\LoggableBundle\IbrowsLoggableBundle::class => ['all' => true],
];
doctrine:schema:update --dump-sql or generate a migration so the *_log table is versioned.Where to look first (three files, everything else is commentary):
Resources/config/services.* — which Doctrine events are subscribed and which service IDs do the work.Loggable annotation/attribute class — whether you opt in per-entity or per-property, and whether an "ignore" variant exists for volatile fields (last_login, updated_at, counters).Entity/LogEntry*.php (or similarly named) — the storage contract: action, object class, object ID, version, diff payload, actor, timestamp. You will query this shape forever, so read it before writing any admin UI.First use case — pick something boring but high-stakes. Annotate an Invoice, Subscription, or PriceRecord entity, not User. Logging a hot, frequently-mutated entity on day one floods the table and teaches you nothing except how to delete rows. Start where a support person would actually ask "who changed this and from what to what?"
Laravel translation table (what you actually build):
| Bundle concept | Laravel equivalent |
|---|---|
Doctrine onFlush listener |
Model::updated() observer / Eloquent\Events |
| Annotation/attribute opt-in | A Loggable trait or $logAttributes allow-list on the model |
| Log entity + table | Eloquent model AuditLog + migration |
| Actor resolution from Symfony security context | auth()->id() inside the observer |
| Diff payload (old/new per field) | $model->getOriginal() vs $model->getChanges() in updated |
| Delete tracking | deleting observer capturing attributes before delete |
If you're on Laravel and want a maintained package instead of a port, spatie/laravel-activitylog (broad activity log) or owen-it/laravel-auditing (field-level before/after) are the established choices — evaluate both before deciding to hand-roll.
1. Opt-in narrowly, then widen. Start with the attribute on a single entity. As confidence grows, apply it to the entities that matter for compliance or support forensics. Resist the urge to make it global — an audit table that logs every write is a write-amplification and PII liability, not a feature.
2. Field allow-listing. Even with an entity marked loggable, most implementations of this pattern log the whole changed field set. Before shipping, prune fields you never want duplicated into a second table: passwords, tokens, PII columns, raw blobs, timestamps like updated_at that change on every write. If the bundle doesn't expose an "ignore" mechanism, fork the listener and add one — this is one of the two design decisions you must make up front (the other is retention).
3. Reader layer, not raw queries. Wrap the log table in a repository/service that exposes the shapes you actually need:
$log->forEntity(Invoice::class, $invoice->id)
->latest()
->limit(50)
->get()
->map(fn ($entry) => [
'at' => $entry->createdAt,
'actor' => $entry->user,
'action' => $entry->action,
'diff' => $entry->data, // ['status' => ['old' => 'draft', 'new' => 'sent']]
]);
UI code should never know the schema; when you add retention/partitioning later, only this layer changes.
4. Actor attribution — resolve it inside the write path, once. The bundle pulls the acting user from Symfony's security token. In Symfony that's free. In a Laravel port, resolve auth()->id() in the observer (not at query time), because by the time you display the log the actor is a different person. Also capture the actor for CLI/queue contexts explicitly (system user, job initiator) — otherwise the field is silently null and your "who did this?" answer is "nobody."
5. Diffing semantics — pick one and document it. The bundle records old/new per field. Two subtleties to nail down:
->value) before persisting.6. Read-path integration points. Three consumers, one data source:
entity_id + date range, filter by actor.Building the reader layer first means all three ship from the same query interface.
7. Transactions and failure isolation. The listener runs inside the same Doctrine flush as the business write. Wrap the audit write in its own try/catch (or dispatch to a queue) so a logging failure cannot roll back the business transaction. Audit logging that breaks checkouts is worse than no audit logging.
8. Testing. Write one functional test per operation (create/update/delete) asserting the row shape: action, actor, and diff content. This is your regression net for framework upgrades — and with an unmaintained bundle, you'll need it.
It's Symfony. This is the dominant gotcha and it invalidates any "just install it" motion in Laravel. On Laravel, don't attempt a Doctrine bridge purely to host auditing — that's a large architectural change for one concern. Reimplement the pattern.
Zero adoption = zero hardening. No dependents means no reproducible integrations to learn from and no evidence of compatibility with recent PHP/Symfony/Doctrine versions. Assume it needs patching to run at all, and test before you build on it.
Actor is often null. Security-context resolution doesn't fire in CLI commands, queue workers, and tests. Explicitly capture and set a system/automation actor; document the convention so "null" isn't ambiguous.
Value/old-vs-new drift on updates. If you store only the changed fields, reconstructing "what the record looked like at time T" requires replaying the log. That's fine for "who changed field X," but not for point-in-time restore. If you need snapshots, store the full entity state on each change and accept the storage cost.
Delete logging is the sharp edge. On deleting, the entity's attributes are still readable; after the flush they're gone. Capture the full attribute set in the delete listener, otherwise your "deleted" entries are useless ("something was deleted, we don't know what").
PII duplicated silently. Any field-level diff copies values into a second table. If User is loggable and you don't redact email/phone/address, you've created a shadow copy of personal data with different retention rules — a GDPR and storage liability. Allow-list, don't deny-list.
Bounded table growth. Audit tables grow monotonically and are never deleted by the framework. Plan retention (archive after N months), partitioning (by date or entity type), and a vacuum/prune job before the table is large. Retrofitting partitioning on a hot audit table is painful.
Listener signature drift = silent no-op. When Symfony/Doctrine bump event signatures, an unmaintained listener can stop being called without a loud error — you keep shipping, the audit trail goes dark. Add an assertion in your test suite (a write must produce a log row) and run it in CI, not just locally.
Schema coupling to domain entities. The log stores class names and possibly property names. Refactoring an entity (rename, move namespace, split) orphans its history. Either keep a stable "auditable key" on entities or accept that old log rows reference classes that no longer exist — and handle that in the reader layer.
Extension point to watch. The Entity/LogEntry mapping is where you'll want to add columns (tenant ID, request ID, IP). Fork it early if your reader layer needs those — otherwise you'll be migrating a populated audit table mid-flight.
Debugging tip. When logs aren't appearing: verify the bundle is registered, the entity is actually annotated (attribute reflection is a common silent miss), the flush occurred (listeners fire on flush, not on persist()), and the log table exists. In that order — the first two account for most "it doesn't work" cases.
How can I help you explore Laravel packages today?