softspring/doctrine-changelog-bundle
A Symfony bundle for Softspring Doctrine Changelog that automatically records entity changes and stores an audit trail of inserts, updates, and deletes. It integrates with Doctrine ORM to help track data history, surface modifications, and support compliance and debugging workflows.
Read this first: softspring/doctrine-changelog-bundle is a Symfony bundle for Doctrine ORM, not a Laravel package. There is no service provider, no config publish tag, no Artisan command, no Eloquent integration. It attaches a Doctrine event listener that computes field-level before/after diffs on flush and persists them to a changelog table, keyed by entity FQCN + id, with a timestamp and (configurable) user identifier.
That splits your path in two, and you should decide before writing code:
doctrine/orm alongside or instead of Eloquent) → you can consume the bundle by wiring its services manually in a small service provider.owen-it/laravel-auditing or spatie/laravel-activitylog. This package is still worth reading as design prior art — its diff-payload schema is a good shape to copy.Assuming path 1, the minimal sequence:
composer require softspring/doctrine-changelog-bundle — and open the AGPL-3.0 license before you go further (see Gotchas).EntityManager, metadata driver, and migrations pipeline actually work. The bundle assumes a functioning Doctrine setup; a half-installed Doctrine is the most common reason a first attempt does nothing.vendor/softspring/doctrine-changelog-bundle/src and its DependencyInjection/Configuration.php. The README is thin and the release notes are empty; the config tree is the real documentation.doctrine:migrations:diff from the bundle's own changelog entity (Symfony path), or by mirroring its columns in a Laravel migration if you're bridging.Auth::id() for HTTP, an explicit actor for jobs/console), otherwise you get a table of anonymous rows.Track a short, deliberate list. The bundle is driven by a configured entity list. Five entities maximum at launch. Audit tables are append-only and grow with every write — the cost is real and permanent.
Symfony config is the native surface:
# config/packages/doctrine_changelog.yaml
doctrine_changelog:
entities:
App\Entity\Invoice: ~
App\Entity\Contract: ~
In a Laravel+Doctrine bridge, port the meaningful keys into config/changelog.php and construct the listener yourself:
// AppServiceProvider::register()
$this->app->singleton(ChangeLogSubscriber::class, function ($app) {
return new ChangeLogSubscriber(
$app->make(EntityManagerInterface::class),
config('changelog.entities'),
fn () => Auth::id() ?? 'system', // never let this be null
);
});
// after the EntityManager is built
$em->getEventManager()->addEventSubscriber(
$this->app->make(ChangeLogSubscriber::class)
);
Keep this in a single provider — it's the seam that breaks on upgrade.
User resolution must be a closure. The same code path has to serve HTTP (Auth::user()), queued jobs (the job's actor, not the currently-authenticated user, which is null in a worker), and console/backfills (an explicit system marker). Hardcoding Auth::id() is how you end up with a majority-anonymous audit log.
One diff presenter, reused everywhere. The payload is field → [old, new]. Write a single presenter that renders "status: draft → sent", handles inserts (old is null) and deletes as explicit cases, and is used by the detail page, exports, and notifications. Don't let three Blade templates each invent their own null handling.
Index for the only query you'll run. "History of this record, newest first":
$t->index(['entity_class', 'entity_id', 'created_at'], 'changelog_entity_history');
Missing this composite index is the first performance complaint, usually around month three.
Enrich asynchronously. During flush you don't have the new entity's id for inserts, and you shouldn't run extra queries inside the unit of work. Write a minimal row synchronously, then enrich (request id, IP, source system, resolved display labels) in a queued job keyed on the changelog row's own id.
Retention from day one. Ship a scheduled changelog:prune --days=365 command in the first PR. Nobody ever deletes from an audit table until storage is an incident.
If you need revert/restore, verify it in the source before promising it. Restoring an entity to a prior version is the feature stakeholders get excited about, but it means replaying old values back through Doctrine, and relation/collection semantics are where those flows break. Read the code and write a test before it appears on a roadmap slide.
*Bundle, the DI extension, and its YAML tree do nothing under Laravel. Don't burn an afternoon trying to make config/packages/*.yaml load. Documented configuration is only partially applicable to you.onFlush, before SQL runs; for new entities the identifier may not exist yet and the insert lands on postFlush. Null entity ids on insert rows are this, not a bug in your wiring.DB::table()->update(), raw SQL, Model::query()->update(), and any write outside a managed Doctrine entity produce no changelog row. If compliance assumes full coverage, this is the hole to document loudly and loudly in the runbook.TEXT/LONGTEXT/JSON, never VARCHAR(255). Rich-text fields and serialized blobs truncate silently.updated_at, created_at, sort columns, version counters, cache keys. Otherwise a single related-item edit looks like a change to the parent and the timeline becomes unreadable.App\Models\Invoice to App\Domain\Billing\Invoice orphans the old trail — pair the rename with a data migration.composer.json constraints against your PHP, doctrine/orm, and symfony/* versions — Doctrine ORM 3.x compatibility has been an evolving area.How can I help you explore Laravel packages today?