gtt/doctrine-auditable-bundle
GTT Doctrine Auditable Bundle for Symfony automatically tracks entity changes in Doctrine, recording inserts, updates, and deletes with user and timestamp metadata. Easily configure audited fields and review a reliable audit trail for compliance, troubleshooting, and data integrity.
What you're actually adopting. This bundle is a Doctrine event-listener that writes entity changes into a single audit table (one row per changed entity per flush), storing the changeset as a JSON diff plus who did it and when. That single-table design is the whole pitch: unlike DoctrineExtensions\Loggable, which creates a version table per audited entity, you get one query-able timeline you can paginate, export, and index. The trade-off is that reads are delta-based (see Gotchas).
Reality check before you start: this is a Symfony bundle, not a Laravel package. There's no service provider, no config publish, no Auth::id() integration. It has 5 stars and zero dependents. 5.0.0 (PHP 8.0 minimum, Symfony 6 support, attribute-based ORM mappings, final classes, auditable config resolved at cache-warming time) is a modernization release, not a feature release — the capture semantics (onFlush/postFlush, changeset-as-JSON-diff) are unchanged, so every read/write caveat in this assessment still holds. The valuable part remains ~a few hundred lines of subscriber plus a mapped Audit entity; budget for a half-day port rather than a composer require and go.
What 5.0.0 actually changes for you:
Audit entity with #[ORM\Entity]/#[ORM\Column] attributes straight into app/Models/Doctrine/ instead of hand-translating XML/YAML.composer.json constraints before bumping.final on non-inheritance classes — this removes "subclass and override" as an extension strategy (see Gotchas).Minimal steps (Symfony, the intended path):
composer require gtt/doctrine-auditable-bundle
// config/bundles.php
Gtt\Bundle\DoctrineAuditableBundle\GttDoctrineAuditableBundle::class => ['all' => true],
# config/packages/gtt_doctrine_auditable.yaml
gtt_doctrine_auditable:
entity_manager: default
Then generate the audit table from the bundle's shipped mapping (bin/console doctrine:schema:update --dump-sql or doctrine:migrations:diff), and mark your first entity as auditable with the bundle's marker interface/attribute found under src/Entity/. On 5.0.0 you must clear the cache after changing that opt-in, because the auditable set is now computed during warmup.
Laravel path: install doctrine/orm + laravel-doctrine/orm, copy or require the bundle, and in a service provider's boot() attach the subscriber to the manager's event manager directly:
$em = app('em');
$em->getEventManager()->addEventSubscriber(new AuditSubscriber(/* blame provider, config */));
Skip the DIC compiler passes and the Symfony security adapter; supply your own blame provider (below). Map the audit entity in your Doctrine config (config/doctrine.php) rather than relying on bundle auto-registration — or, on 5.0.0, let the entity's own attributes do the mapping. Then replicate the warmup step: the bundle resolves which entities are auditable in a cache-warmer, so under Laravel that resolution never runs unless you trigger it. Resolve it once at boot (or in a cached config/artisan command) and make sure your audit subscriber is constructed with that resolved set.
Where to look first (in this order):
README.md — the config keys and the auditable-entity opt-in are the only two things you truly need.src/Entity/ — the Audit entity and any Blame/Diff value objects; this defines your schema and your read API, and on 5.0.0 it also shows you the attribute mapping you can copy verbatim.src/EventListener/ — the onFlush/postFlush pair. Read it end to end once; it's short and it tells you exactly what is and isn't captured.src/Blame/ — the provider interface you'll reimplement for Laravel.src/CacheWarmer/ (or wherever the auditable-config resolution now lives) — new in 5.0.0, and the piece you must replace, since it's the one that never fires outside Symfony.src/Resources/config/ — the service wiring, so you know what to construct manually.First use case: audit a User. Create → flush → update the name → flush → delete → flush, then SELECT * FROM audit. You should see three rows: create with the initial fields, update with only name in the diff, delete with whatever the listener snapshots on removal. Inspect those three rows before writing any UI; they set your expectations for everything downstream. Do it once on the Symfony reference install and once through your Laravel wiring — if the row counts differ, your warmup step is missing.
1. Wrap the bundle in your own facade. Application code should never touch the bundle's classes. Expose an AuditRepository (or AuditQuery) interface with historyFor(object $entity): iterable and find(int $id): AuditEntry, implemented by the Doctrine repository. This is your escape hatch when you inevitably fork the bundle or replace it with a 150-line observer + activity_log table — and it's non-negotiable on 5.0.0, where final classes mean you can't subclass your way out of trouble.
2. Blame resolution is the main integration point. The bundle asks a provider "who is acting?" at flush time. In Symfony that's a token-storage adapter; in Laravel it's three lines:
final class AuthBlameProvider implements BlameProviderInterface
{
public function getBlame(): ?string
{
return Auth::id() !== null ? (string) Auth::id() : null;
}
}
Bind it as a singleton so the listener resolves it lazily — resolving Auth at container-build time breaks in queues and artisan commands, where there is no user. If you want a durable "system" actor for console/queue writes, return a sentinel ('system', 'queue:invoice-sync') rather than null, so the audit trail never has an unexplained gap.
3. Tenant / impersonation-aware blame. Same interface, richer payload: store "{tenantId}:{actorId}", or stuff the impersonated user into a request-scoped service that the provider reads. If your audit column is an integer FK, this is the moment to widen it to a string — retrofitting that later means a migration plus rewriting history.
4. History endpoint / timeline UI. The single-table design makes this trivial:
$rows = $auditRepo->createQueryBuilder('a')
->where('a.objectClass = :class')
->andWhere('a.foreignId = :id')
->setParameter('class', User::class)
->setParameter('id', (string) $user->getId())
->orderBy('a.loggedAt', 'DESC')
->setMaxResults(50)
->getQuery()
->getResult();
Then render the diff. Dump one row's diff to see its exact shape before coding against it — the representation (plain ['old' => …, 'new' => …] array vs. a small value object with getters) is version-specific. Normalize to ['field' => ['old', 'new']] in your own presenter so the view never depends on the bundle.
5. Redaction before persist. Passwords, tokens, PII. Do not try to filter in the UI — the plaintext is already in your database, in backups, in your read replicas. Either mark fields as non-auditable in the mapping config, or wrap the listener with a diff transformer that masks values before the audit row is persisted. (Since 5.0.0 the listener is final, so wrapping/decorating is the only option — see Gotchas.) Treat this as a hard requirement, not a nice-to-have: an audit table is a permanent, wide-open copy of everything your app writes.
6. Reconstruct state at time T. Diffs are deltas, so replaying them from creation forward (applying new values in loggedAt order) gives you a snapshot. It works, but build it as an offline reporting job — never on the request path, and never assume the audit rows are complete (see bulk updates below).
7. Async read model. Don't run reporting queries against the audit table. Push inserted audit IDs onto a queue (Doctrine postFlush → dispatch) and denormalize into a search-indexed table where field-level filtering is cheap. Keeps the write path thin and your audit writes resilient to reporting downtime.
8. Warming the auditable-entity set (new in 5.0.0). Opt-in is no longer evaluated per-flush. In Symfony that's a cache-warmer; in Laravel you own it. Resolve the auditable class list once, cache it (config cache, APCu, a artisan audit:warm command), and pass it into your subscriber:
// AppServiceProvider::boot() or a dedicated cached resolver
$auditable = Cache::rememberForever('doctrine.auditable', fn () => $resolver->resolve());
Anything that opts an entity in dynamically — multi-tenant model sets, feature flags around auditing, tests that register a fixture entity on the fly — must bust that cache explicitly, or your "nothing is recorded" bug becomes a stale-cache bug.
9. Testing. A functional test that persists, flushes, and asserts a row is worth ten unit tests here, because the failure mode is "listener not registered" / "entity not in the warmed auditable set" rather than "logic wrong":
$em->persist($user);
$em->flush();
self::assertSame(1, (int) $connection->fetchOne('SELECT COUNT(*) FROM audit'));
Add a second test asserting that a sensitive field produces no plaintext in the audit row, and a third that opts an entity in after the warmup cache is populated and still expects a row. Those two will save you one day.
UPDATE users SET active = 0 WHERE … via DQL emits no onFlush changeset. Any admin bulk action, import, or migration silently produces zero audit rows. Route bulk writes through entities, or accept the gap explicitly and document it.$user->getRoles(), or attaching a child via $parent->addChild(), may produce no row at all if no scalar field on the owning entity changed. Audit the join/child entities themselves, not just the parent.preRemove (or soft-delete instead) — don't assume the delete row is a full record.null and a field never touched look different — check that your diff transformer preserves the distinction, or "cleared the field" renders as "no change".onFlush. Doctrine is mid-unit-of-work there; extra queries trigger implicit flushes and stale-state bugs, and persisting an audit entity naively can re-enter the listener. The bundle schedules audit writes for postFlush — if you wrap or extend the listener, keep that pattern religiously.final classes. Previously you could subclass the subscriber or the audit entity and re-map it. You now need composition (a wrapper subscriber/delegator that calls the bundle's), a Doctrine mapping override, or a straight copy of the entity into your own namespace. Plan for copying the Audit entity if you need extra columns — that's the realistic path, and it also insulates you from a composer update reshaping your table.loggedAt in UTC and convert at the edges. An audit timeline is exactly the feature people cross-reference with external logs, so a local-time column turns into a debugging session.foreignId width and type. UUIDs and composite identifiers need a string column; an int FK (the tempting default) truncates or explodes. Decide before the first production row exists.jsonb (queryable with diff->>'status', indexable with GIN); MySQL json; on SQLite it's text and you get no query support. If you plan to query inside the diff, this determines your whole reporting design.(objectClass, foreignId, loggedAt), plus a retention/archival job (partition by month or copy to cold storage).debug:event-dispatcher and a cache clear; in Laravel, dump $em->getEventManager()->getListeners('onFlush') and your resolved auditable list, and assert your subscriber is in the array.final subscriber, (3) copying or mapping-overriding the audit entity class for extra columns, (4) forking the listener entirely. Anything beyond (2) and you should ask whether you're fighting the package.final, warmup) with no change to auditing behaviour — treat it as "still alive", not "actively developed". Pin the version, vendor the mapping into your own repo (so a composer update can't silently reshape your audit table), and keep the listener behind your AuditRepository facade. If you're on Laravel and only need create/update/delete plus a blame column, Eloquent observers writing to an activity_log table (or spatie/laravel-activitylog) is fewer moving parts than porting this — the single-table diff idea is the thing worth stealing, not necessarily the code.How can I help you explore Laravel packages today?