Reverting

revert() runs only when module:uninstall removes data, may use only INSERT and UPDATE, and some patches should not have one at all.

Syntaxpublic function revert(); // Magento\Framework\Setup\Patch\PatchRevertableInterface

Magento\Framework\Setup\Patch\PatchRevertableInterface adds one method, revert(), returning nothing. It answers a single question: what should the database look like if this module goes away.

When it actually runs

Only during bin/magento module:uninstall, and only when that command removes module data. Pass -r (--remove-data), or answer yes to the prompt it shows. The command targets Composer-installed modules; for a module that lives in app/code add --non-composer. Magento then walks the module's applied patches in reverse dependency order, calls revert() on the ones that implement the interface, deletes each from patch_list, and commits. Deleting a module's folder by hand reverts nothing, and neither does module:disable.

Rules the interface imposes

The interface documents its own constraints, and they are narrow. A revertable patch may use only INSERT and UPDATE. DELETE is prohibited, because removing rows can trigger foreign key constraints in tables you were not thinking about. Stay out of the application layer: no collections, no serializers, just the setup connection. Schema patches are never revertable.

The patch that cannot honestly be reverted

A patch that overwrote a column, re-encoded serialized values as JSON, or removed rows has destroyed the information a revert would need. Writing a revert() that guesses the old values is worse than having none, because the uninstall then reports success. Leave the interface off. The data stays behind, which is the truthful outcome, and the uninstall notes say to clean it up by hand.

Example

Example · php
<?php
// app/code/Acme/Loyalty/Setup/Patch/Data/EnableGuestLoyalty.php
declare(strict_types=1);

namespace Acme\Loyalty\Setup\Patch\Data;

use Magento\Framework\App\Config\Storage\WriterInterface;
use Magento\Framework\Setup\Patch\DataPatchInterface;
use Magento\Framework\Setup\Patch\PatchRevertableInterface;

class EnableGuestLoyalty implements DataPatchInterface, PatchRevertableInterface
{
    public function __construct(
        private readonly WriterInterface $configWriter
    ) {
    }

    public function apply(): self
    {
        $this->configWriter->save('acme_loyalty/general/allow_guest', '1');

        return $this;
    }

    public function revert(): void
    {
        // UPDATE back to the shipped default. No DELETE: the interface
        // forbids it, and system.xml already supplies 0 as the default.
        $this->configWriter->save('acme_loyalty/general/allow_guest', '0');
    }

    public static function getDependencies(): array
    {
        return [];
    }

    public function getAliases(): array
    {
        return [];
    }
}

// bin/magento setup:upgrade          applies it
// bin/magento module:uninstall -r Acme_Loyalty   reverts it
// Both need bin/magento cache:flush config afterwards.

When to use it

  • Ship a paid extension that leaves a clean database when a merchant uninstalls it.
  • Restore a config flag to its shipped default as part of removing a module.
  • Decide, in review, whether a colleague's revert() can actually restore what apply() overwrote.
  • Test an uninstall path against a copy of production before publishing a release.

More examples

What the uninstall prints

Run this when you want to verify that a revert works before shipping an extension, ideally against a copy of a real database. The line that matters is "Removing data of Acme_Loyalty": that step is uninstallData(), which calls revert() on your patches and clears their rows from patch_list. On this Composer path the command puts the site into maintenance mode on its own, unlike setup:upgrade. With --non-composer the command does none of that: it calls revertDataPatches() for each named module and exits straight away, printing nothing at all, so a silent run is the expected result for a module in app/code and you check the work by querying patch_list.

Example · bash
$ bin/magento module:uninstall -r Acme_Loyalty
You are about to remove code and/or database tables. Are you sure?[y/N] y
Enabling maintenance mode
Removing data of Acme_Loyalty
Removing Acme_Loyalty from module registry in database
Removing Acme_Loyalty from module list in deployment configuration
Removing code from Magento codebase:
Disabling maintenance mode

# Without -r, Magento asks whether to remove the data and skips
# revert() if you decline.
#
# A module in app/code needs --non-composer, and that flag is a
# different code path: the command reverts the data patches of each
# named module and returns immediately, so it prints nothing, asks
# nothing, does not enable maintenance mode and removes no code or
# registry rows. -r is not even read there, so the patches are
# reverted either way.
$ bin/magento module:uninstall -r --non-composer Acme_Loyalty
$

A patch that correctly has no revert

The shape to copy when a patch is a one-way data migration. The comment explaining the missing interface is part of the lesson: a reviewer who does not see it will ask for a revert. Note the bound tier_id in the where array and the allowed_classes option on unserialize, which stops stored data from instantiating objects. A patch this shape is also a candidate for batching once the table is large.

Example · php
<?php
// app/code/Acme/Loyalty/Setup/Patch/Data/ConvertTierRulesToJson.php
declare(strict_types=1);

namespace Acme\Loyalty\Setup\Patch\Data;

use Magento\Framework\Setup\ModuleDataSetupInterface;
use Magento\Framework\Setup\Patch\DataPatchInterface;

// No PatchRevertableInterface on purpose: apply() overwrites the
// serialized payload in place, so the old bytes no longer exist
// anywhere once it has run. A revert() could only invent them.
class ConvertTierRulesToJson implements DataPatchInterface
{
    public function __construct(
        private readonly ModuleDataSetupInterface $moduleDataSetup
    ) {
    }

    public function apply(): self
    {
        $connection = $this->moduleDataSetup->getConnection();
        $table = $this->moduleDataSetup->getTable('acme_loyalty_tier');

        foreach ($connection->fetchAll(
            $connection->select()->from($table, ['tier_id', 'rules'])
        ) as $row) {
            $connection->update(
                $table,
                ['rules' => json_encode(unserialize($row['rules'], ['allowed_classes' => false]))],
                ['tier_id = ?' => (int) $row['tier_id']]
            );
        }

        return $this;
    }

    public static function getDependencies(): array
    {
        return [];
    }

    public function getAliases(): array
    {
        return [];
    }
}

Discussion

  • Be the first to comment on this lesson.