Änderungshistorie, Diff und Rollback ohne Enterprise-Lizenz
Ein CMS-Block in Magento speichert nur den aktuellen Stand, jede Änderung überschreibt die vorherige Fassung ohne Historie und ohne Rückweg. Mit einer eigenen CMS-Block-Versionierung entsteht eine nachvollziehbare Änderungshistorie, ein Diff zwischen Fassungen und ein sicheres Rollback, ganz ohne Enterprise-Staging.
Inhaltsverzeichnis
- 1. Warum CMS-Blöcke ohne Historie ein Risiko sind
- 2. Datenmodell: eine eigene Versionstabelle per db_schema.xml
- 3. Versionen erzeugen: Plugin auf BlockRepository::save
- 4. Versionshistorie im Admin: eigenes UI-Component-Grid
- 5. Diff-Ansicht: Änderungen zwischen zwei Versionen sichtbar machen
- 6. Rollback: eine frühere Version sicher wiederherstellen
- 7. Freigabe-Workflow: Entwurf, Review, Veröffentlichung
- 8. Aufbewahrungsfristen und Bereinigung alter Versionen
- 9. Versionierungsansätze im Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Warum CMS-Blöcke ohne Historie ein Risiko sind
Ein Magento-CMS-Block besteht aus genau einer Zeile in der Tabelle cms_block, das Feld content enthält den aktuellen HTML-Stand. Speichert ein Redakteur eine Änderung, wird diese Zeile überschrieben, die vorherige Fassung ist unwiderruflich verloren, sofern kein Datenbank-Backup zufällig zum passenden Zeitpunkt existiert. Für Startseiten-Banner, rechtliche Hinweistexte oder saisonale Kampagnen-Blöcke ist das ein erhebliches Betriebsrisiko: Ein fehlerhafter Speichervorgang oder ein versehentlich gelöschter Absatz lässt sich ohne CMS-Block-Versionierung nicht rückgängig machen.
Gerade in Teams mit mehreren Redakteuren verschärft sich das Problem. Ohne Historie ist nicht nachvollziehbar, wer wann welche Änderung an einem Block vorgenommen hat, was bei Rechtsstreitigkeiten über AGB-Texte oder bei der Fehlersuche nach einem defekten Layout zum echten Problem wird. Eine selbst gebaute CMS-Block-Versionierung schließt genau diese Lücke, ohne dass eine kostenpflichtige Enterprise-Lizenz mit nativem Staging notwendig wäre.
2. Datenmodell: eine eigene Versionstabelle per db_schema.xml
Das Fundament der CMS-Block-Versionierung ist eine zusätzliche Tabelle, die jede gespeicherte Fassung eines Blocks als eigenen Datensatz hält, statt den bestehenden Datensatz zu überschreiben. Über db_schema.xml wird eine Tabelle mironsoft_cms_block_version deklariert, mit Fremdschlüssel auf cms_block, einem Zeitstempel, dem Redakteur-Account und dem vollständigen HTML-Snapshot. Deklaratives Schema statt Install-Skripten sorgt dafür, dass die Tabelle bei jedem setup:upgrade konsistent bleibt, auch über mehrere Magento-Versionen hinweg.
Wichtig ist, den kompletten Content-Snapshot zu speichern, nicht nur ein Diff gegenüber der Vorversion. Das erhöht zwar den Speicherbedarf, vereinfacht aber Rollback und Diff-Berechnung erheblich, weil jede Version für sich vollständig und unabhängig lesbar bleibt. Bei typischen CMS-Block-Größen im Kilobyte-Bereich ist dieser Mehrverbrauch in der Praxis vernachlässigbar gegenüber dem Gewinn an Nachvollziehbarkeit.
<!-- app/code/Mironsoft/CmsBlockVersioning/etc/db_schema.xml -->
<?xml version="1.0"?>
<schema xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
<table name="mironsoft_cms_block_version" resource="default" engine="innodb"
comment="CMS Block Version History">
<column xsi:type="int" name="version_id" padding="10" unsigned="true"
nullable="false" identity="true" comment="Version ID"/>
<column xsi:type="int" name="block_id" padding="10" unsigned="true"
nullable="false" comment="Referenced CMS Block ID"/>
<column xsi:type="text" name="content_snapshot" nullable="false" comment="Full content snapshot"/>
<column xsi:type="varchar" name="title_snapshot" length="255" nullable="false" comment="Title at save time"/>
<column xsi:type="int" name="admin_user_id" padding="10" unsigned="true"
nullable="true" comment="Editor who saved this version"/>
<column xsi:type="timestamp" name="created_at" on_update="false"
nullable="false" default="CURRENT_TIMESTAMP" comment="Version created at"/>
<constraint xsi:type="primary" referenceId="PRIMARY">
<column name="version_id"/>
</constraint>
<constraint xsi:type="foreign" referenceId="MIRONSOFT_CMS_BLOCK_VERSION_BLOCK_ID_CMS_BLOCK_BLOCK_ID"
table="mironsoft_cms_block_version" column="block_id"
referenceTable="cms_block" referenceColumn="block_id" onDelete="CASCADE"/>
<index referenceId="MIRONSOFT_CMS_BLOCK_VERSION_BLOCK_ID_CREATED_AT" indexType="btree">
<column name="block_id"/>
<column name="created_at"/>
</index>
</table>
</schema>
3. Versionen erzeugen: Plugin auf BlockRepository::save
Die Erfassung neuer Versionen erfolgt konsequent per Plugin, nicht per Preference, wie es die Coding-Standards für Magento-Erweiterungen vorsehen. Ein afterSave-Plugin auf Magento\Cms\Api\BlockRepositoryInterface liest den bestehenden Datenbank-Stand vor dem Speichern aus und persistiert ihn als neue Version, bevor die eigentliche Änderung wirksam wird. So bleibt die Kernlogik von Magento vollständig unangetastet, die CMS-Block-Versionierung hängt sich nur beobachtend daneben.
Ein häufiger Fehler an dieser Stelle: Das Plugin greift auf afterSave zu, liest dort aber bereits den neuen, gerade gespeicherten Content statt der Vorversion. Der korrekte Ansatz nutzt ein beforeSave-Plugin, das den alten Stand aus der Datenbank lädt und als Version sichert, kombiniert mit einer Prüfung, ob sich der Content überhaupt geändert hat. Ohne diese Prüfung entstehen bei jedem Speichervorgang, auch ohne inhaltliche Änderung, unnötige Versionsdatensätze.
<?php
declare(strict_types=1);
namespace Mironsoft\CmsBlockVersioning\Plugin;
use Magento\Cms\Api\BlockRepositoryInterface;
use Magento\Cms\Api\Data\BlockInterface;
use Mironsoft\CmsBlockVersioning\Model\VersionFactory;
use Mironsoft\CmsBlockVersioning\Model\ResourceModel\VersionRepository;
/**
* Captures the previous content state of a CMS block before it gets overwritten.
*/
class CaptureVersionBeforeSave
{
/**
* @param VersionFactory $versionFactory Factory for version entities
* @param VersionRepository $versionRepository Persists version snapshots
* @param BlockRepositoryInterface $blockRepository Reads the current block state
*/
public function __construct(
private readonly VersionFactory $versionFactory,
private readonly VersionRepository $versionRepository,
private readonly BlockRepositoryInterface $blockRepository,
) {
}
/**
* Persists the current block state as a version before it is overwritten.
*
* @param BlockRepositoryInterface $subject The intercepted repository
* @param BlockInterface $block The block about to be saved
* @return array{0: BlockInterface}
*/
public function beforeSave(BlockRepositoryInterface $subject, BlockInterface $block): array
{
if (!$block->getId()) {
return [$block]; // New block, nothing to version yet
}
$existing = $this->blockRepository->getById((int) $block->getId());
// Skip if content and title are unchanged — avoid noise in the history
if ($existing->getContent() === $block->getContent()
&& $existing->getTitle() === $block->getTitle()) {
return [$block];
}
$version = $this->versionFactory->create();
$version->setBlockId((int) $existing->getId());
$version->setContentSnapshot((string) $existing->getContent());
$version->setTitleSnapshot((string) $existing->getTitle());
$this->versionRepository->save($version);
return [$block];
}
}
4. Versionshistorie im Admin: eigenes UI-Component-Grid
Für Redakteure ist die reine Existenz von Versionsdaten wertlos, wenn sie nicht sichtbar sind. Ein eigenes UI-Component-Grid, eingebunden als Tab im bestehenden CMS-Block-Bearbeitungsformular, listet alle gespeicherten Versionen mit Zeitstempel und Redakteur-Namen auf. Magentos UI-Component-Framework, dasselbe, das auch die Standard-Admin-Grids antreibt, liefert Sortierung, Paginierung und Filterung ohne zusätzlichen Aufwand.
Der entscheidende Mehrwert entsteht durch zwei Aktionen pro Zeile: eine Vorschau der historischen Fassung und ein Rollback-Button. Beide Aktionen führen auf einen eigenen Admin-Controller, der die Berechtigung über die ACL prüft, bevor überhaupt eine Version angezeigt oder wiederhergestellt wird. Diese Trennung zwischen Anzeige und Wiederherstellung verhindert, dass ein Redakteur versehentlich eine Version überschreibt, während er nur nachsehen wollte, was sich geändert hat.
5. Diff-Ansicht: Änderungen zwischen zwei Versionen sichtbar machen
Eine reine Liste von Zeitstempeln beantwortet nicht die eigentliche Frage: Was genau hat sich geändert? Die Diff-Ansicht vergleicht zwei HTML-Snapshots zeilenweise und markiert Einfügungen grün und Löschungen rot, ähnlich einer Code-Diff-Ansicht in einer IDE. Da CMS-Block-Content meist HTML mit Page-Builder-Markup ist, lohnt sich ein Diff auf Textebene mehr als ein Diff auf gerendertem HTML, weil Redakteure die tatsächlich geänderten Textpassagen sehen wollen, nicht verschobene Attribut-Reihenfolgen.
Für die Implementierung reicht ein zeilenbasierter Diff-Algorithmus wie der Longest-Common-Subsequence-Ansatz, der auch klassischen Unix-Diff-Tools zugrunde liegt. Eine fertige PHP-Bibliothek nimmt hier viel Implementierungsaufwand ab, die Darstellung im Admin erfolgt über eine einfache zweispaltige Ansicht mit farblich markierten Zeilen. Bei sehr umfangreichen Content-Blöcken mit eingebetteten Page-Builder-Bausteinen lohnt sich zusätzlich eine Vorschau, die beide Versionen gerendert nebeneinander zeigt, damit visuelle Unterschiede auch ohne HTML-Kenntnisse erkennbar sind.
<?php
declare(strict_types=1);
namespace Mironsoft\CmsBlockVersioning\ViewModel;
use Magento\Framework\View\Element\Block\ArgumentInterface;
/**
* Computes a line-based diff between two CMS block content snapshots.
*/
class ContentDiffViewModel implements ArgumentInterface
{
/**
* Builds a simple line diff structure between two content strings.
*
* @param string $oldContent Content of the older version
* @param string $newContent Content of the newer version
* @return array<int, array{type: string, line: string}>
*/
public function buildDiff(string $oldContent, string $newContent): array
{
$oldLines = explode("\n", $oldContent);
$newLines = explode("\n", $newContent);
$diff = [];
// Simplified line diff — production code should use a proper LCS-based library
foreach ($newLines as $index => $line) {
$unchanged = isset($oldLines[$index]) && $oldLines[$index] === $line;
$diff[] = ['type' => $unchanged ? 'unchanged' : 'added', 'line' => $line];
}
return $diff;
}
}
6. Rollback: eine frühere Version sicher wiederherstellen
Der Rollback selbst ruft intern denselben BlockRepositoryInterface::save-Pfad auf, den auch ein normaler Speichervorgang im Admin durchläuft, mit dem historischen Content-Snapshot als neuem Wert. Das ist bewusst so gestaltet: Der Rollback erzeugt keinen Sonderpfad, der an der eigenen Versionierungslogik vorbeiläuft, sondern durchläuft dieselbe Plugin-Kette, wodurch der aktuelle Stand vor dem Rollback automatisch ebenfalls als Version gesichert wird. Ein Rollback ist damit nie ein destruktiver Vorgang, sondern selbst wieder eine neue, nachvollziehbare Version.
Sicherheitshalber sollte ein Rollback eine Bestätigung mit Vorschau verlangen, bevor er ausgeführt wird, insbesondere bei Blöcken, die auf vielbesuchten Seiten wie der Startseite eingebunden sind. Eine zusätzliche ACL-Berechtigung speziell für Rollback-Aktionen, getrennt von der allgemeinen Bearbeitungsberechtigung für CMS-Blöcke, verhindert, dass jeder Redakteur ungeprüft Fassungen zurücksetzen kann, die möglicherweise bereits von anderen weiterbearbeitet wurden.
7. Freigabe-Workflow: Entwurf, Review, Veröffentlichung
Eine reine Versionshistorie erlaubt Rückblick, verhindert aber nicht, dass fehlerhafter Content überhaupt erst live geht. Ein Freigabe-Workflow ergänzt die CMS-Block-Versionierung um einen Status pro Version: Entwurf, in Review, freigegeben. Nur Versionen im Status freigegeben werden tatsächlich in cms_block.content übernommen und damit im Frontend ausgeliefert, alle anderen bleiben in der Versionstabelle sichtbar, aber wirkungslos für Besucher.
Für Teams mit klarer Rollentrennung, etwa Redakteure, die Entwürfe erstellen, und ein Redaktionsleiter, der freigibt, lohnt sich eine zusätzliche Admin-Notification, sobald eine Version auf Review wartet. Diese Kombination aus Versionierung und Freigabe-Workflow deckt einen großen Teil dessen ab, was Magento Commerce mit nativem Content Staging bietet, lässt sich aber vollständig innerhalb von Magento Open Source umsetzen.
8. Aufbewahrungsfristen und Bereinigung alter Versionen
Ohne Bereinigungsstrategie wächst die Versionstabelle unbegrenzt, insbesondere bei häufig bearbeiteten Blöcken wie Kampagnen-Bannern, die mehrmals täglich angepasst werden. Ein Cron-Job, der Versionen älter als eine konfigurierbare Anzahl von Tagen löscht, dabei aber immer mindestens die letzten zehn Versionen pro Block behält, verhindert unbegrenztes Wachstum, ohne die für Audits relevante jüngste Historie zu gefährden.
Für regulatorisch relevante Blöcke, etwa AGB- oder Widerrufstexte, sollte die Aufbewahrungsfrist deutlich länger sein als für rein werbliche Banner. Eine pro-Block konfigurierbare Aufbewahrungsdauer, hinterlegt als zusätzliches Attribut am CMS-Block selbst, gibt Redakteuren die Kontrolle, ohne dass Entwickler für jede Ausnahme Code anpassen müssen.
9. Versionierungsansätze im Vergleich
Es gibt mehrere Wege, Änderungshistorie für Content in Magento abzubilden, mit unterschiedlichem Implementierungsaufwand und Funktionsumfang. Die folgende Tabelle stellt die gängigen Ansätze gegenüber.
| Ansatz | Aufwand | Rollback | Kosten |
|---|---|---|---|
| Datenbank-Backup | Keiner | Nur ganzer DB-Restore möglich | Kostenlos, aber grob |
| Magento Commerce Staging | Keiner (nativ) | Feingranular, per Zeitplan | Nur mit Enterprise-Lizenz |
| Eigene CMS-Block-Versionierung | Mittel | Feingranular, pro Block | In Open Source umsetzbar |
| Git-Versionierung von CMS-Content | Hoch | Manuell, außerhalb des Admins | Kostenlos |
Für die meisten Projekte ohne Enterprise-Lizenz ist die eigene CMS-Block-Versionierung der beste Kompromiss zwischen Aufwand und Funktionsumfang. Sie deckt die praktisch relevanten Fälle, Rückblick, Diff und Rollback, vollständig ab, ohne den Aufwand einer vollständigen Content-Staging-Plattform zu erfordern.
Mironsoft
Magento 2 & Hyvä: Content-Governance ohne Enterprise-Lizenz
Content-Änderungen ohne Historie ein Risiko?
Wir entwickeln eine maßgeschneiderte CMS-Block-Versionierung mit Diff-Ansicht, Freigabe-Workflow und Rollback, vollständig in Magento Open Source umgesetzt.
Versionshistorie
Eigene Tabelle, Plugin-basierte Erfassung, ACL-geschützter Zugriff
Diff & Rollback
Zeilenbasierte Diff-Ansicht und sicherer Rollback mit Bestätigung
Freigabe-Workflow
Entwurf, Review, Veröffentlichung mit Admin-Benachrichtigungen
10. Zusammenfassung
Eine eigene CMS-Block-Versionierung schließt eine der spürbarsten Lücken von Magento Open Source im Content-Management: Ohne sie überschreibt jede Änderung die vorherige Fassung unwiderruflich, mit ihr entsteht eine vollständige, nachvollziehbare Historie. Eine Versionstabelle per db_schema.xml, ein Plugin auf BlockRepositoryInterface::save und ein UI-Component-Grid im Admin bilden das technische Fundament, Diff-Ansicht und Rollback machen die Historie tatsächlich nutzbar.
Ergänzt um einen Freigabe-Workflow mit Entwurf-, Review- und Veröffentlichungsstatus deckt diese Lösung praktisch dieselben Kernanforderungen ab wie natives Content Staging in Magento Commerce, ohne die zusätzliche Lizenzkosten zu erfordern. Aufbewahrungsfristen und ein Cron-Job für die Bereinigung alter Versionen halten die Lösung auch langfristig wartbar, ohne dass die Versionstabelle unkontrolliert wächst.
CMS-Block-Versionierung — Das Wichtigste auf einen Blick
Datenmodell
Eigene Versionstabelle per db_schema.xml, vollständiger Content-Snapshot pro Version statt Diff.
Erfassung
beforeSave-Plugin auf BlockRepositoryInterface, mit Änderungsprüfung gegen unnötige Versionen.
Rollback
Läuft über denselben Speicherpfad wie ein regulärer Save, dadurch selbst wieder versioniert und nie destruktiv.
Governance
Freigabe-Workflow mit Status Entwurf, Review, Veröffentlichung plus konfigurierbare Aufbewahrungsfristen.