CMS-Block-Versionierung: Änderungshistorie und sicheres Rollback
AI generated
M2
di.xml
Magento 2 · CMS Block · Content-Historie · Rollback
CMS-Block-Versionierung selbst bauen
Ä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.

18 Min. Lesezeit db_schema.xml · Plugin · Diff · Rollback Magento 2.4.x Open Source & Commerce

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.

11. FAQ: CMS-Block-Versionierung

1Warum keine native CMS-Block-Historie in Open Source?
Content-Staging ist Teil der lizenzpflichtigen Magento-Commerce-Funktionen. Open Source speichert nur den aktuellen Stand ohne Historie.
2Wie funktioniert die Datenerfassung technisch?
Ein beforeSave-Plugin liest den bestehenden Stand vor der Änderung und speichert ihn als neue Zeile in einer eigenen Versionstabelle.
3Warum ein Plugin statt einer Preference?
Plugins hängen sich beobachtend an, ohne Kernlogik zu ersetzen, das reduziert Update-Konflikte über Magento-Versionen hinweg.
4Entsteht bei jedem Speichern eine neue Version?
Nur bei tatsächlicher Änderung. Eine Vergleichsprüfung verhindert unnötige Versionen bei identischen Speichervorgängen.
5Wie funktioniert die Diff-Ansicht?
Ein zeilenbasierter Diff-Algorithmus vergleicht zwei Snapshots und markiert Einfügungen und Löschungen farblich.
6Ist ein Rollback destruktiv?
Nein, er läuft über den regulären Speicherpfad, wodurch der Stand vor dem Rollback selbst wieder versioniert wird.
7Unterschied zu einem Datenbank-Backup?
Backups erlauben nur einen kompletten Restore der ganzen Datenbank, die eigene Versionierung erlaubt gezielten Rollback einzelner Blöcke.
8Wie verhindere ich unbegrenztes Wachstum?
Ein Cron-Job löscht ältere Versionen nach konfigurierbarer Frist, behält aber eine Mindestanzahl jüngster Versionen pro Block.
9Kann ich einen Freigabe-Workflow ergänzen?
Ja, per zusätzlichem Status-Feld Entwurf, Review, Veröffentlicht. Nur veröffentlichte Versionen gehen in den ausgelieferten Block.
10Lohnt sich das auch mit Magento Commerce?
Meist nicht für CMS-Blöcke, da natives Staging dort schon vergleichbare Funktionen bietet. Für Open Source ist es die einzig praktikable Option.