PHPackages                             metadev/doctrine-audit-trail-bundle - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Database &amp; ORM](/categories/database)
4. /
5. metadev/doctrine-audit-trail-bundle

ActiveSymfony-bundle[Database &amp; ORM](/categories/database)

metadev/doctrine-audit-trail-bundle
===================================

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.

v0.7.0(3w ago)11↓50%MITPHPPHP &gt;=8.2CI passing

Since Jun 10Pushed 2w agoCompare

[ Source](https://github.com/bastienPetit7/doctrine-audit-trail-bundle)[ Packagist](https://packagist.org/packages/metadev/doctrine-audit-trail-bundle)[ Docs](https://github.com/bastienPetit7/doctrine-audit-trail-bundle)[ RSS](/packages/metadev-doctrine-audit-trail-bundle/feed)WikiDiscussions main Synced 1w ago

READMEChangelogDependencies (36)Versions (8)Used By (0)

DoctrineAuditTrailBundle
========================

[](#doctrineaudittrailbundle)

[![CI](https://github.com/bastienPetit7/doctrine-audit-trail-bundle/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/bastienPetit7/doctrine-audit-trail-bundle/actions/workflows/ci.yml)[![Latest Stable Version](https://camo.githubusercontent.com/f6da39ef27ed70a4668fd48ac564174b642e316e7f99a8590ea769bf240f3cc7/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f6d6574616465762f646f637472696e652d61756469742d747261696c2d62756e646c652e7376673f6c6162656c3d737461626c652663616368655365636f6e64733d333030)](https://packagist.org/packages/metadev/doctrine-audit-trail-bundle)[![Total Downloads](https://camo.githubusercontent.com/429160e72197dd6a4ad8d9f6f4023572a8c5895d3f340485c2bef191a1b16abe/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f64742f6d6574616465762f646f637472696e652d61756469742d747261696c2d62756e646c652e7376673f63616368655365636f6e64733d333030)](https://packagist.org/packages/metadev/doctrine-audit-trail-bundle)[![License](https://camo.githubusercontent.com/2c38540967834c06b993cc0de9305f1ccb41ce2f2bd42967d17836305553ef41/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6c6963656e73652f6261737469656e5065746974372f646f637472696e652d61756469742d747261696c2d62756e646c652e7376673f63616368655365636f6e64733d333030)](LICENSE)[![PHP Version](https://camo.githubusercontent.com/a3b62720e24b68cdfe74b0f2619b0cdb03c527babba0f21b4e884d60744702d7/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f7068702d762f6d6574616465762f646f637472696e652d61756469742d747261696c2d62756e646c652e7376673f6c6f676f3d706870266c6f676f436f6c6f723d77686974652663616368655365636f6e64733d333030)](https://www.php.net/)[![Symfony Version](https://camo.githubusercontent.com/a2a30512153e99bf68cd6931261ce8d5a21a4360e1914890502cce0a0a89736f/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f73796d666f6e792d253545362e34253230253743253743253230253545372e30253230253743253743253230253545382e302d626c61636b3f6c6f676f3d73796d666f6e79)](https://symfony.com/)[![PHPStan Level](https://camo.githubusercontent.com/f60d96f7c2579690ab6dfa8918f777fe93a02a92301c661eb38a85861a92b780/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f5048505374616e2d6c6576656c253230382d627269676874677265656e2e7376673f7374796c653d666c6174)](phpstan.dist.neon)[![Code Style: PHP-CS-Fixer](https://camo.githubusercontent.com/f25cdac0fb5866533cfe61757d5cc133bec6d8dccf9a7e0f9afbe5489ad72461/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f636f64652532307374796c652d5048502d2d43532d2d46697865722d626c75652e737667)](.php-cs-fixer.dist.php)

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 &amp; safe**: raw field values are captured in `onFlush`, then formatted and written in `postFlush` through a **dedicated entity manager**so the audited unit of work is never touched and the listener never re-enters itself. Deferring value formatting to `postFlush` lets 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 global `ignored_fields` list. It provides the primitives to comply — retention, anonymisation and access control remain the integrator's responsibility.

Table of contents
-----------------

[](#table-of-contents)

- [Requirements](#requirements)
- [Installation](#installation)
- [Host wiring](#host-wiring)
- [Configuration](#configuration)
- [Marking entities](#marking-entities)
- [Reading the trail](#reading-the-trail)
- [Retention &amp; pruning](#retention--pruning)
- [GDPR actor anonymisation](#gdpr-actor-anonymisation)
- [Extension points](#extension-points)
- [Quality &amp; tests](#quality--tests)
- [Contributing](#contributing)
- [License](#license)

Requirements
------------

[](#requirements)

ComponentVersionPHP`>= 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
------------

[](#installation)

```
composer require metadev/doctrine-audit-trail-bundle
```

Register the bundle (Symfony Flex does this automatically):

```
// config/bundles.php
return [
    // ...
    Metadev\DoctrineAuditTrailBundle\DoctrineAuditTrailBundle::class => ['all' => true],
];
```

Host wiring
-----------

[](#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.

```
# config/packages/doctrine.yaml
doctrine:
    dbal:
        default_connection: default
        connections:
            default:
                url: '%env(resolve:DATABASE_URL)%'
            audit:
                url: '%env(resolve:AUDIT_DATABASE_URL)%'

    orm:
        default_entity_manager: default
        entity_managers:
            default:
                connection: default
                auto_mapping: false
                mappings:
                    App:
                        type: attribute
                        is_bundle: false
                        dir: '%kernel.project_dir%/src/Entity'
                        prefix: 'App\Entity'
                        alias: App
            audit:
                connection: audit
                # AuditTrailEntry mapping is injected by the bundle.
```

Create the table:

```
# Recommended — generate a migration in your project's namespace:
php bin/console make:migration --em=audit
php bin/console doctrine:migrations:migrate --em=audit

# Quick start for demos / dev:
php bin/console doctrine:schema:update --em=audit --force
```

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`](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 &amp; hardening

[](#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`](docs/hardening.sql). For tamper *evidence* that survives even a privileged DBA or a restored backup, enable the optional [cryptographic HMAC seal](#cryptographic-seal-hmac).

Configuration
-------------

[](#configuration)

```
# config/packages/doctrine_audit_trail.yaml
doctrine_audit_trail:
    enabled: true                       # global kill switch

    storage:
        entity_manager: audit           # dedicated EM name
        table_name: audit_trail

    # A built-in security blacklist is always applied first (secure by default):
    #   password, plainPassword, passwordHash, apiKey, apiToken, accessToken,
    #   refreshToken, secret, token, salt, pin, cvv, iban, bic, pan, mfaSecret, …
    ignored_fields:                     # extra fields, MERGED with the blacklist
        - ssn
        - nationalId

    force_audit_fields:                 # escape hatch: audit a blacklisted field
        - refreshToken                  # ⚠️ stored in CLEARTEXT — see warning below

    diff:
        max_size_bytes: 65536           # cap JSON diff size (0 = disabled)
        delete_snapshot_mode: minimal   # minimal (hash) | full (cleartext fields)
        track_collections: false        # audit OneToMany/ManyToMany changes as added/removed deltas

    actor:
        fallback_label: cli             # label outside an HTTP request
        user_resolver: ~                # custom resolver service id (optional)

    persistence:
        mode: sync                      # sync (default) | async
        soft_fail: false                # catch + log write failures instead of breaking the app
        message_bus: messenger.bus.default   # used in async mode
        batch_size: 100                 # async mode: max entries per Messenger message

    retention:
        default_age: ~                  # cutoff used by audit:prune when --before is omitted
                                        # (e.g. '-10 years', '2020-01-01'); see Retention & pruning

    integrity:                          # see Cryptographic seal (HMAC) for usage
        enabled: false                  # opt-in HMAC tamper-evidence seal
        secret: ~                       # required when enabled without a custom provider (use an env var)
        secret_provider: ~              # custom SignatureProviderInterface service id (KMS/Vault)
```

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

[](#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`:

ModeLatency / large-flush costAudit write failureAtomicity with business data`sync` (default)paid in the requestpropagates (see `soft_fail`)❌ written after the business commit`async`offloaded to Messengerretried 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 includes `dropped_entries` and `total_entries` so operators can quantify the loss without reproducing the failure. In `async` mode, `soft_fail` only 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`** — requires `symfony/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). `createdAt` and 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 of `batch_size` (default `100`) so a bulk flush never produces a single oversized message — useful because AMQP enforces a low `frame_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. Tune `batch_size`to 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 — with `soft_fail: true` — logged as a single error carrying the exact `dropped_entries` count. Messages are stamped with `DispatchAfterCurrentBusStamp`, 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
----------------

[](#marking-entities)

```
use Metadev\DoctrineAuditTrailBundle\Attribute\Auditable;
use Metadev\DoctrineAuditTrailBundle\Attribute\AuditIgnore;

#[Auditable(label: 'Blog post')]
class Post
{
    #[AuditIgnore]                      // never recorded in the diff
    private ?string $internalToken = null;

    // ...
}
```

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]`)

[](#embeddables-ormembedded)

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:

```
{
    "before": {"price.amount": 1000, "price.currency": "EUR"},
    "after":  {"price.amount": 1500, "price.currency": "EUR"}
}
```

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_fields` match against each segment, so a sub-field literally named `secret`, `apiKey`, `token`, etc. is filtered even when the parent embeddable is **not** ignored.

```
#[Auditable]
class Order
{
    #[ORM\Embedded(class: Money::class)]
    public Money $price;                  // recorded as price.amount / price.currency

    #[ORM\Embedded(class: Credentials::class)]
    #[AuditIgnore]
    public Credentials $providerCreds;    // every sub-field hidden

    #[ORM\Embedded(class: Credentials::class, columnPrefix: 'exposed_')]
    public Credentials $exposedCreds;     // .login is recorded; .secret / .apiKey are
                                          // still filtered by the default deny-list
}
```

DELETE snapshot modes
---------------------

[](#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:

ModeConfig value`diff.before` contentUse caseMinimal (default)`minimal``{_snapshot_hash: "…"}`GDPR-friendly, no cleartext duplicationFull`full`All non-blacklisted scalar fields **and** single-valued associations (`ManyToOne` / `OneToOne`) as `{class, id}` referencesForensic / legacy tooling```
doctrine_audit_trail:
    diff:
        delete_snapshot_mode: full   # opt back in to cleartext DELETE snapshots
```

> 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:

```
if ($entry->isMinimalDeleteSnapshot()) {
    $hash = $entry->getSnapshotHash();
} else {
    $title = $entry->getDiff()['before']['title'] ?? null;
}
```

Reading the trail
-----------------

[](#reading-the-trail)

```
use Metadev\DoctrineAuditTrailBundle\Repository\AuditTrailEntryRepository;

public function history(AuditTrailEntryRepository $repository): void
{
    $entries = $repository->findByEntity(Post::class, $postId);
    $byUser  = $repository->findByActor('jane_admin');
}
```

Retention &amp; pruning
-----------------------

[](#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:

```
# Delete every entry older than 7 years
bin/console audit:prune --before="-7 years"

# Preview first
bin/console audit:prune --before="-7 years" --dry-run

# Chunked deletion to keep transactions short on large tables (default 1000)
bin/console audit:prune --before="2020-01-01" --batch=500
```

Configure a default cutoff so the command can be scheduled without arguments:

```
# config/packages/doctrine_audit_trail.yaml
doctrine_audit_trail:
    retention:
        default_age: '-10 years'   # any DateTimeImmutable-parseable spec
```

```
bin/console audit:prune                # uses retention.default_age
```

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-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:

```
# Anonymise every entry attributed to user "jane"
bin/console audit:actor-anonymise --user-identifier="jane" --reason="GDPR-art-17 ticket #4711"

# Preview first
bin/console audit:actor-anonymise --user-identifier="jane" --reason="GDPR-art-17" --dry-run

# Chunked processing on large tables (default 500)
bin/console audit:actor-anonymise --user-identifier="jane" --reason="GDPR-art-17" --batch=200
```

What happens to each matched row:

ColumnAfter anonymisation`userIdentifier``hash('sha256', )` (deterministic, 64-char hex)`userId``hash('sha256', )` or `null` if it was null`ipAddress``NULL``userAgent``NULL``actorLabel``'gdpr-anonymised'``actorAnonymisedAt``now()` (UTC)`signature`**recomputed** so `audit:verify` keeps passingThe 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

[](#append-only-hardening-and-anonymisation)

If you have applied the [`docs/hardening.sql`](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
----------------

[](#extension-points)

### Value formatters

[](#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):

FormatterPriorityHandles`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`)

[](#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:

```
{
  "before": { "category": { "class": "App\\Entity\\Category", "id": 3 } },
  "after":  { "category": { "class": "App\\Entity\\Category", "id": 7 } }
}
```

**Output shape (public API).** Association values in the diff JSON follow this convention — changing it in a future release would be a **breaking change**:

KeyTypeMeaning`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 unsetThis 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:
>
> ```
> doctrine_audit_trail:
>     diff:
>         track_collections: true
> ```
>
>
>
> 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`

[](#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

[](#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:

```
use Metadev\DoctrineAuditTrailBundle\Diff\Formatter\ValueFormatterInterface;

// Auto-tagged via the interface; priority 0 runs before the built-ins (-500 / -1000).
final class MoneyFormatter implements ValueFormatterInterface
{
    public function supports(mixed $value): bool { return $value instanceof Money; }
    public function format(mixed $value): mixed   { return $value->getAmount(); }
}
```

To enrich an association with a display label (only when the data is already in memory — never lazy-load inside a formatter):

```
final class CategoryLabelFormatter implements ValueFormatterInterface
{
    public function supports(mixed $value): bool
    {
        return $value instanceof Category;
    }

    public function format(mixed $value): array
    {
        return [
            'class' => $value::class,
            'id' => $value->getId(),
            'label' => $value->getName(), // must not trigger DB I/O
        ];
    }
}
```

### Custom actor resolver

[](#custom-actor-resolver)

Implement `AuditUserResolverInterface` and point the config at it:

```
doctrine_audit_trail:
    actor:
        user_resolver: App\Audit\MyResolver
```

> **⚠️ 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](https://symfony.com/doc/current/deployment/proxies.html)), or override `AuditUserResolverInterface` to source the IP from a channel you control.

### Anonymising actor PII (IP / identifier) — GDPR

[](#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:

```
use Metadev\DoctrineAuditTrailBundle\User\AuditActor;
use Metadev\DoctrineAuditTrailBundle\User\AuditUserResolverInterface;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;

#[AsDecorator(decorates: AuditUserResolverInterface::class)]
final readonly class GdprAuditUserResolver implements AuditUserResolverInterface
{
    public function __construct(
        #[AutowireDecorated] private AuditUserResolverInterface $inner,
        #[Autowire('%kernel.secret%')] private string $salt,
    ) {
    }

    public function resolve(): AuditActor
    {
        $actor = $this->inner->resolve();

        return $actor
            // CNIL: drop the last octet — 192.168.1.42 → 192.168.1.0
            ->withIpAddress(
                null === $actor->ipAddress
                    ? null
                    : preg_replace('/\.\d+$/', '.0', $actor->ipAddress),
            )
            // Pseudonymise the identifier with a salted hash
            ->withUserIdentifier(
                null === $actor->userIdentifier
                    ? null
                    : hash('sha256', $actor->userIdentifier.$this->salt),
            );
    }
}
```

This keeps anonymisation, salting and retention decisions in *your* compliance scope — the bundle only ships the primitives.

### Labelling CLI / messenger actors

[](#labelling-cli--messenger-actors)

Inject `AuditContextHolder` and set an explicit actor; it takes precedence over automatic resolution and should be reset when done:

```
$this->contextHolder->setActor(new AuditActor(label: 'batch-nightly'));
// ... run the batch ...
$this->contextHolder->reset();
```

### Cryptographic seal (HMAC)

[](#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](#tamper-evidence--hardening) — enable the optional per-row HMAC seal:

```
# config/packages/doctrine_audit_trail.yaml
doctrine_audit_trail:
    integrity:
        enabled: true
        secret: '%env(AUDIT_HMAC_SECRET)%'   # keep it OUT of the audit database
```

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:

```
php bin/console audit:verify                    # exit 0 if intact, non-zero + the offending ids otherwise
php bin/console audit:verify --format=json      # SOC/SIEM-friendly output: {status,total,signed,unsigned,unsigned_ids[],tampered[]}
php bin/console audit:verify --fail-fast        # stop at the first anomaly (useful in monitoring cron)
php bin/console audit:verify --allow-unsigned   # tolerate rows written before integrity was enabled
```

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:

```
php bin/console audit:sign-backfill --dry-run   # count the rows that would be sealed
php bin/console audit:sign-backfill             # seal them (batched, idempotent, resumable)
```

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:

```
ALTER TABLE audit_trail ALTER COLUMN signature SET NOT NULL;
```

If you applied the append-only trigger from [`docs/hardening.sql`](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:

```
doctrine_audit_trail:
    integrity:
        enabled: true
        secret_provider: App\Audit\KmsSignatureProvider
```

> **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`](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 &amp; tests
-------------------

[](#quality--tests)

The bundle ships with a full quality pipeline: PHPUnit (unit + integration + functional), PHPStan level 8 and PHP-CS-Fixer.

```
composer test              # all tests
composer test-unit         # unit tests only
composer test-integration  # integration tests only
composer test-functional   # functional tests only
composer cs-check          # PHP-CS-Fixer dry-run
composer cs-fix            # PHP-CS-Fixer auto-fix
composer phpstan           # PHPStan level 8
composer ci                # cs-check + phpstan + test
```

Run a single test file or method:

```
vendor/bin/phpunit tests/Unit/Diff/ChangeSetExtractorTest.php
vendor/bin/phpunit --filter it_should_record_an_update_diff
```

Integration tests use **in-memory SQLite** — no Docker or database server required.

Contributing
------------

[](#contributing)

Contributions are welcome. Please read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request, and make sure `composer ci` is green locally.

License
-------

[](#license)

This bundle is released under the [MIT License](LICENSE).

---

This README was generated with the help of [Claude](https://claude.com/claude-code).

###  Health Score

38

—

LowBetter than 83% of packages

Maintenance95

Actively maintained with recent releases

Popularity3

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity41

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~3 days

Total

7

Last Release

27d ago

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/7236112?v=4)[Harald De Bondt](/maintainers/metadev)[@MetaDev](https://github.com/MetaDev)

---

Top Contributors

[![bastienPetit7](https://avatars.githubusercontent.com/u/78349577?v=4)](https://github.com/bastienPetit7 "bastienPetit7 (52 commits)")

---

Tags

symfonyloggingdoctrineAudithistorySymfony Bundleaudit-trailgdpraudit-log

###  Code Quality

TestsPHPUnit

Static AnalysisPHPStan

Code StylePHP CS Fixer

Type Coverage Yes

### Embed Badge

![Health badge](/badges/metadev-doctrine-audit-trail-bundle/health.svg)

```
[![Health](https://phpackages.com/badges/metadev-doctrine-audit-trail-bundle/health.svg)](https://phpackages.com/packages/metadev-doctrine-audit-trail-bundle)
```

###  Alternatives

[easycorp/easyadmin-bundle

Admin generator for Symfony applications

4.3k17.9M401](/packages/easycorp-easyadmin-bundle)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
