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.
Whether we can ship an audit trail we're willing to stand behind. v1.3.1 is almost entirely about the integrity of the record: transaction boundaries, what actually got written versus what we think got written, and what leaves the process. That maps directly to compliance, customer trust, and incident-response decisions rather than to feature work.
COMMIT/ROLLBACK sent as raw SQL, a rollback that failed and left a session open for the next request. Before this, AuditTransaction::run() could report a commit that hadn't happened. If we're telling auditors or customers "every change to these entities is recorded atomically with the change itself," this release is the difference between that claim being true and being approximately true. That is a go/no-go input, not a nice-to-have.json_encode shows, and Messenger's PhpSerializer then stored everything else. This was live since 1.0.0 and applied even with no redact.fields configured. Any decision to route audit data through Messenger, or to store it in a shared database, should be re-reviewed against this release. If we have production data written by ≤1.3.0 with object-valued fields, a data-hygiene sweep is warranted.jsonSerialize() being called twice so what was checked wasn't what was written. That is a strong argument for consuming and pinning this rather than growing an in-house equivalent — and for budgeting upgrade work as a recurring line item, not a one-off integration.audit:check now validates every audit declaration on the audited connection before anything is flushed: always-recorded associations, associations without a representer, unmapped fields, version/generated/binary columns, untrackable collections, BLOB under #[AuditField], bound streams. It fails the check for anything it can't honour. This turns "we'll discover it in production on the first write of that entity" into a pre-merge failure. Low effort, disproportionate risk reduction — worth scheduling regardless of whether we upgrade anything else.log vs throw) is now a product-visible behaviour, and the release makes the semantics precise: under log the rest of the record is written and the offending field is named; inside an AuditTransaction the commit is refused; under throw the flush fails. That's a real decision with user-facing consequences — an incomplete-but-committed row versus a failed business operation — and it should be documented as policy, not left at the default.immediately: true is now documented honestly: the record is written before the request ends and is findable after the index's next refresh. If any roadmap item assumes real-time audit visibility (live dashboards, "show me the last change" in the UI), it needs re-scoping or a different read path.RecordCreatedEvent / CollectedRecord / transport consumers now see stdClass, arrays and strings where they previously saw application objects and DateTimeInterface. Any code doing instanceof MyDto on a change value breaks. Dates and enums are normalised to one form everywhere in changes. This is a scheduled migration, not a patch bump.Adopt when:
AuditTransaction at the audited connection so driver-level commits and rollbacks are actually observed.audit:check in CI and treat a failure as a blocker — the package is now designed to be used that way.Look elsewhere, or scope expectations down, when:
#[AuditField], entity metadata. Retrofitting it onto an Eloquent codebase is a bigger project than the audit trail is worth.audit:check warns when the outbox's connection isn't watched, and without the observer only the transaction level is checked — silent gaps remain by design.Executives (one paragraph): A new release of the audit-trail library we use for change history closes several defects that could have made our audit record incomplete or, in one case, exposed private data. Specifically: audit history could be reported as committed when it wasn't; a rollback that failed could leave a database session open for the next request, with subsequent work silently inside it; and objects placed in audit records were being written into a queue table in our own database with all their properties, including private ones, even with redaction unconfigured. All of these are fixed, and the package now ships a single command we can run in CI that validates every audit declaration before code reaches production. The cost is a contained upgrade: our own listeners and event consumers see plain values instead of application objects, which is a day or two of engineering plus test coverage. Recommendation: upgrade on a planned schedule, treat the CI check as a merge gate, and — if we have production audit data written before this version — run a one-off review of object-valued fields for exposed data.
Engineering (the actionable summary):
stdClass — never application objects. RecordCreatedEvent, CollectedRecord, the failure path and every transport are affected. $record->changes['x'] instanceof MyDto and DateTimeInterface on a Change side will break; consult UPGRADE.md. Dates and enums are normalised to one form per place (UTC Y-m-d H:i:s with fraction; backed enum value / pure enum name). changes isn't indexed, so no mapping depends on it; attribute values keep their mapping-indexed form.audit:check to CI. It asks every flush-time question of every audited class at once, before any entity is involved, and fails on anything it can't honour. It also reports when the outbox's connection isn't watched — fix that, or accept level-only checking.run() now refuses operations that leave a level open, that close its transaction themselves, or that send BEGIN/START TRANSACTION/COMMIT/END/ROLLBACK/ABORT as SQL. Test your actual write paths, including any listener that runs when the frame closes.BLOB columns under #[AuditField] are now refused per the policy — under log the rest of the record is written with the field named and omitted, inside AuditTransaction the commit is refused, under throw the flush fails. Decide which you want per entity; the default (a log line the first time) is quiet.redact.max_depth and redact.max_nodes were silently inert without redact.fields; they now apply either way. If you set them and assumed they were bounding something, they weren't — re-measure payload sizes and queue table growth.How can I help you explore Laravel packages today?