Stop Hand-Writing Upgrade Scripts: Magento 2 Declarative Schema in Practice
Declarative schema in Magento 2 replaces fragile upgrade scripts with a single source of truth in db_schema.xml. Here's how to add a column, backfill it with a Data Patch, and verify the result.
11 Sept 2026, 22:58 UTC

If your team has ever shipped a Magento 2 release where staging had a column that production didn't, you already know the failure mode: someone ran an ad-hoc ALTER TABLE, someone else's UpgradeSchema.php never got merged, and now setup:upgrade behaves differently on every environment. Declarative schema — db_schema.xml, available since Magento 2.3 — exists to kill that class of bug. The thesis of this post is simple: declare your tables and columns in XML, let Magento compute the diff, and reserve PHP for data migrations only.
What declarative schema actually does
Legacy install and upgrade scripts are imperative: each script mutates the database, and the final state is whatever the accumulated scripts produce. That works until scripts run in a different order, get skipped, or a developer edits the database directly. Declarative schema flips this around. You describe the desired end state of every table your module owns in app/code/Vendor/Module/etc/db_schema.xml. When you run bin/magento setup:upgrade, Magento compares that declaration against the current database and generates the ALTER statements itself.
The file covers tables, columns, constraints (primary keys, foreign keys, uniques) and indexes. Because the declaration is the source of truth, two developers on different branches converge to the same schema once both run setup:upgrade — no merge conflicts in numbered upgrade scripts, no drift.
Schema in XML, data in patches
The important mental split: db_schema.xml owns structure, Data Patches own data. A Data Patch is a PHP class implementing Magento\Framework\Setup\Patch\DataPatchInterface that runs exactly once (tracked in the patch_list table) and is the right place for backfills, default values and transformations. Trying to smuggle data logic into schema declaration doesn't work — the XML has no concept of row content — and that constraint is a feature, because it forces every migration to be explicit and auditable.
Worked example: adding a featured flag to products
Say you need a nullable boolean is_featured column on catalog_product_entity for a custom module. Two artifacts, no SQL anywhere:
<!-- app/code/Vendor/Catalog/etc/db_schema.xml -->
<schema xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
<table name="catalog_product_entity" resource="default" engine="innodb" comment="Catalog Product">
<column xsi:type="smallint" name="is_featured" nullable="true" default="0"
comment="Is Featured"/>
</table>
</schema>Note the column type: Magento's declarative types map to real SQL types (smallint here, since Magento conventionally represents booleans as smallint). Then a Data Patch to backfill existing rows, for example flagging products already in a "Featured" category:
// app/code/Vendor/Catalog/Setup/Patch/Data/BackfillIsFeatured.php
class BackfillIsFeatured implements DataPatchInterface
{
public function __construct(private ModuleDataSetupInterface $setup) {}
public function apply(): void
{
$this->setup->getConnection()->update(
$this->setup->getTable('catalog_product_entity'),
['is_featured' => 1],
['entity_id IN (?)' => $featuredIds] // your selection logic
);
}
public static function getDependencies(): array { return []; }
public function getAliases(): array { return []; }
}Run bin/magento setup:upgrade from the Magento root as the filesystem owner (or via your deployment pipeline). Magento whitelists the declared tables, computes the diff, adds the column, then executes the patch once. In a non-production environment you can also use bin/magento setup:db-declaration:generate-whitelist and inspect the generated schema dump to review what Magento thinks the database should look like before applying anything.
Verifying it landed
Don't trust a green CLI line. Three concrete checks:
- Column exists: query
information_schema.COLUMNSforTABLE_NAME='catalog_product_entity' AND COLUMN_NAME='is_featured'and confirm the type and nullability match your declaration. - Patch ran:
SELECT * FROM patch_list WHERE patch_name LIKE '%BackfillIsFeatured%';— exactly one row. - Module version: check
setup_modulefor your module's updatedschema_version, confirmingsetup:upgradeprocessed it.
The trade-offs you should know going in
Declarative schema is excellent at structural versioning and mediocre at everything else. Complex data migrations, conditional transformations, and moving large volumes of rows still need Data Patches or custom scripts — and large backfills should be batched to avoid locking product tables on a busy store. Second, never mix models: if anyone hand-edits a declared table on production, the next setup:upgrade will either revert that change or fail on a conflict. Put "no manual DDL" in your team's runbook. Third, behavior matured across the 2.x line — this post assumes 2.3+; diff and constraint handling on early 2.3 releases differs from later versions, so validate on the exact version you run.
Actionable next step
Pick one module that still uses UpgradeSchema.php, declare its current tables in db_schema.xml, generate the whitelist, and run setup:upgrade on a staging copy of production data. If the diff output is empty, your declaration matches reality and you can delete the old scripts. That single exercise will show you both the safety net and the discipline declarative schema enforces.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.