borsche/elasticsearch-audit-bundle
A Symfony bundle that records who changed what into Elasticsearch: Doctrine entities audited automatically, custom domain actions logged on demand, small changes coalesced into one record, async writes via Messenger, and a filterable read API for searchable audit history.
This package audits Doctrine entity changes, records them through an outbox, and ships them over Symfony Messenger to an Elasticsearch index. Start with three files before writing any code:
on_failure (log vs throw), the redact.* block, and which connection the outbox lives on. The outbox connection must be the audited connection, or transaction checking degrades (see Gotchas).RecordCreatedEvent, CollectedRecord, transports) from objects to plain values, and normalises date/enum forms. Read this before touching any listener.AuditTransaction / transaction-watching section of the README — it now spells out exactly what is and is not observed.First use case, in order:
composer require <vendor>/<package>
php artisan vendor:publish # config + migrations for the outbox table
php artisan migrate
php artisan audit:check # validates every declaration on the audited connection
audit:check is the correct entry point because it no longer needs an entity: it asks every flush-time question — always-recorded associations, associations without a representer, unmapped fields, version/generated/binary columns, untrackable collections — of every declared class at once, before anything is flushed. Wire it into CI on day one; a declaration it cannot honour fails the check rather than surfacing as a log line the first time that entity happens to be written.
Then annotate a single entity with #[AuditField] (or implement AuditableInterface for per-instance decisions), flush, and watch a record land in the index. Only after that add the Messenger transport and the outbox.
Declare-then-verify. Treat audit:check as the compile step for your audit surface. Add a field, run the check. Because AuditableInterface answers per instance, its classes are only named as checked at flush — everything statically declarable is caught earlier, so prefer attributes where the decision is not genuinely runtime.
Transaction wrapping. Use AuditTransaction::run() around any operation that writes audited entities plus related history and must be atomic. The contract as of this release is strict:
run()'s own transaction.BEGIN, START TRANSACTION, COMMIT, END, ROLLBACK, ABORT via executeStatement()).All three are refused, and refused levels are rolled back in full. Design your services so transaction boundaries live in exactly one place — the run() call — and repositories never call beginTransaction() themselves.
Failure policy per environment. on_failure: throw in dev and CI so bad declarations and unencodable values break the build; on_failure: log in production so a single unrepresentable field degrades to a reported omission rather than a failed request. Note the asymmetry: under log the rest of the record is still written (the row is true in every other column), but inside an AuditTransaction the commit is refused outright, and a stream in a tracked element's field or an always-recorded field refuses the whole record regardless of policy.
Consume plain values. Listeners on RecordCreatedEvent, anything reading CollectedRecord, the failure path, and every transport now see scalars, arrays, and bundle-built stdClass objects — never an application object. Rewrite listener logic that did $record->changes['x'] instanceof MyDto or type-hinted DateTimeInterface on a Change's side; both are now stdClass/array/string. If you need domain objects back, rehydrate explicitly at the edge.
One form for dates and enums. Everywhere in changes — a Change's side, a value filed under a field, hand-built pairs, nested inside arrays or objects — a date is UTC Y-m-d H:i:s (with fraction when present), a backed enum is its value, a pure enum is its name. In an attribute the form follows the mapping, since mappings index it; changes is not indexed, so nothing depends on its shape. Normalise once in a shared helper rather than per listener.
Redaction. Configure redact.fields for field-level redaction; redact.max_depth and redact.max_nodes now apply whether or not fields is set, so a limit you wrote down earlier and saw do nothing now actually bounds traversal. Each object is asked for its representation exactly once per write, and what it answered is what is written — no more double-serialisation of JsonSerializable.
Outbox and delivery. immediately: true means the record is written before the request ends; it is searchable only after the index's next refresh. Do not build read-after-write UI on top of it. With the outbox enabled, the Messenger queue is a table in your own database — which is why the object-leak fix matters beyond correctness.
AuditTransaction can only check the transaction level. An operation that commits and starts a fresh transaction returns to level one and looks clean. On the audited connection the driver's commits and rollbacks are counted, which is what catches it; without that observer you get a false "committed together" report. If audit:check warns that the outbox's connection is not watched, treat it as a correctness bug, not a lint.BEGIN/START TRANSACTION/COMMIT/END/ROLLBACK/ABORT sent as whole statements are read and applied to the log's frames. The implicit commit MySQL performs for DDL is not seen — the README says so. Keep DDL out of audited transactions.commit()/rollBack() calls; the refusal means one of them ran inside the frame.EntityManager, or anything bound to the old session across that boundary; re-resolve from the container.BLOB under #[AuditField] is refused, not silently lost. It surfaces on every flush through the failure policy, naming class, field, and what to audit instead — a stream is what Doctrine hands back once the row is read. Audit a hash or a length instead.'' while the row held every byte). That field's change is omitted and reported, and so is the next change of it — nobody read its old side. Expect one extra gap after any stream write.json_encode on the index looked clean because it only shows public properties. Audit both storages, not just the index, when validating redaction.name is one). Fixed for bundle-built values; if you pass DTOs anywhere else in the pipeline, keep them out of the encoder's path.immediately: true is a write-timing flag, not a visibility flag. Documentation was corrected; behaviour did not change.AuditableInterface for per-instance decisions (checked at flush, not by audit:check statically), the failure policy for graceful degradation, and the redact.* limits for bounding traversal cost on deeply nested values.How can I help you explore Laravel packages today?