adt / doctrine-loggable
Logging of changes in Doctrine entities
Requires
- php: ^8.0|^8.1|^8.2
- doctrine/common: ^3.5
- doctrine/orm: ^3.0
- nette/di: ^3.0
- nette/security: ^3.2
- nette/utils: ^2.5|^3.0|^4.0
Requires (Dev)
- phpunit/phpunit: ^11.5 | ^12.0 | ^13.0
- symfony/cache: ^6.0 | ^7.0
- symfony/console: ^6.0 | ^7.0
- symfony/var-exporter: ^6.4 | ^7.0
Suggests
- symfony/console: To run doctrine-loggable:convert-legacy-change-sets, the migration of change sets stored by version 3
Provides
None
Conflicts
None
Replaces
None
- dev-master
- v4.2
- v4.1
- v4.0.1
- v4.0
- v3.0.5
- v3.0.4
- v3.0.3
- v3.0.2
- v3.0.1
- v3.0
- v2.0.6
- v2.0.5
- v2.0.4
- v2.0.3
- v2.0.2
- v2.0.1
- v2.0
- v1.6.2
- v1.6.1
- v1.6.0
- v1.5.6
- v1.5.5
- v1.5.4
- v1.5.3
- v1.5.2
- v1.5.1
- v1.5
- v1.4.1
- v1.4
- v1.3.2
- v1.3.1
- v1.3
- v1.2.3
- v1.2.2
- v1.2.1
- v1.2
- v1.1.1
- v1.1
- v1.0.1
- v1.0
- 0.2.5
- 0.2.4
- 0.2.3
- 0.2.2
- 0.2.1
- dev-feature/json-change-sets
- dev-f-postgresql
- dev-old-php-version
- dev-test
This package is auto-updated.
Last update: 2026-09-19 08:19:09 UTC
README
Logging of changes in Doctrine entities. Every change is stored as one change_log row with the
change set serialized to JSON, so the log is readable straight from the database.
Installation
-
Install via composer:
composer require adt/doctrine-loggable
-
Register this extension in your config.neon:
extensions: - ADT\DoctrineLoggable\DI\LoggableExtension
-
Do database migrations
-
Add attributes to entities you wish to log
<?php use Doctrine\ORM\Mapping as ORM; use ADT\DoctrineLoggable\Attributes as ADA; #[ORM\Entity] #[ADA\LoggableEntity] class User { #[ORM\Column(nullable: true)] #[ADA\LoggableProperty] protected ?string $firstname = null; #[ORM\ManyToMany(targetEntity: Role::class, inversedBy: 'users')] #[ADA\LoggableProperty] protected Collection $roles; } #[ORM\Entity] #[ADA\LoggableIdentification(fields: ['name'])] class Role { #[ORM\Column] protected string $name; #[ORM\ManyToMany(targetEntity: User::class, mappedBy: 'roles')] protected Collection $users; }
Logging a change without its value
Some values do not belong in a log even as history - a password hash is the usual one. Dropping
the #[LoggableProperty] attribute is not the answer: the change would then not be logged at all
and the history would be missing the fact that the password ever changed. Keep the attribute and
turn the value off:
#[ORM\Column(nullable: true)] #[ADA\LoggableProperty(withValue: false)] protected ?string $password = null;
The change set then holds a Redacted node carrying nothing but the property name, so the log
says password changed and no more. Nothing is written anywhere, so the old value cannot be
recovered from the log even by someone holding a database dump.
Works for columns and owning-side toOne associations, that is wherever Doctrine reports the
change in the entity change set. On collections the flag is ignored.
Stored format
The change_set column is a JSON column handled by the change_set DBAL type, which the extension
registers for you. ChangeLog::getChangeSet() returns the usual ChangeSet object graph.
{
"version": 1,
"action": "edit",
"entity": {
"class": "App\\Model\\Entities\\Product",
"id": "42",
"identification": { "name": "Pivo 12°" }
},
"properties": {
"name": { "type": "scalar", "old": "Pivo", "new": "Pivo 12°" },
"price": { "type": "scalar", "old": 39.0, "new": 45.0 },
"validFrom": {
"type": "scalar",
"old": null,
"new": { "@type": "datetime", "class": "DateTimeImmutable", "value": "2026-08-27T12:00:00+02:00" }
},
"category": {
"type": "toOne",
"old": { "class": "App\\Model\\Entities\\Category", "id": "3", "identification": { "name": "Nápoje" } },
"new": { "class": "App\\Model\\Entities\\Category", "id": "7", "identification": { "name": "Pivo" } },
"changeSet": null
},
"tags": {
"type": "toMany",
"added": [ { "class": "App\\Model\\Entities\\Tag", "id": "9", "identification": { "name": "akce" } } ],
"removed": [],
"changeSets": []
}
}
}
A value position holds either a plain JSON scalar or an envelope object with the @type key.
Arrays never appear raw in a value position, so @type is always an unambiguous marker.
Built-in envelope types are datetime, enum, array, binary, float (for NAN and INF)
and object as the last resort fallback.
The property type is a scalar/toOne/toMany/redacted discriminator rather than a class name, so the
classes of this library can be moved or renamed without breaking existing logs.
The change set graph may contain cycles. A change set referenced more than once gets a $id and
every further occurrence is written as {"$ref": id}. Change sets referenced just once, which is
almost always the case, carry no ids at all.
Reacting to logged changes
LoggableListener::$onLogEntry announces every stored change log row, so a change can be turned
into something else - an audit trail record, a notification, a search index update. The library
announces everything and filters nothing: which change matters is the consumer's business.
doctrineLoggable: onLogEntry: - [@App\Log\ChangeAuditSubscriber, logEntry]
class ChangeAuditSubscriber { public function logEntry(ChangeLog $logEntry, object $entity, bool $announced): void { if (!$entity instanceof User) { return; } $this->audit->record( $logEntry->getObjectClass(), $logEntry->getObjectId(), $logEntry->getChangeSet(), ); } }
The callback runs in postFlush, that is after the commit: what gets announced is in the database
and $logEntry->getId() is already assigned.
One entity has one change log row per request and every further flush that touches it grows
that row. Such a row is announced again with $announced set to TRUE, and its change set is
cumulative - it holds what the previous announcement carried as well. A consumer writing an
append-only trail should therefore key on $logEntry->getId(): either skip the repeats, or write
a record that supersedes the previous one. Calling $em->clear() starts over - entities logged
afterwards get fresh rows announced with $announced set to FALSE.
Flushing from inside the callback is safe; the nested postFlush announces nothing twice.
Custom value types
Values coming from custom Doctrine types (money, uuid, embeddables) end up in the object envelope,
which keeps their readable representation but does not restore the original object. Register a
handler to make the round trip lossless:
use ADT\DoctrineLoggable\Serializer\ValueHandler; use ADT\DoctrineLoggable\Serializer\ValueSerializer; class MoneyHandler implements ValueHandler { public function getType(): string { return 'money'; } public function supports(mixed $value): bool { return $value instanceof Money; } public function encode(mixed $value, ValueSerializer $serializer): array { return ['amount' => $value->getAmount(), 'currency' => $value->getCurrency()]; } public function decode(array $data, ValueSerializer $serializer): Money { return new Money($data['amount'], $data['currency']); } }
doctrineLoggable: valueHandlers: - App\Log\MoneyHandler
Handlers registered this way are tried before the built-in ones, so they can override them.
Upgrading from 3.x
The change_set column changes from LONGBLOB holding a PHP serialize() payload to a JSON
column. Convert the existing rows first, while the column is still a BLOB:
php bin/console doctrine-loggable:convert-legacy-change-sets --dry-run php bin/console doctrine-loggable:convert-legacy-change-sets
The command reads and rewrites the rows with plain SQL, so the change_set DBAL type never sees a
legacy payload. It commits per batch (--batch-size, 500 by default), recognises rows that already
hold JSON and leaves them alone, so it is safe to run again. A row that fails to convert is
reported and left untouched, and the command exits with a failure so you do not change the column
type on top of data that did not make it.
Only when it reports no failures:
ALTER TABLE change_log MODIFY change_set JSON NOT NULL;
symfony/console is optional. Without it the command is not registered and you can drive
ADT\DoctrineLoggable\Service\LegacyChangeSetConverter yourself.
Adt\DoctrineLoggable\ChangeSet\* is now declared as ADT\DoctrineLoggable\ChangeSet\*, which is
what the PSR-4 prefix always said. The old casing keeps autoloading, PHP treats both as the same
class.
Tests
composer install vendor/bin/phpunit