metadev/doctrine-audit-trail-bundle
Composer 安装命令:
composer require 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.
关键字:
README 文档
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: diffs are computed in
onFlush, written inpostFlushthrough a dedicated entity manager so the audited unit of work is never touched and the listener never re-enters itself. - 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
- 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:
php bin/console doctrine:schema:update --em=audit --force # demo # in production: generate a dedicated migration instead
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, apiKey, apiToken, accessToken, refreshToken, # secret, token, salt, pin, cvv ignored_fields: # extra fields, MERGED with the blacklist - ssn - iban force_audit_fields: # escape hatch: audit a blacklisted field - refreshToken # e.g. to detect token replay 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
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.
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'); }
Extension points
Custom value formatter
The diff is produced by a chain of ValueFormatterInterface. The built-in
ScalarValueFormatter handles scalars, DateTimeInterface, BackedEnum and
Stringable. Anything else falls through unchanged — so association values
are best handled with a custom formatter that extracts an identifier. Tag with
a higher priority than the built-in formatter (which runs last):
use Metadev\DoctrineAuditTrailBundle\Diff\Formatter\ValueFormatterInterface; // Auto-tagged via the interface; priority 0 runs before the built-in (-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(); } }
Custom actor resolver
Implement AuditUserResolverInterface and point the config at it:
doctrine_audit_trail: actor: user_resolver: App\Audit\MyResolver
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
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
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.
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.
统计信息
- 总下载量: 0
- 月度下载量: 0
- 日度下载量: 0
- 收藏数: 1
- 点击次数: 4
- 依赖项目数: 0
- 推荐数: 0
其他信息
- 授权协议: MIT
- 更新时间: 2026-06-12