herdwatch/entity-audit-bundle
Herdwatch EntityAuditBundle adds automatic audit logging to Doctrine entities in Symfony apps. Track who changed what and when, store field-level diffs, and review a complete history of inserts, updates, and deletes without cluttering your domain code.
This is a maintained fork of simplethings/EntityAuditBundle — the API surface, workflow and commands are essentially the upstream ones. If you've used that bundle, nothing changes except the namespace and (possibly) the config root key.
As of v2.1.0 the fork merged upstream 1.23.0, which is the first substantive sync since the fork was cut. Practically that means the fork is tracking upstream again rather than drifting, so upstream docs and StackOverflow answers apply with more confidence than they did on 2.0.x. It also means config keys, service ids and command names were potentially touched by the merge — the release notes only say "Merge upstream 1.23.0" without enumerating changes, so verify against your installed version rather than trusting a blog post.
composer require herdwatch/entity-audit-bundle
config/bundles.php):Herdwatch\EntityAudit\EntityAuditBundle::class => ['all' => true],
config/packages/herdwatch_entity_audit.yaml. Because this is a low-adoption fork, confirm the exact root key and available options before writing anything custom — this matters more after the 2.1.0 upstream merge, not less:bin/console config:dump-reference herdwatch_entity_audit
bin/console debug:container audit_reader # service id may be herdwatch_entityaudit.reader / simplethings_entityaudit.reader
bin/console list audit # confirm command names survived the merge
A typical config:
herdwatch_entity_audit:
table_suffix: '_audit'
revision_table_name: 'revisions'
global_ignore_columns: ['created_at'] # noisy columns you never want versioned
revision_username: 'anon'
use Herdwatch\EntityAudit\Mapping\Annotation\Audited;
#[Audited]
class Product { /* ... */ }
(Use @Audited docblock annotations if you're still on annotation mapping — the fork keeps both. Upstream has been nudging toward attributes; a future merge may drop annotations without a major bump here, so prefer attributes in new code.)
bin/console audit:update
# or, if the fork prefixes commands:
bin/console herdwatch:audit:update
audit:update is a schema diff: it adds audit tables/columns for newly audited entities, it does not drop or alter existing ones.
First use case — "show me the history of product #42":
$reader = $container->get('audit_reader'); // AuditReaderInterface
$revisions = $reader->getRevisions(Product::class, 42); // [1, 4, 9, ...]
foreach ($revisions as $rev) {
$product = $reader->find(Product::class, 42, $rev); // Product state at that revision
$meta = $reader->getRevision($rev); // Revision: getTimestamp(), getUsername()
}
If that returns data, you're done with setup. Everything else is querying and reverting.
Build a generic history endpoint. The most valuable day-to-day pattern is one controller that serves history for any audited entity:
#[Route('/history/{entity}/{id}', methods: ['GET'])]
public function history(AuditReaderInterface $reader, string $entity, int $id)
{
$fqcn = $this->entityMap[$entity] ?? throw $this->createNotFoundException();
return $this->json(array_map(fn (int $rev) => [
'rev' => $rev,
'timestamp' => $reader->getRevision($rev)->getTimestamp()->format(DATE_ATOM),
'username' => $reader->getRevision($rev)->getUsername(),
], $reader->getRevisions($fqcn, $id)));
}
Diff two revisions. find() twice and compare with a normal Doctrine UnitOfWork-free get_object_vars/property loop. There is no built-in diff service; a small EntityComparator of your own is the idiomatic extension point.
"What changed in revision N?" is a single call and is the best hook for activity feeds:
$changed = $reader->findEntitiesChangedAtRevision($rev);
// returns entities hydrated at that revision; use instanceof / metadata to filter
Revert from the UI. The command is the safe path:
bin/console audit:revert Product 42 17 --dry-run
bin/console audit:revert Product 42 17
Programmatically, revert is: find() the entity at the target revision, then EntityManager::merge() and flush() — the merge re-audits, producing a new revision that restores the old state. That is a feature: reverts are themselves audited.
Capture the acting user. Point revision_username_callable (or override the Revision entity) at your token storage:
herdwatch_entity_audit:
revision_username_callable: 'App\Audit\UsernameProvider::getUsername'
Audited associations. Add #[Audited] to owning-side collections to version the join table; you'll get <join_table>_audit with rev/revtype. To-one associations are stored as FK columns directly in the entity's audit row — no extra config needed, but the target entity must be audited too, otherwise the FK dangles.
Integration with tests. Audit writes happen at flush time; fixtures via doctrine:fixtures:load produce revisions 1..N. If you assert on revision numbers, pin them by resetting the schema in setUp() and using audit:update in your test bootstrap rather than doctrine:schema:update alone (which won't create audit tables).
Ignore the noise. global_ignore_columns for created_at/updated_at, #[Ignore] per property for things like lastSeenAt or denormalized counters. This is the single biggest lever on audit-table size.
$em->createQuery('UPDATE ...'), NativeQuery, and DBAL statements never fire the listener. Either flush per entity or accept the gap explicitly.audit:update never drops columns. Remove a property from an entity and the audit table keeps a NOT NULL column — inserts then fail. Every schema change needs a paired Doctrine migration for the audit tables (ALTER TABLE product_audit DROP ...). Make this a checklist item in review.json, uuid, or enum type that isn't registered in the audit entity manager throws at read time, not write time. Same for Embeddables — they're flattened and work, but nested types often don't.revtype semantics: 1 = INSERT, 2 = UPDATE, 3 = DELETE. When you write raw queries against *_audit tables you need all three or you'll show resurrected rows for deleted entities.ManyToOne FK in an old audit row points at an id that may no longer exist. Always load the related entity with find() and null-check; never assume the target is alive.$reader->getEntityManager() gives you the reader's connection/EM. Raw SQL against audit tables through the app EM will miss the audit schema and typically fails on the revisions join.rev. If you're paginating "changes for this entity", getRevisions() with $limit/$offset is the right tool; don't scan the revisions table.#[Audited], (2) was the entity loaded via find()/DQL or via a bulk/native path, (3) is the field in global_ignore_columns, (4) does the audit table actually have the row (SELECT * FROM product_audit WHERE id = 42). The listener is silent on skip; there is no warning.composer.json, run config:dump-reference, debug:container audit_reader and your revert/history smoke tests against a copy of production schema, and diff the published config tree against what you have in config/packages/. Config key, service id, and command-name changes are exactly the kind of thing an upstream merge can carry in silently.Revision entity (add ip, requestId, etc. — set revision_entity in config), custom AuditReader service decoration, and a postFlush listener of your own if you need to enrich revisions after the fact.How can I help you explore Laravel packages today?