Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Elasticsearch Audit Bundle Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  1. The published config — check 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).
  2. UPGRADE.md — v1.3.x changes what leaves the writer (RecordCreatedEvent, CollectedRecord, transports) from objects to plain values, and normalises date/enum forms. Read this before touching any listener.
  3. The 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.

Implementation Patterns

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:

  • The operation must not leave a transaction level open.
  • The operation must not commit or roll back run()'s own transaction.
  • The operation must not send transaction control as SQL (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.

Gotchas and Tips

  • A commit-and-begin-again is invisible without the observer. 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.
  • SQL transaction control is parsed, but not all of it. 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.
  • Debugging a refused transaction: the message deliberately does not say whether the operation committed or rolled back, because the level cannot tell. Bisect by logging around your own commit()/rollBack() calls; the refusal means one of them ran inside the frame.
  • A failed rollback now closes the connection. The session is over and the next use opens a new one. Don't cache the connection, the 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.
  • Bound streams are dropped, not mangled. A stream bound to an audited column is no longer converted (it used to be recorded as '' 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.
  • Redaction happens before write, and the queue is a database table. The old object-passthrough meant private properties reached the Messenger queue and json_encode on the index looked clean because it only shows public properties. Audit both storages, not just the index, when validating redaction.
  • Readonly properties and enums in your own DTOs used to break encoding entirely (elastic/transport walks bodies by reference to null-strip, PHP refuses references to readonly properties, and an enum's 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.
  • Extension points: 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.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
gmostafa/php-graphql-client
xeviant/paystack
snowsoft/faker
meditationsteps/blogetc
animalyoung/laravel-influxdb
onix-solutions/sms-telera
lonely-lights/prosetta
oxygen/data
oxygen/core
jengo/base
xiloryx/log-collector
a21ns1g4ts/filament-stripe
la-php/laravel-admin
artisan-build/community-prompts
vleroy/smart
ayles-software/laravel-australian-public-holidays
cboxdk/laravel-id-spatie
cboxdk/laravel-id-billing
cboxdk/laravel-id-billing-bridge
simple-as-fuck/php-performance-log