CMS Block Versioning: Change History and Safe Rollback
AI generated
M2
di.xml
Magento 2 · CMS Block · Content History · Rollback
Building CMS Block Versioning
change history, diff and rollback without an Enterprise license

A CMS block in Magento only stores the current state, every change overwrites the previous version with no history and no way back. A custom CMS block versioning setup creates a traceable change history, a diff between versions and a safe rollback, all without Enterprise staging.

18 min read db_schema.xml · plugin · diff · rollback Magento 2.4.x Open Source & Commerce

1. Why CMS blocks without history are a risk

A Magento CMS block consists of exactly one row in the cms_block table, and the content field holds the current HTML state. When an editor saves a change, that row is overwritten, the previous version is irretrievably lost unless a database backup happens to exist from the right point in time. For homepage banners, legal disclaimer text or seasonal campaign blocks, that is a considerable operational risk: a faulty save or an accidentally deleted paragraph cannot be undone without CMS block versioning.

This problem gets worse in teams with multiple editors. Without history there is no way to trace who changed what and when on a given block, which becomes a real problem during disputes over terms and conditions text or while debugging a broken layout. A self-built CMS block versioning setup closes exactly this gap, without requiring a paid Enterprise license with native staging.

2. Data model: a dedicated version table via db_schema.xml

The foundation of CMS block versioning is an additional table that holds every saved version of a block as its own record instead of overwriting the existing one. Through db_schema.xml, a table mironsoft_cms_block_version is declared, with a foreign key to cms_block, a timestamp, the editor account and the full HTML snapshot. Declarative schema instead of install scripts ensures the table stays consistent across every setup:upgrade, even across multiple Magento versions.

It is important to store the full content snapshot, not just a diff against the previous version. That increases storage requirements slightly but considerably simplifies rollback and diff computation, because every version remains fully and independently readable on its own. At typical CMS block sizes measured in kilobytes, this extra overhead is negligible in practice compared to the gain in traceability.


<!-- 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. Creating versions: a plugin on BlockRepository::save

Capturing new versions is consistently done through a plugin rather than a preference, as the coding standards for Magento extensions recommend. A beforeSave plugin on Magento\Cms\Api\BlockRepositoryInterface reads the existing database state before the save happens and persists it as a new version before the actual change takes effect. This keeps Magento's core logic completely untouched, CMS block versioning only observes from the side.

A common mistake here: hooking the plugin into afterSave instead, which by then already reads the new, just-saved content rather than the previous version. The correct approach uses a beforeSave plugin that loads the old state from the database and stores it as a version, combined with a check for whether the content actually changed at all. Without that check, every save, even without any content change, would create unnecessary version records.


<?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. Version history in the admin: a custom UI component grid

For editors, the mere existence of version data is worthless if it stays invisible. A custom UI component grid, added as a tab in the existing CMS block edit form, lists every saved version with a timestamp and the editor's name. Magento's UI component framework, the same one powering the standard admin grids, provides sorting, pagination and filtering without extra effort.

The real value comes from two actions per row: a preview of the historical version and a rollback button. Both actions lead to a custom admin controller that checks the permission through ACL before any version is even displayed or restored. This separation between viewing and restoring prevents an editor from accidentally overwriting a version while only wanting to check what had changed.

5. Diff view: making changes between two versions visible

A plain list of timestamps does not answer the actual question: what exactly changed? The diff view compares two HTML snapshots line by line and marks insertions in green and deletions in red, similar to a code diff view in an IDE. Since CMS block content is usually HTML with Page Builder markup, a text-level diff is more useful than a diff of rendered HTML, because editors want to see the actual changed text passages, not shuffled attribute order.

For the implementation, a line-based diff algorithm such as the longest common subsequence approach that also underlies classic Unix diff tools is enough. A ready-made PHP library takes most of the implementation work off your hands, and the display in the admin uses a simple two-column view with color-coded lines. For very large content blocks with embedded Page Builder blocks, an additional preview that renders both versions side by side is worthwhile, so visual differences are recognizable even without HTML knowledge.


<?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: safely restoring an earlier version

The rollback itself internally calls the same BlockRepositoryInterface::save path that a normal save in the admin goes through, using the historical content snapshot as the new value. This is deliberate: rollback does not create a special path that bypasses the versioning logic itself but goes through the same plugin chain, which means the current state before the rollback is automatically saved as a version too. A rollback is therefore never a destructive action, it is itself a new, traceable version.

As a safety measure, rollback should require a confirmation with a preview before it executes, especially for blocks embedded on high-traffic pages such as the homepage. An additional ACL permission specifically for rollback actions, separate from the general editing permission for CMS blocks, prevents any editor from carelessly resetting versions that other people may already have built further changes on top of.

7. Approval workflow: draft, review, publish

A plain version history allows looking back but does not prevent faulty content from going live in the first place. An approval workflow extends CMS block versioning with a status per version: draft, in review, approved. Only versions in the approved status are actually applied to cms_block.content and thereby delivered to the frontend, all others remain visible in the version table but have no effect on visitors.

For teams with clear role separation, for example editors who draft content and an editorial lead who approves it, an additional admin notification when a version is waiting for review is worthwhile. This combination of versioning and approval workflow covers a large part of what Magento Commerce offers with native content staging, yet it can be fully implemented within Magento Open Source.

8. Retention periods and cleaning up old versions

Without a cleanup strategy the version table grows without bound, especially for frequently edited blocks such as campaign banners updated several times a day. A cron job that deletes versions older than a configurable number of days, while always keeping at least the last ten versions per block, prevents unbounded growth without jeopardizing the recent history relevant for audits.

For regulatory-relevant blocks, such as terms and conditions or withdrawal text, the retention period should be considerably longer than for purely promotional banners. A per-block configurable retention duration, stored as an additional attribute on the CMS block itself, gives editors control without developers having to adjust code for every exception.

9. Versioning approaches compared

There are several ways to represent content change history in Magento, with different implementation effort and feature scope. The following table compares the common approaches.

Approach Effort Rollback Cost
Database backup None Only a full DB restore possible Free, but coarse
Magento Commerce staging None (native) Fine-grained, scheduled Enterprise license only
Custom CMS block versioning Medium Fine-grained, per block Feasible in Open Source
Git versioning of CMS content High Manual, outside the admin Free

For most projects without an Enterprise license, custom CMS block versioning is the best compromise between effort and feature scope. It fully covers the practically relevant cases, looking back, diffing and rolling back, without requiring the effort of a full content staging platform.

Mironsoft

Magento 2 & Hyvä: content governance without an Enterprise license

Content changes without history a risk?

We build custom CMS block versioning with diff view, approval workflow and rollback, fully implemented in Magento Open Source.

Version history

Custom table, plugin-based capture, ACL protected access

Diff & rollback

Line-based diff view and safe rollback with confirmation

Approval workflow

Draft, review, publish with admin notifications

10. Summary

Custom CMS block versioning closes one of the most noticeable gaps in Magento Open Source content management: without it, every change overwrites the previous version irretrievably, with it, a full, traceable history emerges. A version table via db_schema.xml, a plugin on BlockRepositoryInterface::save and a UI component grid in the admin form the technical foundation, diff view and rollback make the history actually usable.

Extended with an approval workflow with draft, review and publish status, this solution covers practically the same core requirements as native content staging in Magento Commerce, without requiring the additional license cost. Retention periods and a cron job for cleaning up old versions keep the solution maintainable long term without the version table growing out of control.

CMS block versioning — the essentials at a glance

Data model

Custom version table via db_schema.xml, full content snapshot per version instead of a diff.

Capture

beforeSave plugin on BlockRepositoryInterface, with a change check to avoid unnecessary versions.

Rollback

Runs through the same save path as a regular save, itself versioned again, never destructive.

Governance

Approval workflow with draft, review, publish status plus configurable retention periods.

11. FAQ: CMS block versioning

1Why no native CMS block history in Open Source?
Content staging is part of the licensed Magento Commerce features. Open Source only stores the current state without history.
2How does data capture work technically?
A beforeSave plugin reads the existing state before the change and stores it as a new row in a custom version table.
3Why a plugin instead of a preference?
Plugins hook in observationally without replacing core logic, reducing update conflicts across Magento versions.
4Is a new version created on every save?
Only on actual changes. A comparison check prevents unnecessary versions on identical saves.
5How does the diff view work?
A line-based diff algorithm compares two snapshots and color-codes insertions and deletions.
6Is a rollback destructive?
No, it runs through the regular save path, meaning the state before the rollback is itself versioned.
7Difference to a database backup?
Backups only allow a full restore of the whole database, custom versioning allows targeted rollback of individual blocks.
8How do I prevent unbounded growth?
A cron job deletes older versions after a configurable period but keeps a minimum number of recent versions per block.
9Can I add an approval workflow?
Yes, via an additional status field draft, review, published. Only published versions go into the delivered block.
10Does this still make sense with Magento Commerce?
Usually not for CMS blocks, since native staging already provides comparable functionality. For Open Source it is the only practical option.