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.

FAQ

After the download, you have to make one include require_once('vendor/autoload.php');. After that you have to import the classes with use statements.

Example:
If you use only one package a project is not needed. But if you use more then one package, without a project it is not possible to import the classes with use statements.

In general, it is recommended to use always a project to download your libraries. In an application normally there is more than one library needed.
Some PHP packages are not free to download and because of that hosted in private repositories. In this case some credentials are needed to access such packages. Please use the auth.json textarea to insert credentials, if a package is coming from a private repository. You can look here for more information.

  • Some hosting areas are not accessible by a terminal or SSH. Then it is not possible to use Composer.
  • To use Composer is sometimes complicated. Especially for beginners.
  • Composer needs much resources. Sometimes they are not available on a simple webspace.
  • If you are using private repositories you don't need to share your credentials. You can set up everything on our site and then you provide a simple download link to your team member.
  • Simplify your Composer build process. Use our own command line tool to download the vendor folder as binary. This makes your build process faster and you don't need to expose your credentials for private repositories.
Please rate this library. Is it a good library?

Informations about the package doctrine-audit-trail-bundle

DoctrineAuditTrailBundle

CI Latest Stable Version Total Downloads PHP Version Symfony Version

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).

Table of contents

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 INSERT and SELECT on the audit table. Grant nothing more, and physically reject UPDATE / DELETE / TRUNCATE at 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_manager points at an existing connection. Sections retention, integrity and audit:actor-anonymise are documented in their own sections below.

⚠️ force_audit_fields writes the value IN CLEARTEXT. This option overrides the built-in secret blacklist (password, refreshToken, apiKey, …) and stores the raw field value in the audit diff column 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 ValueFormatterInterface that emits a non-reversible fingerprint (e.g. substr(hash_hmac('sha256', $value, $appSecret), 0, 16)) and tag it with a higher priority than ScalarValueFormatter. 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

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:

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 DELETE privilege on the audit table (recommended for tamper-evidence), run audit:prune from a dedicated role, or wrap the deletion in a Postgres SECURITY DEFINER function 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-anonymise redacts the actor columns only. The diff payload often captures the user's own entity (e.g. a User.email update); 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:

  1. Dedicated role — run audit:actor-anonymise against a second Doctrine connection that uses a audit_anonymiser role granted SELECT, UPDATE on the audit table. The application role stays INSERT, SELECT only.
  2. SECURITY DEFINER function (Postgres) — wrap the UPDATE in a function owned by a privileged role, and adapt the trigger so it accepts that function's current_user.
  3. Session flag (Postgres) — keep the trigger but skip its RAISE when current_setting('audit.allow_anonymise', true) = 'true', then set SET 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 via diff.track_collections: true; the listener then reads UnitOfWork::getScheduledCollectionUpdates() / getScheduledCollectionDeletions() and emits an Update entry on the owner with an added/removed delta:

The recorded shape is {_collection: true, added: [...], removed: [...]} placed under after in 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 via Request::getClientIp(), which only honours X-Forwarded-For when the request comes from a trusted proxy. If trusted_proxies is 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-For without 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_proxies to the exact CIDR of your edge layer (see Symfony docs), or override AuditUserResolverInterface to 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 fail audit:verify unless --allow-unsigned is passed — seal them with audit: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

PHP Build Version
Package Version
Requires php Version >=8.2
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
Composer command for our command line client (download client) This client runs in each environment. You don't need a specific PHP version etc. The first 20 API calls are free. Standard composer command

The package metadev/doctrine-audit-trail-bundle contains the following files

Loading the files please wait ...