manasbala/doctrine-log-bundle
A Symfony bundle for audit logging Doctrine entity changes. It captures create, update, and delete events and stores a change history for traceability, integrating through Doctrine event listeners with configurable logging options.
This is a Symfony bundle wrapping Doctrine ORM, not an Eloquent package — if you're coming from Laravel, the mental model is "model observer / auditable trait," but it only works inside a Symfony kernel with Doctrine's event system. Plan for that before you install it.
Release 1.0.7 added support for JMS Serializer 4, which confirms what the earlier source already implied: the changed-field payload is serialized through jms/serializer, not json_encode. That matters from day one — the shape of your audit rows is governed by JMS metadata (exclusion policies, serialization groups, naming strategies), not by the entity itself. Check your JMS version before installing.
Minimal path:
composer require manasbala/doctrine-log-bundle
composer require jms/serializer:^4.0 # 1.0.7 adds JMS 4 support; 3.x is likely still allowed
Register it (Symfony Flex may do this for you in config/bundles.php):
return [
// ...
ManasBala\DoctrineLogBundle\DoctrineLogBundle::class => ['all' => true],
];
Look here first, in this order — the package has ~8 stars and docs are thin, so the source is the documentation:
vendor/manasbala/doctrine-log-bundle/src/DependencyInjection/Configuration.php — the authoritative list of config keys. Don't trust blog posts. Pay attention to any serializer-related keys added around 1.0.7.src/EventListener/ — shows exactly which Doctrine hooks (onFlush, postPersist, postUpdate, postRemove) it subscribes to. This tells you what it can't see (see Gotchas).SerializerInterface::serialize() — this is the seam you own. Grep for jms / Serializer in src/ to find it.src/Entity/ (or src/Model/) — the log record shape: entity class, id, action, changed payload, timestamp, actor.Then generate the log table:
bin/console doctrine:migrations:diff
bin/console doctrine:migrations:migrate
First use case: an audit trail on one high-value entity — User, Invoice, or Order. Pick a single entity first, verify the rows land correctly, then broaden. Turning it on globally across a legacy schema is how you get a 40GB log table in a week.
Opt-in per entity, not globally. Most bundles of this shape let you either annotate classes or list them in config. Start with an allowlist:
# config/packages/doctrine_log.yaml — confirm exact keys in Configuration.php
doctrine_log:
enabled: true
entities:
App\Entity\Invoice: ~
App\Entity\User: ~
Keep log writes in the same transaction as the change. The listener runs during onFlush, so the log entry commits or rolls back with the business write. Don't wrap business logic in an outer transaction that a separate log connection can't see — you'll get audit rows for changes that never happened.
Control the payload through JMS metadata, not through hacks. Since 1.0.7 the bundle serializes via JMS, so the cleanest way to shape what lands in the log is JMS itself:
use JMS\Serializer\Annotation as Serializer;
class Invoice
{
#[Serializer\Exclude] // never leaves the entity manager on the way to the log
private string $apiToken;
#[Serializer\Groups(['audit'])] // only logged because the audit group asks for it
private string $status;
}
Configure the serializer context (groups, ExcludePolicy::EXCLUDE_NONE or the default exclude-all policy) once, deliberately. Default JMS behavior is "exclude everything unless told otherwise," which is safer than the opposite — but it also means a missing annotation silently produces empty payloads, which looks like a broken bundle rather than a metadata problem.
Attribute actor context at request boundaries. The bundle can't know who made the change. Set a request-scoped "current user" (a lightweight subscriber on kernel.request, or the bundle's user-resolver hook if it exposes one) so every row carries a username/user_id. Without this, your audit log answers "what changed" but never "who."
Build the read side deliberately. Treat the log table as an append-only event stream and query it, don't hydrate entities:
$logs = $logRepository->createQueryBuilder('l')
->where('l.objectClass = :class')
->andWhere('l.objectId = :id')
->setParameter('class', Invoice::class)
->setParameter('id', $invoice->getId())
->orderBy('l.loggedAt', 'DESC')
->getQuery()
->getResult();
Add a composite index on (object_class, object_id, logged_at) in your migration — the bundle won't do it for you, and this is the only query pattern that matters.
Redact before you persist. Two layers, use both: JMS #[Serializer\Exclude] on the sensitive properties (cheap, metadata-driven, survives refactors) and a decorator around the bundle's listener for anything dynamic like "mask the last four digits of a card number." Never rely on post-hoc scrubbing of the log table — by then it's already written and probably already backed up.
Pair it with a diff view in your admin UI. The old/new payload is only useful if a human can read it. A simple "field / before / after" table per log row converts this from a database curiosity into something support actually uses.
composer.json on the tag to see the exact constraint range; if it allows both 3.x and 4.x, your composer.lock decides your behavior, and CI and prod should agree.onFlush blind spots. Collection changes (many-to-many add/remove, owning-side updates) are the classic miss for listeners at this hook. Test a $order->addLineItem() flow, not just scalar field updates.onFlush, verify it uses computeChangeSet/scheduleForInsert correctly rather than calling flush() — a nested flush will either throw or lose data.DELETE FROM log WHERE logged_at < :cutoff, or Postgres partitioning if volume is high. This is the #1 operational failure mode for audit bundles.bin/console debug:event-dispatcher doctrine.event_listener / doctrine.event_subscriber), the entity isn't in your allowlist, or JMS excluded every property so the payload is []. The last one is new as of this release line and is the easiest to misdiagnose as "the bundle is broken." The package's config tree is the ground truth.gedmo/loggable or a maintained fork.doctrine-extensions Loggable (Gedmo) is far more battle-tested and doesn't drag in a serializer. Writing your own onFlush subscriber for a single entity is ~60 lines. This bundle's value is convenience, not depth — if you need field-level history on many entities, read the source before committing to it.How can I help you explore Laravel packages today?