integrated/content-history-bundle
Integrated Content History Bundle adds revision tracking to Symfony content entities, automatically recording changes and giving editors a clear history view to compare and restore previous versions. Simplify auditing, rollback, and editorial workflows with minimal setup.
This bundle lives on Bitbucket, not Packagist, so the first move is a VCS repository entry rather than a plain composer require:
{
"repositories": [
{ "type": "vcs", "url": "https://bitbucket.org/eactive/integrated-content-history-bundle.git" }
]
}
composer require integrated/content-history-bundle:dev-master
Pin the resolved commit in composer.lock immediately, and make sure CI has Bitbucket credentials (SSH deploy key or app password) — anonymous HTTPS clones fail quietly in pipelines.
Where to look first, in order:
src/IntegratedContentHistoryBundle.php and src/DependencyInjection/Configuration.php — for an undocumented bundle, the config tree is the documentation. Every knob it accepts is declared here.src/Entity/ plus any Resources/config/doctrine/ or Migrations/ — this reveals the table shape it expects and whether you must author the schema yourself.src/EventSubscriber/ or src/EventListener/ — this is where "automatic" revision capture actually lives (if it exists). Read the listener instead of guessing which Doctrine events are hooked.README.md — treat as a hint to verify, not a contract.Sanity-check the wiring before writing any feature code:
bin/console cache:clear
bin/console debug:config integrated_content_history
bin/console doctrine:schema:validate
First use case: pick a single existing content entity (Article, Page, or Block) and get exactly one history row written when it's edited. Resist wiring every entity up front — the capture mechanism (automatic listener vs. explicit service call) determines how invasive the whole integration is, and you want to learn that on one model, not five.
Framework reality check: despite any "Laravel/PHP" framing in the listing, the
Bundlesuffix and eactive hosting point to Symfony. If you are Laravel-only, budget this as a port, not a dependency. The steps below assume a Symfony host or an intentional port.
Automatic capture via Doctrine events. If the bundle ships a listener, it typically hooks onFlush/postPersist/postUpdate and derives a history row from the entity change set. The integration work is then mostly configuration: telling it which entities to watch. Check Configuration.php for a classes, entities, or subjects node — that list is your allowlist.
Explicit capture at business boundaries. For content systems, "every column write" is usually the wrong granularity: you get noise from SEO fields, slugs, and timestamps. A more durable pattern is to invoke the history service yourself at meaningful moments:
public function publish(Article $article, User $actor): void
{
$this->history->record($article, 'published', $actor, [
'snapshot' => $this->normalizer->normalize($article, null, ['groups' => ['history']]),
]);
}
This survives refactors of the bundle's listener, is unit-testable, and lets you record intent (published, reverted, translation_added) instead of raw column deltas.
Snapshot vs. delta. Store a normalized snapshot for revert capability, and a diff for display. Do not rely on the bundle to serialize your entities — Doctrine proxies, lazy collections, and circular associations will bite you. Normalize through Symfony's Serializer with an explicit history group.
Version-on-publish, not on-save. A workflow that matches editorial reality:
| Event | History row? | Payload |
|---|---|---|
| Draft autosave | No (or coalesced) | — |
| Publish / unpublish | Yes | Full snapshot + actor |
| Revert | Yes | New row referencing the restored revision |
| Bulk import | No | — |
Revert flow. Implement revert as a new revision whose content copies the old one, not as a destructive write back in time. That keeps the audit trail append-only and makes "restore" itself reviewable. Wrap it in a transaction and re-dispatch any index/cache invalidation the original write would have triggered.
Consumer integration (Laravel app, Symfony history service). If the history capability lives in a Symfony service and a Laravel app needs to read it, expose a thin read API (GET /content/{type}/{id}/revisions) plus an internal event stream for writes. In Laravel, mirror the intent with Eloquent observers and a content_revisions table, so the two sides share a vocabulary even if they do not share code.
Retention and authorization. A revision list is a data-exfiltration surface. Gate it behind the same policy as edit access, and add a retention/archival job from day one — this table only grows.
doctrine:schema:validate will tell you fast; a silent mismatch shows up later as "history writes succeed but reads return nothing."onFlush fires inside the transaction and is the usual place for subtle bugs (you can capture entities that are later rolled back, or double-count updates in a unit of work). Confirm behavior with a test that deliberately fails after flush.composer.json first; deprecated Doctrine APIs (LifecycleEventArgs, preUpdate signatures) differ across majors and will fail at runtime, not at install.DateTimeImmutable, and relations without cascade will either throw or produce gigantic nested snapshots. Use a dedicated normalizer group and assert snapshot size in a test.bin/console debug:event-dispatcher will show whether the history subscriber is actually registered; debug:config integrated_content_history shows what the bundle thinks it was told. Between those two, most "nothing is being recorded" issues resolve.Configuration.php tree and the service definitions in Resources/config/ are the real extension surface — override or decorate services there (e.g. swap the recorder) rather than editing vendor code.How can I help you explore Laravel packages today?