Download the PHP package metadev/doctrine-audit-trail-bundle without Composer
On this page you can find all versions of the php package metadev/doctrine-audit-trail-bundle. It is possible to download/install these versions without Composer. Possible dependencies are resolved automatically.
Download metadev/doctrine-audit-trail-bundle
More information about metadev/doctrine-audit-trail-bundle
Files in metadev/doctrine-audit-trail-bundle
Package doctrine-audit-trail-bundle
Short Description Opt-in Doctrine audit trail for Symfony: opt-in JSON diffs, actor attribution, sync/async persistence, secure-by-default secret blacklist and optional HMAC tamper-evidence seal.
License MIT
Homepage https://github.com/bastienPetit7/doctrine-audit-trail-bundle
Informations about the package doctrine-audit-trail-bundle
DoctrineAuditTrailBundle
Automatic, opt-in audit trail for Doctrine entity mutations on Symfony.
Every create / update / delete of a marked entity is recorded as a structured
AuditTrailEntry row: the entity class and id, the action, a JSON before/after
diff, and the actor (authenticated user with IP / user-agent, or a fallback
label for CLI / messenger / anonymous contexts).
- Opt-in: only entities annotated with
#[Auditable]are tracked. - Synchronous & safe: raw field values are captured in
onFlush, then formatted and written inpostFlushthrough a dedicated entity manager so the audited unit of work is never touched and the listener never re-enters itself. Deferring value formatting topostFlushlets association identifiers resolve after Doctrine assigns generated keys in the same flush. - Extensible: value formatting and actor resolution are swappable.
- GDPR-aware: ships a built-in blacklist of common secret/credential field
names (
password,apiKey,accessToken, …), per-field ignore via#[AuditIgnore], and a globalignored_fieldslist. It provides the primitives to comply — retention, anonymisation and access control remain the integrator's responsibility.
Table of contents
- Requirements
- Installation
- Host wiring
- Configuration
- Marking entities
- Reading the trail
- Retention & pruning
- GDPR actor anonymisation
- Extension points
- Quality & tests
- Contributing
- License
Requirements
| Component | Version |
|---|---|
| PHP | >= 8.2 |
| Symfony | ^6.4 \|\| ^7.0 \|\| ^8.0 |
| Doctrine ORM | ^2.14 \|\| ^3.0 |
| Doctrine Bundle | ^2.10 \|\| ^3.0 |
The CI matrix runs on PHP 8.2 / 8.3 / 8.4 / 8.5 against Symfony 6.4 / 7.x / 8.x
(Symfony 8 requires PHP ≥ 8.4), plus a --prefer-lowest run on PHP 8.2 + Symfony 6.4.
Installation
Register the bundle (Symfony Flex does this automatically):
Host wiring
The bundle persists logs through a dedicated entity manager (named audit
by default). You declare the manager and its connection; the bundle ships and
registers the AuditTrailEntry mapping onto it (via prependExtension()).
Keeping the audit store on its own connection means schema management for the audit table never collides with the application's own tables.
Create the table:
The bundle ships the AuditTrailEntry Doctrine mapping but no migration
class — make:migration picks up the mapping from the audit entity
manager (the bundle wires it via prependExtension()) and emits a
migration that honours your configured table name and target DB platform.
See docs/migrations.md for the alternatives when
you don't use doctrine/migrations and the bootstrap procedure for
deployments that previously used doctrine:schema:update.
Tamper-evidence & hardening
Production prerequisite. The bundle only ever needs
INSERTandSELECTon the audit table. Grant nothing more, and physically rejectUPDATE/DELETE/TRUNCATEat the database level — audit data is more sensitive than the source data, and an append-only store is the strongest tamper prevention control.
Ship-ready DDL (least-privilege grants + append-only triggers for PostgreSQL and
MySQL) is provided in docs/hardening.sql. For tamper
evidence that survives even a privileged DBA or a restored backup, enable the
optional cryptographic HMAC seal.
Configuration
Every key shown above carries its default value — the configuration block is fully optional. The bundle works out of the box once
storage.entity_managerpoints at an existing connection. Sectionsretention,integrityandaudit:actor-anonymiseare documented in their own sections below.⚠️
force_audit_fieldswrites the value IN CLEARTEXT. This option overrides the built-in secret blacklist (password,refreshToken,apiKey, …) and stores the raw field value in the auditdiffcolumn on every change. Auditing a token « to detect replay » effectively duplicates the secret into the audit store, doubling its leak surface — a stolen audit backup now also leaks live credentials.If you really need to audit a secret, never log the cleartext. Register a dedicated
ValueFormatterInterfacethat emits a non-reversible fingerprint (e.g.substr(hash_hmac('sha256', $value, $appSecret), 0, 16)) and tag it with a higher priority thanScalarValueFormatter. The audit trail then records « the value changed » without storing the value itself.
Consistency model
Audit entries are written through a dedicated entity manager with its own
connection. This keeps your application's unit of work untouched, but it means the
audit write is not part of your business transaction. Two trade-offs follow,
and you choose how to handle them via persistence:
| Mode | Latency / large-flush cost | Audit write failure | Atomicity with business data |
|---|---|---|---|
sync (default) |
paid in the request | propagates (see soft_fail) |
❌ written after the business commit |
async |
offloaded to Messenger | retried by the transport (needs a DLQ) | ❌ eventual, may be lost without a DLQ |
soft_fail: true— a failing audit write is caught and logged via the PSR logger instead of surfacing to the caller. Availability over durability: an entry may be dropped (logged as an error), but the request keeps working. The log context includesdropped_entriesandtotal_entriesso operators can quantify the loss without reproducing the failure. Inasyncmode,soft_failonly catches dispatch failures (broker unreachable, transport rejected the envelope). Once a message has been accepted by the broker, worker failures are handled by Symfony Messenger's retry/DLQ — they are intentionally not soft-failed, because doing so would ACK a failed message and silently drop audit data instead of letting the transport retry it.mode: async— requiressymfony/messenger. Audit entries are dispatched to a transport and persisted by a worker, removing the write from the request hot path (latency, large unit-of-work pressure).createdAtand the integrity signature are frozen at capture time, so relaying later does not alter the entry. Consistency is eventual; configure a retry/DLQ on the transport. Entries are split into chunks ofbatch_size(default100) so a bulk flush never produces a single oversized message — useful because AMQP enforces a lowframe_max(~128KB by default) and Redis Streams cap entry sizes. Each chunk is an independent message, so the audit batch is not atomic across chunks: one chunk may succeed while another retries or lands in the DLQ. Tunebatch_sizeto keep the serialized payload comfortably below your transport's limit. When a dispatch fails mid-flush, the persister keeps attempting the remaining chunks (so a transient broker hiccup on chunk 1 does not silently take down chunks 2+); the aggregated failure is then either raised or — withsoft_fail: true— logged as a single error carrying the exactdropped_entriescount. Messages are stamped withDispatchAfterCurrentBusStamp, so when the audit triggers inside a Messenger handler the entries are only released if the parent handler completes successfully.
Strict atomicity (audit committed if and only if the business transaction commits) requires a transactional outbox and is not yet provided. Track it in the roadmap if you target regulated workloads.
Marking entities
Entities without #[Auditable] are ignored.
The optional label is persisted on each row in the entity_label column — useful
for admin UIs that want a human-readable name next to (or instead of) the FQCN.
Embeddables (#[ORM\Embedded])
Embeddable sub-fields are recorded with their Doctrine dotted path in the
diff, e.g. an #[ORM\Embedded] Money $price produces price.amount and
price.currency keys:
Both ignore mechanisms operate per segment of the dotted path:
#[AuditIgnore]placed on the embedded property hides every sub-field (providerCreds.login,providerCreds.secret, …).- The built-in deny-list and any user-defined
ignored_fieldsmatch against each segment, so a sub-field literally namedsecret,apiKey,token, etc. is filtered even when the parent embeddable is not ignored.
DELETE snapshot modes
By default, DELETE entries store a SHA-256 fingerprint of the deleted entity's non-blacklisted state instead of field values in cleartext:
| Mode | Config value | diff.before content |
Use case |
|---|---|---|---|
| Minimal (default) | minimal |
{_snapshot_hash: "…"} |
GDPR-friendly, no cleartext duplication |
| Full | full |
All non-blacklisted scalar fields and single-valued associations (ManyToOne / OneToOne) as {class, id} references |
Forensic / legacy tooling |
The hash fingerprints the non-blacklisted state. It is data minimization, not encryption. Sensitive fields must still be excluded via
#[AuditIgnore],ignored_fields, or the built-in blacklist.
Detect the shape at read time:
Reading the trail
Retention & pruning
GDPR (art. 5(1)(e)) requires a finite, justified retention period. The bundle
ships an audit:prune console command that deletes entries older than a
cutoff:
Configure a default cutoff so the command can be scheduled without arguments:
The query is bounded by the existing idx_audit_trail_created_at index;
deletions run in --batch-sized chunks so a single invocation never holds a
long transaction on multi-million-row tables.
Wire it to your scheduler of choice — cron, a k8s CronJob, or Symfony
Scheduler. The bundle does not ship a scheduler integration on purpose:
host applications already own scheduling.
Note on append-only setups — if the database role used by your app has no
DELETEprivilege on the audit table (recommended for tamper-evidence), runaudit:prunefrom a dedicated role, or wrap the deletion in a PostgresSECURITY DEFINERfunction owned by the privileged role.
GDPR actor anonymisation
GDPR art. 17 (right to be forgotten) cannot be satisfied by deleting audit
rows: doing so breaks the append-only contract and would defeat the integrity
seal. The bundle ships an audit:actor-anonymise console command that
rewrites the actor PII columns in-place (userId, userIdentifier,
ipAddress, userAgent, actorLabel) for every row attributed to a given
subject, then stamps an actorAnonymisedAt marker:
What happens to each matched row:
| Column | After anonymisation |
|---|---|
userIdentifier |
hash('sha256', <original>) (deterministic, 64-char hex) |
userId |
hash('sha256', <original>) or null if it was null |
ipAddress |
NULL |
userAgent |
NULL |
actorLabel |
'gdpr-anonymised' |
actorAnonymisedAt |
now() (UTC) |
signature |
recomputed so audit:verify keeps passing |
The deterministic sha256 lets support / legal teams correlate the rows that
belonged to the same erased subject without ever holding the cleartext
identifier again. The original userIdentifier itself never appears in the
PSR log either — only its hash, the reason, the count, and timing are logged
(audit.actor_anonymise.completed).
Scope —
audit:actor-anonymiseredacts the actor columns only. Thediffpayload often captures the user's own entity (e.g. aUser.emailupdate); auto-scanning JSON for PII would be brittle and unsafe, so the bundle leaves that to a dedicated application-side script that knows which entities reference the erased subject. Use this command together with such a script for full right-to-be-forgotten coverage.
Append-only hardening and anonymisation
If you have applied the docs/hardening.sql recipe, the
audit role rejects UPDATE — so audit:actor-anonymise will fail by design,
exactly like audit:prune. The bundle does not bypass that on its own; pick
one of:
- Dedicated role — run
audit:actor-anonymiseagainst a second Doctrine connection that uses aaudit_anonymiserrole grantedSELECT, UPDATEon the audit table. The application role staysINSERT, SELECTonly. SECURITY DEFINERfunction (Postgres) — wrap the UPDATE in a function owned by a privileged role, and adapt the trigger so it accepts that function'scurrent_user.- Session flag (Postgres) — keep the trigger but skip its
RAISEwhencurrent_setting('audit.allow_anonymise', true) = 'true', then setSET LOCAL audit.allow_anonymise = 'true'in the command's transaction.
See the optional trigger example at the end of docs/hardening.sql for
pattern (1).
Extension points
Value formatters
The diff is produced by a chain of ValueFormatterInterface implementations.
The first formatter whose supports() returns true wins. Two built-in
formatters are registered (custom formatters typically use priority 0 or higher
so they run before them):
| Formatter | Priority | Handles |
|---|---|---|
DoctrineAssociationFormatter |
-500 |
Doctrine-managed entities (ManyToOne / OneToOne values) |
ScalarValueFormatter |
-1000 |
Scalars, DateTimeInterface, BackedEnum, non-managed Stringable |
Values that no formatter supports are left unchanged in the diff (usually not JSON-serialisable — avoid this in production).
Doctrine associations (ManyToOne, OneToOne)
When an audited field points at another entity, the built-in
DoctrineAssociationFormatter records a stable identity reference, not a
snapshot of the related entity's fields:
Output shape (public API). Association values in the diff JSON follow this convention — changing it in a future release would be a breaking change:
| Key | Type | Meaning |
|---|---|---|
class |
string |
Entity FQCN (ClassMetadata::getName()) |
id |
scalar | object | null |
Single-column PK (42); composite-key map ({"tenant": "…", "ref": …}) when the mapping declares multiple identifier fields — including when only some columns are set yet; or null when the association is unset |
This answers which entity was linked, not what that entity looked like at that moment. To also store a human-readable label, register a custom formatter with a higher priority (see below).
Two-phase diff pipeline. ChangeSetExtractor works in two steps: extract*
methods gather raw values from the unit of work during onFlush (no formatter,
no size quota, no minimal-delete hash); format() applies the formatter chain,
the deletion-snapshot mode, and the size quota during postFlush, once Doctrine
has assigned all generated identifiers. This is what makes a {class, id}
reference reliable even when the related entity is cascade-persisted in the same
flush.
The formatter reads identifiers via ClassMetadata::getIdentifierValues() — it
does not lazy-load associations and performs no extra SQL. Composite-key
shape follows the mapping's identifier cardinality (ClassMetadata::getIdentifier()),
not the number of values currently extracted.
ToMany collections (
OneToMany,ManyToMany) are off by default. Enable tracking viadiff.track_collections: true; the listener then readsUnitOfWork::getScheduledCollectionUpdates()/getScheduledCollectionDeletions()and emits anUpdateentry on the owner with an added/removed delta:The recorded shape is
{_collection: true, added: [...], removed: [...]}placed underafterin the diff. Items go through the same formatter chain as scalars (so a managed entity becomes{class, id}). Per-collection opt-out is the existing#[AuditIgnore]attribute on the property. Full collection snapshots — neither on DELETE nor on creation — are still out of scope (only deltas are recorded).
Managed entities vs Stringable
A Doctrine-managed entity that also implements Stringable is formatted as
{class, id}, not as its __toString() output. Audit trails need stable
identifiers; display labels can change over time, and __toString() may trigger
lazy-loading or other side effects (forbidden while the unit of work is open).
Non-managed Stringable value objects (not known to any entity manager) still
go through ScalarValueFormatter and are stored as strings.
Custom value formatter
Implement ValueFormatterInterface and tag the service with
doctrine_audit_trail.value_formatter. Priority 0 (or any value greater
than -500) runs before the built-in association and scalar formatters:
To enrich an association with a display label (only when the data is already in memory — never lazy-load inside a formatter):
Custom actor resolver
Implement AuditUserResolverInterface and point the config at it:
⚠️ Behind a reverse proxy, configure
framework.trusted_proxies/trusted_headers. The default actor resolver captures the client IP viaRequest::getClientIp(), which only honoursX-Forwarded-Forwhen the request comes from a trusted proxy. Iftrusted_proxiesis misconfigured (or empty) behind a load balancer / CDN, two failure modes appear:
- every audit row records the proxy's IP instead of the real client — actor attribution becomes useless for forensics;
- if you do trust
X-Forwarded-Forwithout restricting upstream, any external caller can spoof the header (X-Forwarded-For: 1.2.3.4) and poison the audit log with attacker-controlled IPs.Configure
framework.trusted_proxiesto the exact CIDR of your edge layer (see Symfony docs), or overrideAuditUserResolverInterfaceto source the IP from a channel you control.
Anonymising actor PII (IP / identifier) — GDPR
The bundle is intentionally un-opinionated about anonymisation: it records the
actor as resolved, and lets you apply your own policy. All actor PII
(ipAddress, userIdentifier, userAgent) flows through
AuditUserResolverInterface before the entry is persisted, so the cleanest
approach is to decorate the default resolver and rewrite only what you need.
AuditActor exposes immutable withIpAddress(), withUserIdentifier() and
withUserAgent() copy helpers for exactly this:
This keeps anonymisation, salting and retention decisions in your compliance scope — the bundle only ships the primitives.
Labelling CLI / messenger actors
Inject AuditContextHolder and set an explicit actor; it takes precedence over
automatic resolution and should be reset when done:
Cryptographic seal (HMAC)
For tamper evidence — detecting that a row's content was rewritten or its timestamp backdated, even by someone who bypassed the append-only DB grants — enable the optional per-row HMAC seal:
The secret must be at least 32 characters — the provider throws on shorter
values. Generate one with openssl rand -hex 32 (64 hex chars, 256 bits of
entropy).
Every audit row is then sealed with HMAC-SHA256(secret, canonical_payload) in a
nullable signature column. Verify the whole table at any time:
Run it from CI, a cron, or after restoring a backup. Because the secret lives
outside the database, an attacker who can only write to the audit table cannot
forge a valid signature. Each tampered entry is also logged at error level via
the PSR logger, so a SIEM can pick it up without re-running the command.
Unsigned rows fail the verification by default. An attacker who cannot forge
a signature can still strip one (UPDATE ... SET signature = NULL) and let a
falsified row masquerade as a row written before the seal was enabled. To close
that hole, audit:verify treats every NULL signature as a failure unless you
pass --allow-unsigned. Use the flag only as a transition measure, and get rid
of unsigned rows for good with the backfill:
The retroactive seal attests each row's state at backfill time — it proves
nothing about tampering that happened before it, so run it as soon as you enable
integrity. Once audit:sign-backfill reports nothing left to sign, make the
column NOT NULL so a stripped signature becomes impossible at the SQL level:
If you applied the append-only trigger from
docs/hardening.sql, the backfill needs a temporary role
with UPDATE (signature) — a ready-to-use recipe is included in that file.
Plug a KMS/Vault-backed secret by implementing SignatureProviderInterface
and pointing the config at it:
Scope. The seal is computed per row: it proves a row was not altered, but on its own it does not detect the deletion of a whole row (there is no chaining — a deliberate choice to avoid serialising every audit write). Pair it with the append-only DB grants in
docs/hardening.sql, which prevent deletion at the source. Rows written before enabling the seal report as unsigned (distinct from tampered) and failaudit:verifyunless--allow-unsignedis passed — seal them withaudit:sign-backfill.
Quality & tests
The bundle ships with a full quality pipeline: PHPUnit (unit + integration + functional), PHPStan level 8 and PHP-CS-Fixer.
Run a single test file or method:
Integration tests use in-memory SQLite — no Docker or database server required.
Contributing
Contributions are welcome. Please read CONTRIBUTING.md before
opening a pull request, and make sure composer ci is green locally.
License
This bundle is released under the MIT License.
This README was generated with the help of Claude.
All versions of doctrine-audit-trail-bundle with dependencies
doctrine/doctrine-bundle Version ^2.10|^3.0
doctrine/orm Version ^2.14|^3.0
symfony/config Version ^6.4|^7.0|^8.0
symfony/console Version ^6.4|^7.0|^8.0
symfony/dependency-injection Version ^6.4|^7.0|^8.0
symfony/http-kernel Version ^6.4|^7.0|^8.0