precision-soft / symfony-doctrine-audit
doctrine audit library
Package info
github.com/precision-soft/symfony-doctrine-audit
Type:symfony-bundle
pkg:composer/precision-soft/symfony-doctrine-audit
Requires
- php: >=8.2
- doctrine/dbal: ^4.2
- doctrine/orm: 3.*
- doctrine/persistence: 3.*
- precision-soft/doctrine-type: ^3.0
- precision-soft/symfony-console: ^4.0
- symfony/config: ^7.0 || ^8.0
- symfony/console: ^7.0 || ^8.0
- symfony/dependency-injection: ^7.0 || ^8.0
- symfony/filesystem: ^7.0 || ^8.0
- symfony/http-kernel: ^7.0 || ^8.0
- symfony/serializer: ^7.0 || ^8.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.0
- phpstan/phpstan: ^2.0
- phpstan/phpstan-mockery: ^2.0
- phpunit/phpunit: ^11.5
- precision-soft/symfony-phpunit: ^3.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-04 07:00:21 UTC
README
You may fork and modify it as you wish.
Any suggestions are welcomed.
Requirements
- PHP >= 8.2
- Symfony 7.* or 8.* -- a Symfony 8 set needs PHP >= 8.4, which is what
symfony/config8 requires; below that composer resolves 7.x - Doctrine ORM 3.*
- Doctrine DBAL 4.2 or newer --
Types::ENUMandColumn::getValues(), which the audit schema needs to keep an enum column stable, arrived in 4.2
Installation
composer require precision-soft/symfony-doctrine-audit
Register the bundle in config/bundles.php (if not auto-discovered):
return [ PrecisionSoft\Doctrine\Audit\PrecisionSoftDoctrineAuditBundle::class => ['all' => true], ];
How it works
The library hooks into Doctrine's onFlush and postFlush events to capture entity changes automatically:
- Entity detection -- Mark entities for auditing with the
#[Auditable]PHP attribute. Individual fields can be excluded with#[Ignore]. Both attributes are inherited: they may be declared on a parent entity or on a mapped superclass, and the nearest declaration wins, so#[Auditable(false)]on a child opts it out of a parent's#[Auditable]. - Change capture -- During Doctrine's flush cycle, the auditor inspects the Unit of Work to collect inserts, updates, and deletes. For updates, the full change set (old and new values) is recorded.
- Storage -- Captured changes are wrapped in a
StorageDtoand dispatched to one or more storage backends (Doctrine tables, JSONL files, or a custom service). Storages can be synchronous or asynchronous. - Transaction grouping -- All changes within a single flush are grouped under one transaction record that includes the username (provided by a
TransactionProviderInterfaceimplementation) and a timestamp.
Owning ManyToMany changes are grouped by association and contain owner_class, owner_identifier, field, target_class, and deterministically ordered added and removed identifier lists. Doctrine storage writes that list to the transaction table's collection_changes JSON column; JSONL storage writes the same list under collections. Only identifiers are read from the mapped objects, so capturing a change does not serialize object graphs or initialize unrelated associations, and the ordering is stable across runs, which makes two audit trails comparable.
Limitations
Auditing is driven by Doctrine ORM flush events, so a few categories of changes are intentionally not captured:
- Inverse-side association changes -- Owning
ManyToManycollections are audited with deterministic owner and target identifiers under the transaction'scollection_changespayload, including the rows aCollection::clear()or the removal of the owner takes with it. Inverse-side andOneToManycollections are not recorded independently because Doctrine persists their relationship through the owning side. - Bulk DQL / DBAL operations --
UPDATE/DELETEissued via DQL or raw DBAL bypass the Unit of Work and therefore dispatch no flush events, so they produce no audit rows. Mutate entities through the ORM (persist/remove + flush) when an audit trail is required. - File storage locking --
FileStoragetakes an exclusive advisory lock while writing each complete JSONL record; a write that fails after emitting bytes is truncated back under the same lock, so the file never keeps a half-written record. The lock coordinates writers that use this storage on the same filesystem; network filesystems must provide workingflock()semantics.
A few further constraints are not limitations of the flush cycle but properties of how the audit schema is laid out:
- The audit database must not be the source database. Audit tables carry the same names as the entity tables they mirror, so pointing a doctrine storage's
entity_managerat the audited connection makesschema:createreplace your application's tables. Always give the audit storage its own database. - A collection's identifiers must be renderable as scalars. Owner and target identifiers are recorded as scalars: backed enums are reduced to their value, dates to ATOM, and anything
Stringable-- everyUuid,Ulidor uid value object -- to its string form. An entity whose primary key contains an association still cannot take part in an audited collection, because no string form of it would be stable; the flush is rejected withidentifier field ... is not scalar, and the refusal is raised inonFlush, before the transaction opens, whenever the identifier is already readable there. Audit the join entity of the association instead. - Many-to-many join tables are not mirrored. A join table has no transaction id column, so it is not an audit table and is dropped from the generated audit schema; the collection's history lives in the transaction's
collection_changespayload instead. - Every audited entity needs a primary key, and its identifier fields cannot be listed in
ignored_fieldsor marked#[Ignore]-- the audit table's primary key is the entity's key plus the transaction id. transaction_id_column_typemust be an integer type (integer,bigint,smallint). The transaction table'sidis an autoincrement column read back throughlastInsertId(); anything else is rejected when the container is built.
Configuration reference
precision_soft_doctrine_audit: storages: # Doctrine storage -- writes audit rows into a dedicated database <name>: type: doctrine # required entity_manager: <em_name> # required -- the entity manager for the audit database connection: <connection_name> # optional -- defaults to the entity manager's connection logger: <logger_service_id> # optional config: transaction_table_name: 'audit_transaction' # optional transaction_id_column_name: 'audit_transaction_id' # optional transaction_id_column_type: 'integer' # optional -- integer, bigint or smallint operation_column_name: 'audit_operation' # optional collection_changes_column_name: 'collection_changes' # optional # File storage -- appends JSONL entries to a file <name>: type: file # required file: '%kernel.project_dir%/var/audit.log' # required # Custom storage -- delegates to your own StorageInterface implementation <name>: type: custom # required service: App\Service\MyStorage # required -- must implement StorageInterface auditors: <name>: entity_manager: default # the source entity manager to audit (default: 'default') connection: <connection_name> # optional -- defaults to the entity manager name storages: # required -- list of storage names from above - <storage_name> synchronous_storages: # optional -- subset of storages executed synchronously (defaults to all) - <storage_name> transaction_provider: App\Service\TransactionProvider # required -- must implement TransactionProviderInterface logger: <logger_service_id> # optional ignored_fields: # optional -- field names to globally ignore - created - modified
Performance notes
- The auditor reads entity metadata on first flush and caches it for subsequent flushes within the same request.
- Each audited flush triggers one INSERT per transaction plus one INSERT per changed entity per storage. For high-throughput systems, consider using asynchronous storages (e.g., a RabbitMQ-backed custom storage) so that only the message publish happens synchronously.
- The
ignored_fieldsoption (both global and per-entity via#[Ignore]) reduces the number of columns tracked and therefore the volume of audit data written. - File storage appends JSONL lines and does not open a database connection, making it the lightest option for development or low-volume environments. Each append takes an exclusive
flock()for the duration of one record, so many concurrent writers serialize on it. - Owning
ManyToManycollection changes cost one identifier read per added or removed target, plus one JSON column on the transaction row. Reading identifiers does not initialize the target entities, so the cost scales with the size of the change, not with the size of the collection. - Removing an audited owner, or clearing one of its owning collections, costs one
SELECTper collection duringonFlush. Doctrine schedules neither case in a way that keeps the set it is about to delete --Collection::clear()takes its snapshot after emptying itself, and removing the owner deletes the join rows straight from the persister -- so the rows are read from the database while they are still there. Nothing is read for an entity without owning collections. - Reading through
FileAuditReaderscans the file: a filter is applied per record and a cursor skips lines without decoding them, but there is no index. It suits operational lookups and retention, not reporting over a large history - use a doctrine storage and query the audit tables for that.
Usage
Sample config and storage
precision_soft_doctrine_audit: storages: doctrine_one: type: doctrine entity_manager: audit_em_one config: # \PrecisionSoft\Doctrine\Audit\Storage\Doctrine\Configuration transaction_table_name: 'audit_transaction' file: type: file file: '%kernel.project_dir%/var/audit.log' doctrine_two: type: doctrine entity_manager: audit_em_two config: # \PrecisionSoft\Doctrine\Audit\Storage\Doctrine\Configuration transaction_table_name: 'audit_transaction' rabbit: type: custom service: Acme\Shared\Service\AuditStorageService auditors: doctrine: entity_manager: source_em_one storages: - doctrine transaction_provider: Acme\Shared\Service\AuditTransactionProviderService logger: monolog.logger ignored_fields: - created - modified file: entity_manager: source_em_two storages: - file transaction_provider: Acme\Shared\Service\AuditTransactionProviderService async: entity_manager: source_em_three storages: - doctrine_two - rabbit synchronous_storages: - rabbit # the rabbit storage will publish the storage dto and a consumer will be required to save to the doctrine storage transaction_provider: Acme\Shared\Service\AuditTransactionProviderService
services: Acme\Shared\Service\AuditStorageService: arguments: $storage: '@precision_soft_doctrine_audit.storage.doctrine_two'
<?php declare(strict_types=1); namespace Acme\Shared\Service; use PrecisionSoft\Doctrine\Audit\Contract\TransactionProviderInterface; use PrecisionSoft\Doctrine\Audit\Dto\Storage\TransactionDto; final class AuditTransactionProviderService implements TransactionProviderInterface { public function getTransaction(): TransactionDto { $username = '~'; return new TransactionDto($username); } }
<?php declare(strict_types=1); namespace Acme\Shared\Service; use PrecisionSoft\Doctrine\Audit\Contract\StorageInterface; use PrecisionSoft\Doctrine\Audit\Dto\Storage\StorageDto; use PrecisionSoft\Doctrine\Audit\Storage\Doctrine\Storage; use OldSound\RabbitMqBundle\RabbitMq\ProducerInterface; use PhpAmqpLib\Message\AMQPMessage; use Psr\Log\LoggerInterface; use Symfony\Component\Serializer\Encoder\JsonEncoder; use Symfony\Component\Serializer\SerializerInterface; use Throwable; class AuditStorageService implements StorageInterface { private SerializerInterface $serializerInterface; private Storage $storage; private ProducerInterface $producerInterface; private LoggerInterface $loggerInterface; private ThrowableHandlerService $throwableHandlerService; public function __construct( SerializerInterface $serializerInterface, Storage $storage, ProducerInterface $producerInterface, LoggerInterface $loggerInterface, ThrowableHandlerService $throwableHandlerService ) { $this->serializerInterface = $serializerInterface; $this->storage = $storage; $this->producerInterface = $producerInterface; $this->loggerInterface = $loggerInterface; $this->throwableHandlerService = $throwableHandlerService; } public function save(StorageDto $storageDto): void { try { $serializedMessage = $this->serializerInterface->serialize($storageDto, JsonEncoder::FORMAT); $this->producerInterface->publish($serializedMessage); } catch (Throwable $throwable) { $context = $this->throwableHandlerService->getContext($throwable); $context['dto'] = $serializedMessage ?? 'could not serialize'; $this->loggerInterface->error($throwable->getMessage(), $context); } } public function consume(AMQPMessage $amqpMessage): void { /** @var StorageDto $storageDto */ $storageDto = $this->serializerInterface->deserialize($amqpMessage->getBody(), StorageDto::class, JsonEncoder::FORMAT); $this->storage->save($storageDto); } }
Doctrine storage
This library registers two commands for each pair of auditor and doctrine storage, so an auditor writing to several audit databases gets a create/update pair per database:
precision-soft:doctrine:audit:schema:create:<auditor-name>:<storage-name>- creates the audit database schema.precision-soft:doctrine:audit:schema:update:<auditor-name>:<storage-name>- updates the audit database schema.
Running schema:update immediately after schema:create emits no statements, so the commands are safe to run from a deployment pipeline.
File storage: reading and retention
A JSONL record keys a value by the entity's field name where the audit table uses its column name, and its
date is written in ATOM with the offset, so a reader or a purge running in another timezone means the same instant.
Every file storage also registers a reader on the same path, so nothing has to be configured twice:
precision_soft_doctrine_audit.storage.<storage-name>.reader-- aFileAuditReader, which implements bothAuditReaderInterfaceandAuditPurgerInterface.
With exactly one file storage configured, both contracts are aliased onto that reader and autowire:
use PrecisionSoft\Doctrine\Audit\Contract\AuditReaderInterface; use PrecisionSoft\Doctrine\Audit\Dto\Query\AuditQuery; use PrecisionSoft\Doctrine\Audit\Dto\Operation; public function __construct(private readonly AuditReaderInterface $auditReader) {} $page = $this->auditReader->read(new AuditQuery( entityClass: Order::class, identity: ['id' => 42], operation: Operation::Update, limit: 50, )); foreach ($page->getTransactions() as $transaction) { /* ... */ } $nextCursor = $page->getNextCursor();
A query reaches collection changes as well as entity rows, so the same call finds a flush that only added or removed a related entity:
$page = $this->auditReader->read(new AuditQuery(entityClass: Tag::class, identity: ['id' => 9]));
With several file storages the alias would be ambiguous, so none is registered and the per-storage service id is the only way in.
Retention goes through PurgeRequest, which is dry-run by default:
use PrecisionSoft\Doctrine\Audit\Contract\AuditPurgerInterface; use PrecisionSoft\Doctrine\Audit\Dto\Query\PurgeRequest; $result = $auditPurger->purge(new PurgeRequest(new DateTimeImmutable('-1 year'), 500, false)); $result->getMatchedTransactions(); $result->getPurgedTransactions(); $result->hasMore();
A purge removes whole transactions only, never individual entity rows, and never more than batchSize per call.
hasMore() reports whether records older than before remain beyond this batch, so looping until it is false drains the backlog in bounded steps.
The same two operations are available from the console, one pair per auditor and file storage:
precision-soft:doctrine:audit:read:<auditor-name>:<storage-name>----entity-class,--identity field=value(repeatable),--from,--until,--username,--operation,--limit,--cursor.precision-soft:doctrine:audit:purge:<auditor-name>:<storage-name>----before(mandatory),--batch-size, and--forceto purge instead of reporting.
The console only ever hands over strings while a JSONL record keeps the column's JSON type, and the two are compared strictly, so --identity casts what looks like a number or a keyword: id=42 is the integer 42, paid=true the boolean, note=null the null. Wrap the value in double quotes to keep it a string, which is what a code, a reference or a zero-padded number needs:
bin/console precision-soft:doctrine:audit:read:catalogue:jsonl --identity='code="007"'
Without the quotes code=007 is the integer 7 and matches nothing, silently. A future --before is accepted on purpose -- it is the only way to empty a trail -- and the dry run the command defaults to reports the count first.
Three properties of this reader are worth knowing before you build on it:
- The contract is satisfied by the JSONL storage only, and its payload is
@experimental.AuditPage::getTransactions()returns the decoded JSONL records as they are on disk. There is no doctrine-backed reader yet, and the shape becomes a dedicated DTO once there is one -- the method signatures are stable, but what they carry is not covered by the backward compatibility promise until then. - A cursor is a line offset, opaque but not stable: a purge between two pages shifts the lines, so a cursor held across a purge resumes at the wrong place. Page through in one pass, or re-query from the start. A cursor that does not decode back to exactly the number it carries is refused rather than treated as end-of-data.
identitymatches the value a transaction left behind, never the one it replaced: a column stored as anold/newpair is compared on itsnewhalf, because an audit lookup asks which transaction produced a given state.- A collection change matches on either side of the association.
entityClassmatches itsowner_classor itstarget_class, andidentitymatches the owner's identifier or one of the added or removed target identifiers.operationis entity-only -- a collection change carries none, so settingoperationnarrows the query to entity rows and excludes collection changes.
Purge rewrites the audit file, so it is a maintenance operation: run it when audited flushes are not in flight. An interrupted purge leaves the records it meant to keep next to the audit file as <file>.purge and refuses to run again until that file is dealt with, rather than leaving a truncated audit trail.
Upgrading
v3.x → v4.0
Run the audit schema update command. The transaction table gains a nullable collection_changes JSON column:
bin/console precision-soft:doctrine:audit:schema:update:<auditor-name>:<storage-name> --force
Storage::getTransactionId() takes the whole StorageDto. The collection payload belongs on the transaction row, so subclasses overriding it must widen the parameter:
protected function getTransactionId(StorageDto $storageDto): int
v2.x → v3.0
getOperation() returns Operation enum instead of string
Before:
$entity->getOperation() === 'delete'
After:
use PrecisionSoft\Doctrine\Audit\Dto\Operation; $entity->getOperation() === Operation::Delete $entity->getOperation()->value === 'delete'
OPERATION_* constants removed from AbstractEntityDto
Replace any references to AbstractEntityDto::OPERATION_DELETE / OPERATION_INSERT / OPERATION_UPDATE / OPERATIONS
with Operation::Delete / Insert / Update and Operation::values().
FileStorage JSONL format changed
- Each entity now includes an
operationfield. - UPDATE fields that have changed are serialized as
{"old": ..., "new": ...}instead of a plain value.
Exception context
Every exception in this package carries a structured context array next to its message, so the facts describing a failure do not have to be parsed back out of a string:
try { // ... } catch (Exception $exception) { $logger->error($exception->getMessage(), $exception->getContext()); }
getContext() returns [] when nothing was attached. setContext() replaces it and returns the exception, and the constructor accepts it as an optional fourth argument. Values are expected to be scalars, so the array stays serialisable by a logger.
The context is purely additive: no message, code or previous throwable changed when it was introduced, so code that logs only getMessage() behaves exactly as before.
What this bundle attaches:
DoctrineSchemaListenerreportsentityTableName(per-table generation) ortransactionTableName(transaction table generation) when schema generation fails. Both are in the message too, but only as formatted text.StorageFailureExceptionreportsfailedStorages— the class name of every sink that rejected the payload — andstoredPayload.getFailures()returnsThrowables, and a throwable cannot name the storage that raised it, so the context is the only place that mapping exists.
Every exception in the package implements Contract\ExceptionInterface, so a consumer can read the context off any of them without knowing the concrete class. A subclass of your own that already declares a $context property or a
getContext()/setContext() method will collide with Exception\Trait\ExceptionTrait.
Example application
A runnable product nomenclator lives under .example/: categories, products, sales channels and joined-inheritance offers whose every change is written to an audit trail in a second database and to a JSONL file, with the bundle booted by a micro-kernel the way an application boots it -- two connections, two entity managers, one doctrine storage, one file storage, one auditor with a global ignored_fields. Its scenarios cover insert, update and delete with the username and the transaction extras, both ignore mechanisms, an owning ManyToMany published, withdrawn, cleared and carried away by the removal of its owner, joined inheritance with a child that opts out, the parity of the two storages, and all four commands -- schema:create, schema:update, audit:read by identity, operation and cursor, and audit:purge walking one bounded batch per run. It runs on MySQL and MariaDB and installs the bundle from the working tree through a path repository, so it
always tests the code as it stands; run it with .dev/validate/all.sh --example (which starts the databases) or cd .example && composer install && composer check. The directory is export-ignored and never reaches a consumer's vendor/.
Dev
git clone git@github.com:precision-soft/symfony-doctrine-audit.git cd symfony-doctrine-audit ./dc build && ./dc up -d
Run the full gate the way the pre-commit hook runs it - the CI workflow in
.github/workflows/ci.yml calls the same composer scripts, so the two cannot drift:
.dev/validate/all.sh .dev/validate/all.sh --audit # also audits the locked dependencies ( needs the network ) .dev/validate/all.sh --staged # what the pre-commit hook runs: nothing unless the index carries php
Mutation testing is opt-in for the same reason, plus cost - it runs the suite once per mutant:
.dev/validate/all.sh --mutation
Infection is a pinned phar in the image, not a composer dependency, and infection.json5 carries a
minMsi floor equal to the last measured score, so the section fails when a change makes the suite weaker rather than only reporting a number. Raise the floor when the score improves.
The integration suite needs real databases, which are behind a Compose profile so the default up
stays fast and offline:
./dc --profile db up -d .dev/validate/all.sh --integration
Tests connect through DATABASE_URL_MYSQL and DATABASE_URL_MARIADB and skip themselves when those services are not running, so composer check never depends on them.
Build against another PHP version with the PHP_VERSION build argument - each version is tagged as its own image, so switching back and forth costs nothing:
PHP_VERSION=8.4 ./dc build && PHP_VERSION=8.4 ./dc up -d
Coverage is available through pcov, which is installed but disabled by default:
./dc exec dev php -d pcov.enabled=1 vendor/bin/simple-phpunit --coverage-text
After editing a file, ./dc restart dev (a few seconds) is enough to be sure the container is not serving a stale copy - the bind mount can keep the old inode after an atomic rewrite.