Un rédacteur supprime trois paragraphes, sauvegarde, et réalise son erreur. Sans historique de versions, le contenu est perdu. WordPress résout ce problème avec ses révisions : chaque modification est enregistrée, comparable et restaurable. Ce mécanisme n'existe pas nativement dans Symfony, ni dans EasyAdmin. Cet article documente l'implémentation d'un système équivalent, avec Doctrine comme ORM et EasyAdmin comme back-office.
Le système repose sur trois composantes : un DoctrineEventSubscriber qui capture automatiquement les modifications, un ensemble de services qui gèrent les snapshots JSON et le diff visuel, et un LiveComponent Symfony UX qui fournit l'interface d'historique et de comparaison. La seule dépendance externe est jfcherng/php-diff (BSD-3-Clause).
Choix d'architecture
Quatre décisions structurantes ont orienté la solution :
| Décision | Justification |
|---|---|
| Snapshots JSON complets | Chaque version stocke l'état complet de l'entité. La restauration ne nécessite pas de reconstruire l'état à partir de diffs incrémentiels. L'ajout d'un champ à l'entité ne requiert pas de migration sur la table de versions. |
| jfcherng/php-diff | Diff mot-à-mot avec renderers HTML intégrés (SideBySide et Inline). Alternative à Doctrine Loggable (Gedmo) qui ne fournit pas de diff visuel. |
| Deferred pattern (postFlush) | Doctrine interdit d'appeler flush() pendant un événement preUpdate ou postPersist. Le pattern de file d'attente vidée dans postFlush évite la boucle infinie. |
| LiveComponent | L'état de sélection des versions est géré côté serveur. Le re-rendu partiel évite un rechargement de page pour comparer ou changer de renderer. |
Stockage : une entité polymorphe
Une seule table stocke les versions de tous les types de contenu (Post et Page). Le polymorphisme repose sur deux colonnes : content_type ("post" ou "page") et content_id (UUID de l'entité source, stocké en VARCHAR(36) pour éviter le type PostgreSQL uuid dans une table partagée).
#[ORM\Entity(repositoryClass: ContentVersionRepository::class)]
#[ORM\Index(name: 'idx_content_version_lookup', columns: ['content_type', 'content_id', 'created_at'])]
#[ORM\Index(name: 'idx_content_version_content', columns: ['content_type', 'content_id'])]
class ContentVersion
{
public string $authorName {
get => $this->author instanceof User ? (string) $this->author : 'Système';
}
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: Types::INTEGER)]
public private(set) ?int $id = null;
#[ORM\Column(length: 50)]
public private(set) string $contentType;
#[ORM\Column(type: Types::STRING, length: 36)]
public private(set) string $contentId;
#[ORM\Column(type: Types::INTEGER)]
public private(set) int $versionNumber;
/**
* @var array<string, mixed>
*/
#[ORM\Column(type: Types::JSON)]
public private(set) array $snapshot = [];
/**
* @var list<string>
*/
#[ORM\Column(type: Types::JSON)]
public private(set) array $changedFields = [];
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(name: 'author_id', nullable: true, onDelete: 'SET NULL')]
public private(set) ?User $author = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
public private(set) \DateTimeImmutable $createdAt;
#[ORM\Column(type: Types::TEXT, nullable: true)]
public private(set) ?string $changeDescription = null;
// ...
}
Chaque enregistrement contient un snapshot JSON complet de l'entité au moment de la modification, ainsi qu'un tableau changedFields listant les champs modifiés par rapport à la version précédente. Ce tableau permet d'afficher des badges dans l'interface sans recalculer le diff.
Deux index couvrent les requêtes fréquentes : un index composite (content_type, content_id, created_at) pour l'affichage chronologique, et un index (content_type, content_id) pour le comptage et la purge.
Capture automatique : le pattern deferred postFlush
Le subscriber Doctrine écoute trois événements : postPersist (création), preUpdate (modification) et postFlush (persistance effective). Le point technique à résoudre est la gestion du flush-inside-flush.
Le problème
Créer une ContentVersion nécessite un appel à EntityManager::flush(). Or, preUpdate est déclenché pendant un flush en cours. Appeler flush() à l'intérieur déclencherait un nouveau preUpdate — boucle infinie.
La solution
Les événements postPersist et preUpdate collectent les entités modifiées dans une file d'attente ($pendingVersions). L'événement postFlush, déclenché après la fin du flush initial, draine la file et persiste les versions.
La file doit être vidée avant le flush de persistance des versions. Sinon, le postFlush déclenché par ce second flush retrouverait les mêmes éléments dans la file.
#[AsDoctrineListener(event: 'postPersist', connection: 'default')]
#[AsDoctrineListener(event: 'preUpdate', connection: 'default')]
#[AsDoctrineListener(event: 'postFlush', connection: 'default')]
#[WithMonologChannel('content')]
final class ContentVersionDoctrineSubscriber
{
/** @var list<array{entity: AbstractContent, author: ?User, description: ?string}> */
private array $pendingVersions = [];
public function __construct(
private readonly ContentVersioningService $contentVersioningService,
private readonly Security $security,
private readonly LoggerInterface $logger,
) {
}
public function postPersist(PostPersistEventArgs $postPersistEventArgs): void
{
$entity = $postPersistEventArgs->getObject();
if (!$this->isVersionableEntity($entity)) {
return;
}
$this->pendingVersions[] = [
'entity' => $entity,
'author' => $this->resolveAuthor(),
'description' => 'Version initiale',
];
}
public function preUpdate(PreUpdateEventArgs $preUpdateEventArgs): void
{
$entity = $preUpdateEventArgs->getObject();
if (!$this->isVersionableEntity($entity)) {
return;
}
if (!$this->hasRelevantChanges($preUpdateEventArgs)) {
return;
}
$this->pendingVersions[] = [
'entity' => $entity,
'author' => $this->resolveAuthor(),
'description' => null,
];
}
public function postFlush(): void
{
if ($this->pendingVersions === []) {
return;
}
// Vider la file AVANT le flush pour éviter la boucle infinie
$pending = $this->pendingVersions;
$this->pendingVersions = [];
foreach ($pending as $data) {
try {
$this->contentVersioningService->createVersion(
$data['entity'],
$data['author'],
$data['description'],
);
} catch (\Throwable $throwable) {
$this->logger->error('Failed to create content version', [
'error' => $throwable->getMessage(),
'entity' => $data['entity']::class,
]);
}
}
}
private function hasRelevantChanges(PreUpdateEventArgs $preUpdateEventArgs): bool
{
$relevantFields = ['title', 'content', 'status', 'seo.title', 'seo.description', 'seo.keywords'];
return array_any(
$relevantFields,
static fn (string $field): bool => $preUpdateEventArgs->hasChangedField($field),
);
}
// isVersionableEntity(), resolveAuthor()...
}
Seuls les changements sur les champs de contenu déclenchent une nouvelle version. Une modification de slug ou updatedAt seul n'en crée pas. Le filtrage utilise array_any (PHP 8.4) sur la liste des champs pertinents.
Services de versioning
Trois services final readonly se répartissent les responsabilités.
Snapshot et détection de changements
Le ContentSnapshotService sérialise une entité en tableau associatif et détecte les champs modifiés entre deux snapshots. Les champs imbriqués (SEO) sont gérés via dot-notation :
final readonly class ContentSnapshotService
{
private const array TRACKED_FIELDS = [
'title', 'content', 'status',
'seo.title', 'seo.description', 'seo.keywords',
'proficiencyLevel', 'githubRepo', 'categoryId',
];
/** @return list<string> */
public function detectChangedFields(array $oldSnapshot, array $newSnapshot): array
{
$changed = [];
foreach (self::TRACKED_FIELDS as $field) {
$oldValue = $this->getNestedValue($oldSnapshot, $field);
$newValue = $this->getNestedValue($newSnapshot, $field);
if ($oldValue !== $newValue) {
$changed[] = $field;
}
}
return $changed;
}
private function getNestedValue(mixed $data, string $path): mixed
{
if (!\is_array($data)) {
return null;
}
$keys = explode('.', $path);
$current = $data;
foreach ($keys as $key) {
if (!\is_array($current) || !\array_key_exists($key, $current)) {
return null;
}
$current = $current[$key];
}
return $current;
}
}
Le service expose aussi createSnapshot() (sérialisation d'une entité vers un tableau) et applySnapshot() (application d'un snapshot sur une entité pour la restauration).
Orchestration et politique de rétention
Le ContentVersioningService coordonne les opérations. À chaque création de version, il persiste le snapshot, détecte les champs modifiés par rapport à la version précédente, puis purge les versions excédentaires.
La politique de rétention conserve les 25 versions les plus récentes, plus la version initiale (v1). La purge est gérée par le repository :
class ContentVersionRepository extends ServiceEntityRepository
{
public function purgeOldVersions(
string $contentType,
string $contentId,
int $retentionLimit,
): int {
$totalCount = $this->countByContent($contentType, $contentId);
if ($totalCount <= $retentionLimit) {
return 0;
}
// IDs des N versions les plus récentes à conserver
$keepIds = $this->createQueryBuilder('v')
->select('v.id')
->andWhere('v.contentType = :contentType')
->andWhere('v.contentId = :contentId')
->setParameter('contentType', $contentType)
->setParameter('contentId', $contentId)
->orderBy('v.versionNumber', 'DESC')
->setMaxResults($retentionLimit)
->getQuery()
->getSingleColumnResult()
;
// Toujours conserver la version 1 (état initial)
$firstVersion = $this->createQueryBuilder('v')
->select('v.id')
->andWhere('v.contentType = :contentType')
->andWhere('v.contentId = :contentId')
->andWhere('v.versionNumber = 1')
->setParameter('contentType', $contentType)
->setParameter('contentId', $contentId)
->getQuery()
->getOneOrNullResult()
;
if (\is_array($firstVersion) && isset($firstVersion['id'])) {
$keepIds[] = (int) $firstVersion['id'];
}
return $this->createQueryBuilder('v')
->delete()
->andWhere('v.contentType = :contentType')
->andWhere('v.contentId = :contentId')
->andWhere('v.id NOT IN (:keepIds)')
->setParameter('contentType', $contentType)
->setParameter('contentId', $contentId)
->setParameter('keepIds', array_unique($keepIds))
->getQuery()
->execute()
;
}
}
La v1 est préservée pour permettre la comparaison avec l'état original du contenu, quel que soit le nombre de modifications ultérieures.
Restauration
Restaurer une version applique le snapshot JSON sur l'entité existante via applySnapshot(), puis flush. Le subscriber Doctrine capture cette modification comme une nouvelle version — la restauration ne remplace pas l'historique, elle y ajoute une entrée.
Comparaison visuelle : diff mot-à-mot
Le ContentDiffService utilise jfcherng/php-diff pour générer du HTML de diff entre deux snapshots. Chaque champ est comparé indépendamment. Les champs identiques sont omis du résultat.
final readonly class ContentDiffService
{
private function computeDiff(string $old, string $new, string $renderer): string
{
return DiffHelper::calculate(
$old,
$new,
$renderer, // 'SideBySide' ou 'Inline'
[
'context' => 3,
'ignoreWhitespace' => false,
'ignoreCase' => false,
],
[
'detailLevel' => 'word',
'wrapperClasses' => ['version-diff'],
],
);
}
}
Le paramètre detailLevel: 'word' contrôle la granularité du diff : il surligne les mots modifiés individuellement plutôt que des lignes entières. Pour un article où seul un mot change dans un paragraphe, la différence est lisible sans avoir à parcourir le paragraphe entier.
La méthode publique generateDiff() itère sur les champs (titre, contenu, SEO) et retourne un tableau associatif ['title' => '<html>', 'content' => '<html>'] contenant le HTML du diff pour chaque champ modifié. Deux renderers sont disponibles : SideBySide (ancien à gauche, nouveau à droite) et Inline (suppressions et insertions dans le même flux).
Interface : LiveComponent et sélection par radio buttons
L'interface d'historique est un LiveComponent Symfony UX. Le composant gère l'état côté serveur : sélection des versions, basculement entre vue liste et vue diff, changement de renderer.
Sélection des versions
Le modèle de sélection s'inspire de l'interface de comparaison de Wikipedia. Chaque ligne de version affiche deux radio buttons : un pour la version « ancienne » (source), un pour la « nouvelle » (cible). La version la plus récente ne peut être sélectionnée que comme cible. La plus ancienne ne peut être que source. Les versions intermédiaires acceptent les deux rôles.
{% for version in versions %}
<tr>
<td class="text-center">
{# La plus récente ne peut pas être "ancienne" #}
{% if not loop.first %}
<input type="radio" name="oldVersion" value="{{ version.id }}"
data-model="selectedOldVersionId"
{{ selectedOldVersionId == version.id ? 'checked' : '' }}>
{% endif %}
</td>
<td class="text-center">
{# La plus ancienne ne peut pas être "nouvelle" #}
{% if not loop.last %}
<input type="radio" name="newVersion" value="{{ version.id }}"
data-model="selectedNewVersionId"
{{ selectedNewVersionId == version.id ? 'checked' : '' }}>
{% endif %}
</td>
<td><span class="badge text-bg-dark">v{{ version.versionNumber }}</span></td>
<td>{{ version.createdAt|time_ago }}</td>
<td>{{ version.authorName }}</td>
{# Badges des champs modifiés, bouton restaurer... #}
</tr>
{% endfor %}
À l'ouverture, les deux versions les plus récentes sont pré-sélectionnées pour permettre une comparaison immédiate sans interaction. Le composant expose trois #[LiveAction] : compare() (bascule vers la vue diff), toggleRenderer() (alterne SideBySide/Inline) et closeDiff() (retour à la liste).
Proxy d'entité via reflection
Le LiveComponent ne reçoit que contentType et contentId en string — il n'a pas accès à l'entité hydratée. Pour interroger un repository qui attend un AbstractContent, le composant crée un proxy léger via reflection :
private function createEntityProxy(string $entityClass, string $contentId): AbstractContent
{
$entity = new $entityClass();
$reflectionClass = new \ReflectionClass($entityClass);
$reflectionClass->getProperty('id')
->setValue($entity, Uuid::fromString($contentId));
return $entity;
}
Cette approche évite une requête SQL supplémentaire pour hydrater l'entité complète alors que seul l'UUID est nécessaire pour les requêtes du repository.
Intégration EasyAdmin
L'historique fonctionne via des controllers ADR (Action-Domain-Responder) standalone, hors du pipeline CRUD d'EasyAdmin. Trois routes couvrent les cas d'usage : affichage de l'historique, comparaison de deux versions, et restauration (POST avec token CSRF).
Initialisation manuelle du contexte
Les routes standalone n'ont pas le contexte EasyAdmin nécessaire au layout. Sans initialisation manuelle, le template lance l'erreur : « Impossible to access an attribute ('i18n') on a null variable ». Un VersionContextService dédié crée le contexte via AdminContextFactory et l'injecte dans les attributs de la requête :
final readonly class VersionContextService
{
public function initializeEasyAdminContext(Request $request): void
{
if ($request->attributes->get(EA::CONTEXT_REQUEST_ATTRIBUTE) !== null) {
return;
}
$adminContext = $this->adminContextFactory->create(
$request,
$this->dashboardController,
null,
);
$request->attributes->set(EA::CONTEXT_REQUEST_ATTRIBUTE, $adminContext,
);
}
}
Bouton dans les CRUD controllers
Le bouton « Historique » utilise linkToRoute() pour générer l'URL via le router Symfony :
public function configureActions(Actions $actions): Actions
{
$versionHistory = Action::new('versionHistory', 'Historique', 'fa fa-history')
->linkToRoute('admin_version_history', static fn (Post $post): array => [
'contentType' => 'post',
'contentId' => (string) $post->id,
])
->displayIf(static fn (Post $post): bool => $post->id instanceof Uuid)
;
return parent::configureActions($actions)
->add(Crud::PAGE_EDIT, $versionHistory)
->add(Crud::PAGE_INDEX, $versionHistory)
;
}
Pourquoi pas Doctrine Loggable (Gedmo) ?
Doctrine Extensions (Gedmo) propose une extension Loggable qui enregistre les changements sur les entités. Elle couvre un cas d'usage proche mais diffère sur plusieurs points.
| Critère | Gedmo Loggable | Solution custom |
|---|---|---|
| Diff visuel | Non — stocke les changements bruts | Oui — diff mot-à-mot HTML |
| Restauration | Revert basique | Snapshot complet → entité |
| UI intégrée | Non | LiveComponent interactif |
| Dépendance | Gedmo DoctrineExtensions | jfcherng/php-diff |
Gedmo Loggable convient si le besoin se limite à l'enregistrement des modifications sans interface de comparaison. Pour un historique visuel interactif, la solution custom offre un contrôle complet sur le rendu et le comportement.
Limites connues
Le stockage en snapshot JSON complet consomme plus d'espace qu'un stockage incrémentiel. Avec la politique de rétention à 25 versions, l'impact reste contenu pour des articles de taille standard. Pour des contenus volumineux (images encodées, HTML lourd), un mécanisme de compression serait à envisager.
Le subscriber Doctrine crée une version à chaque flush modifiant un champ pertinent. Un enregistrement automatique fréquent (autosave) peut générer un nombre élevé de versions. La purge automatique limite l'effet, mais un mécanisme de debounce côté applicatif pourrait être nécessaire selon le cas d'usage.
La restauration n'applique que les champs suivis par le snapshot. Les relations complexes (tags, catégories liées) ne sont pas restaurées — seul l'identifiant de la catégorie est stocké, sans garantie que celle-ci existe toujours au moment de la restauration.
Le mot de la fin
Le système de versioning présenté dans ce billet se décompose en quatre couches distinctes, chacune isolée et testable indépendamment.
La capture repose sur le subscriber ContentVersionDoctrineSubscriber et le pattern deferred postFlush. Ce pattern résout un problème réel de Doctrine — l'impossibilité de flush pendant un événement de lifecycle — par une file d'attente vidée après la transaction initiale. C'est la pièce la plus délicate de l'architecture : sans elle, le système génère soit une boucle infinie, soit des versions perdues.
Le stockage en snapshots JSON complets est un compromis explicite. Il consomme plus d'espace qu'un stockage incrémentiel, mais élimine la complexité de reconstruction d'état et rend chaque version autonome. La politique de rétention (25 versions + v1) et les deux index composites maintiennent les performances de lecture sur la durée.
Le diff via jfcherng/php-diff fournit la comparaison mot-à-mot avec deux renderers HTML. La granularité au mot, plutôt qu'à la ligne, est ce qui rend la comparaison exploitable sur du contenu éditorial où les modifications portent sur des formulations plutôt que sur des blocs entiers.
L'interface LiveComponent gère l'état de sélection côté serveur. Le re-rendu partiel permet de basculer entre renderers et de comparer des versions sans rechargement de page, tout en restant dans le paradigme Symfony sans framework JavaScript dédié.
Deux extensions sont envisageables. L'ajout de types de contenu ne nécessite que l'implémentation de VersionableContentInterface sur l'entité et l'ajout de son type dans la map du ContentSnapshotService. La restauration des relations reste le point non couvert par cette implémentation. Un prochain article explorera comment stocker les identifiants des relations dans le snapshot, vérifier leur existence avant restauration et produire un rapport d'erreur pour les références devenues invalides.e de versions dans EasyAdmin : capturer, comparer et restaurer chaque modification