Writing One
DataPatchInterface asks for three methods: apply, a static getDependencies, and getAliases for a patch you renamed.
public function apply(): self | public static function getDependencies(): array | public function getAliases(): arrayDataPatchInterface extends PatchInterface, which extends DependentPatchInterface. Between them they ask for exactly three methods.
apply()does the work and returns$this.getDependencies()is static and returns the class names of patches that must run first.getAliases()returns names this patch used to have.
Getting a connection
Inject Magento\Framework\Setup\ModuleDataSetupInterface and name the constructor parameter $moduleDataSetup. The patch applier passes its own setup instance under that argument name, which is the one holding the transaction your patch runs in. From it, getConnection() gives the adapter and getTable('name') resolves a table name through the configured prefix. Never hardcode a table name.
Aliases
If you rename or move a patch class, every install that already applied it has the old name in patch_list and will happily run the new one. Return the old fully qualified name from getAliases(). On apply, Magento inserts the aliases into patch_list as well, and on a later run it treats an applied alias as proof the patch is done.
Write it idempotent anyway
Magento promises a patch runs once per database, not once per dataset. Databases get copied between environments, rows get restored from a dump, and a colleague deletes a patch_list row to test something. Prefer insertOnDuplicate over insert, and check before you update. A patch that is safe to run twice costs nothing extra.
Example
<?php
// app/code/Acme/Loyalty/Setup/Patch/Data/AddDefaultTiers.php
declare(strict_types=1);
namespace Acme\Loyalty\Setup\Patch\Data;
use Magento\Framework\Setup\ModuleDataSetupInterface;
use Magento\Framework\Setup\Patch\DataPatchInterface;
class AddDefaultTiers implements DataPatchInterface
{
private ModuleDataSetupInterface $moduleDataSetup;
public function __construct(ModuleDataSetupInterface $moduleDataSetup)
{
$this->moduleDataSetup = $moduleDataSetup;
}
public function apply(): self
{
$connection = $this->moduleDataSetup->getConnection();
$table = $this->moduleDataSetup->getTable('acme_loyalty_tier');
// insertOnDuplicate($table, array $data, array $fields = [])
// $fields lists the columns to overwrite when the unique key matches.
$connection->insertOnDuplicate(
$table,
[
['code' => 'bronze', 'threshold' => 0],
['code' => 'silver', 'threshold' => 500],
['code' => 'gold', 'threshold' => 2000],
],
['threshold']
);
return $this;
}
public static function getDependencies(): array
{
return [];
}
public function getAliases(): array
{
return [];
}
}
// bin/magento setup:upgrade
// Three rows appear in acme_loyalty_tier. Running it again after
// deleting the patch_list row re-applies the same three rows instead
// of duplicating them, because code carries a unique index.When to use it
- Seed the lookup rows a new module needs before its admin grid can be used.
- Rename a patch class that has already shipped to customers without it running twice.
- Write a one-off correction for a config value that an earlier release set wrongly.
- Give a patch an idempotency guard so restoring a production dump onto staging cannot duplicate rows.
More examples
Constructor property promotion is the same constructor
These two declare the same private property and the same constructor signature; promotion, added in PHP 8.0, is only shorter spelling. Magento's own core classes use the longhand because they still support older coding standards, so you will read both forms in the same project. The argument name is what matters to the patch applier, and it is unchanged by promotion.
<?php
// Longhand: property, parameter, assignment.
class AddDefaultTiers implements DataPatchInterface
{
private ModuleDataSetupInterface $moduleDataSetup;
public function __construct(ModuleDataSetupInterface $moduleDataSetup)
{
$this->moduleDataSetup = $moduleDataSetup;
}
}
// Shorthand: promoted constructor property. Identical class.
class AddDefaultTiers implements DataPatchInterface
{
public function __construct(
private readonly ModuleDataSetupInterface $moduleDataSetup
) {
}
}Surviving a rename with getAliases
Use this the moment you rename, move or re-namespace a patch that has already shipped. Without the alias, every store that upgrades runs the patch a second time. The old class no longer exists, so ::class here is just a compile-time string; writing the plain string 'Acme\\Loyalty\\Setup\\Patch\\Data\\AddDefaultTiers' produces exactly the same array, and is the safer form once the old file is deleted and a static analyser starts complaining about a missing class.
<?php
// app/code/Acme/Loyalty/Setup/Patch/Data/AddLoyaltyTiers.php
declare(strict_types=1);
namespace Acme\Loyalty\Setup\Patch\Data;
use Magento\Framework\Setup\Patch\DataPatchInterface;
// Renamed from AddDefaultTiers in version 1.2.0 of this module.
class AddLoyaltyTiers implements DataPatchInterface
{
public function apply(): self
{
// unchanged
return $this;
}
public static function getDependencies(): array
{
return [];
}
public function getAliases(): array
{
return [
\Acme\Loyalty\Setup\Patch\Data\AddDefaultTiers::class,
];
}
}Guarding with a bound value
A read-then-write guard for the case where the patch cannot rely on a unique index. The value goes in through the ? placeholder in where(), which binds it; concatenating it into the string would be an injection even in setup code, where the value often comes from a CSV or an environment variable. fetchOne returns false when nothing matches, so compare with !== false rather than truthiness, since a tier_id of 0 is falsy.
<?php
public function apply(): self
{
$connection = $this->moduleDataSetup->getConnection();
$table = $this->moduleDataSetup->getTable('acme_loyalty_tier');
$code = 'platinum';
$select = $connection->select()
->from($table, 'tier_id')
->where('code = ?', $code);
if ($connection->fetchOne($select) !== false) {
return $this;
}
$connection->insert($table, ['code' => $code, 'threshold' => 10000]);
return $this;
}
Discussion