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.
Package info
github.com/bastienPetit7/doctrine-audit-trail-bundle
Type:symfony-bundle
pkg:composer/metadev/doctrine-audit-trail-bundle
Requires
- php: >=8.2
- doctrine/doctrine-bundle: ^2.10|^3.0
- doctrine/orm: ^2.14|^3.0
- symfony/config: ^6.4|^7.0|^8.0
- symfony/console: ^6.4|^7.0|^8.0
- symfony/dependency-injection: ^6.4|^7.0|^8.0
- symfony/http-kernel: ^6.4|^7.0|^8.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.50
- matthiasnoback/symfony-dependency-injection-test: ^6.3
- nyholm/symfony-bundle-test: ^3.0
- phpstan/phpstan: ^2.0
- phpstan/phpstan-doctrine: ^2.0
- phpstan/phpstan-phpunit: ^2.0
- phpstan/phpstan-symfony: ^2.0
- phpunit/phpunit: ^11.0
- symfony/framework-bundle: ^6.4|^7.0|^8.0
- symfony/http-foundation: ^6.4|^7.0|^8.0
- symfony/messenger: ^6.4|^7.0|^8.0
- symfony/security-core: ^6.4|^7.0|^8.0
Suggests
- symfony/http-foundation: To capture the client IP address and user agent
- symfony/maker-bundle: To generate the audit table migration with `make:migration --em=audit`
- symfony/messenger: To offload audit persistence with persistence.mode: async
- symfony/security-core: To attribute audit entries to the authenticated user
README
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
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
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 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
# 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_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
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])
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_fieldsmatch against each segment, so a sub-field literally namedsecret,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
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 |
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
use Metadev\DoctrineAuditTrailBundle\Repository\AuditTrailEntryRepository; public function history(AuditTrailEntryRepository $repository): void { $entries = $repository->findByEntity(Post::class, $postId); $byUser = $repository->findByActor('jane_admin'); }
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
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:
# 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:
| 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:
{
"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:
| 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:doctrine_audit_trail: diff: track_collections: trueThe 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:
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
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 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:
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
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)
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:
# 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 if tampered php bin/console audit:verify --format=json # SOC/SIEM-friendly output: {status,total,signed,unsigned,tampered[]} php bin/console audit:verify --fail-fast # stop at the first tampered entry (useful in monitoring cron)
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.
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, which prevent deletion at the source. Existing rows written before enabling the seal verify as unsigned, not tampered.
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
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.