Magento 2: Add a Product Attribute Programmatically With a Data Patch (Not InstallData)
InstallData is dead. This is the data patch pattern I ship to client stores: one class, dropdown options, attribute set assignment, and a patch_list war story from a live deploy.
To add a product attribute programmatically in Magento 2, create a class in Setup/Patch/Data that implements DataPatchInterface, inject EavSetupFactory, call addAttribute() inside apply(), then run bin/magento setup:upgrade. That is the entire modern recipe. Most tutorials still ranking for this query show InstallData scripts that Adobe deprecated years ago. This post is the version I actually ship to client stores: full module code, a dropdown with options, attribute set assignment, revert logic, and how the attribute ends up on a Hyvä PDP. Everything here was tested on Magento 2.4.9, PHP 8.4 (July 2026).
Why a data patch, not InstallData or UpgradeData
If you search for this topic today, the top results from the big extension vendor blogs still open with InstallData.php and a version check inside UpgradeData.php. Both mechanisms were deprecated when Magento 2.3 introduced declarative schema and the patch system. They still execute on 2.4.9, which is exactly why outdated tutorials keep circulating, but new code written that way fails review for a reason: version-gated upgrade scripts turn into a giant if-else ladder that nobody can safely reorder, and there is no built-in rollback story.
Data patches fix all of that. Each patch is one class with one job. Magento records applied patches in the patch_list table, so every patch runs exactly once, in dependency order you declare yourself. And note that declarative schema (db_schema.xml) does not handle EAV attributes at all. It manages plain tables and columns. Attributes are data rows in eav_attribute, so a data patch is the correct tool, full stop.
The module skeleton (4 files)
I am using a real namespace here, Panth_CatalogAttributes, because placeholder code with Acme namespaces is how copy-paste bugs happen. If you have never registered a module before, read my post on creating a custom Magento 2 module from scratch first; I will keep the skeleton short here.
app/code/Panth/CatalogAttributes/
|-- registration.php
|-- etc/
| `-- module.xml
`-- Setup/
`-- Patch/
`-- Data/
|-- AddWarrantyInfoAttribute.php
`-- AddMetalFinishAttribute.php
etc/module.xml declares the module and a sequence on Magento_Catalog, since our patches need the catalog entity type to exist:
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
<module name="Panth_CatalogAttributes">
<sequence>
<module name="Magento_Catalog"/>
</sequence>
</module>
</config>
registration.php is the standard three-liner with ComponentRegistrar::register(). Nothing special. The interesting files are the two patches.
Add a product attribute programmatically with a data patch
Here is the full patch for a simple text attribute, warranty_info. This is PHP 8.4 style: constructor property promotion, readonly properties, typed return values. It implements both DataPatchInterface (so it runs) and PatchRevertableInterface (so it can be undone).
<?php
declare(strict_types=1);
namespace Panth\CatalogAttributes\Setup\Patch\Data;
use Magento\Catalog\Model\Product;
use Magento\Eav\Model\Entity\Attribute\ScopedAttributeInterface;
use Magento\Eav\Setup\EavSetupFactory;
use Magento\Framework\Setup\ModuleDataSetupInterface;
use Magento\Framework\Setup\Patch\DataPatchInterface;
use Magento\Framework\Setup\Patch\PatchRevertableInterface;
class AddWarrantyInfoAttribute implements DataPatchInterface, PatchRevertableInterface
{
private const ATTRIBUTE_CODE = 'warranty_info';
public function __construct(
private readonly ModuleDataSetupInterface $moduleDataSetup,
private readonly EavSetupFactory $eavSetupFactory
) {
}
public function apply(): void
{
$this->moduleDataSetup->getConnection()->startSetup();
$eavSetup = $this->eavSetupFactory->create(['setup' => $this->moduleDataSetup]);
$eavSetup->addAttribute(Product::ENTITY, self::ATTRIBUTE_CODE, [
'type' => 'varchar',
'label' => 'Warranty Info',
'input' => 'text',
'global' => ScopedAttributeInterface::SCOPE_STORE,
'required' => false,
'user_defined' => true,
'searchable' => true,
'filterable' => false,
'comparable' => false,
'visible_on_front' => true,
'used_in_product_listing' => true,
'is_used_in_grid' => true,
'group' => 'General',
'sort_order' => 50,
]);
$this->moduleDataSetup->getConnection()->endSetup();
}
public function revert(): void
{
$this->moduleDataSetup->getConnection()->startSetup();
$eavSetup = $this->eavSetupFactory->create(['setup' => $this->moduleDataSetup]);
$eavSetup->removeAttribute(Product::ENTITY, self::ATTRIBUTE_CODE);
$this->moduleDataSetup->getConnection()->endSetup();
}
public static function getDependencies(): array
{
return [];
}
public function getAliases(): array
{
return [];
}
}
Enable and run it:
php bin/magento module:enable Panth_CatalogAttributes
php bin/magento setup:upgrade
php bin/magento cache:flush
php bin/magento indexer:reindex catalogsearch_fulltext
Four commands, and the attribute shows up under Stores, Attributes, Product in the admin. A few notes on the array keys, because this is where most tutorials hand-wave:
typeis the backend storage table:varchar,int,text,decimal, ordatetime. It maps tocatalog_product_entity_varcharand friends.globaltakes aScopedAttributeInterfaceconstant:SCOPE_STORE,SCOPE_WEBSITE, orSCOPE_GLOBAL. Anything you plan to use in price rules or configurable products should be global.user_defined => trueis what lets admins delete the attribute from the UI and add it to attribute sets. Leave it false for system attributes that must never be removed by hand.group => 'General'quietly does a lot of work: it assigns the attribute to that group in every existing attribute set. More on that below.
Create a dropdown attribute with options programmatically
The second most common request: a select attribute with predefined options, the kind merchandisers use for filters. The second patch, AddMetalFinishAttribute.php, has the same skeleton, so I will only show the addAttribute() call that differs:
$eavSetup->addAttribute(Product::ENTITY, 'metal_finish', [
'type' => 'int',
'label' => 'Metal Finish',
'input' => 'select',
'source' => \Magento\Eav\Model\Entity\Attribute\Source\Table::class,
'global' => ScopedAttributeInterface::SCOPE_GLOBAL,
'required' => false,
'user_defined' => true,
'searchable' => true,
'filterable' => 1,
'is_filterable_in_search' => true,
'visible_on_front' => true,
'used_in_product_listing' => true,
'option' => ['values' => ['Brushed', 'Polished', 'Matte Black', 'Chrome']],
'group' => 'General',
'sort_order' => 60,
]);
Three details that trip people up:
typemust beintfor a select, because the stored value is the option ID, not the label. Set it tovarcharand layered navigation silently breaks.sourceshould be theTablesource model when your options live ineav_attribute_option. Only write a custom source model when options are computed at runtime, because table-sourced options are editable by the admin and indexable by layered navigation.option => ['values' => [...]]inserts the options in one pass. If you need fixed option IDs (for import mappings), pass a keyed array per store instead; for 95 percent of cases the flat list is what you want.
Multiselect variant: change input to multiselect, type to varchar (values are stored as comma-separated option IDs), and add 'backend' => \Magento\Eav\Model\Entity\Attribute\Backend\ArrayBackend::class. Everything else stays identical.
Assign the attribute to an attribute set programmatically
The group key in addAttribute() already assigns the attribute to that group in all attribute sets that exist when the patch runs. That is usually the behavior you want for a store-wide attribute. When you need finer control, say only the "Jewelry" attribute set, or a specific group and position, drop the group key and do it explicitly inside apply():
$entityTypeId = $eavSetup->getEntityTypeId(Product::ENTITY);
// One specific attribute set by name
$attributeSetId = $eavSetup->getAttributeSetId($entityTypeId, 'Jewelry');
$eavSetup->addAttributeToSet($entityTypeId, $attributeSetId, 'Product Details', 'metal_finish', 60);
// Or every attribute set, explicit version of what 'group' does
foreach ($eavSetup->getAllAttributeSetIds($entityTypeId) as $setId) {
$eavSetup->addAttributeToSet($entityTypeId, $setId, 'Product Details', 'metal_finish', 60);
}
One caveat worth planning around: attribute sets created after the patch ran will only inherit the attribute if the set is based on Default and the attribute is in Default. If merchandisers create sets from scratch regularly, document that, or ship a small follow-up patch when new sets appear.
is_filterable, used_in_product_listing, and when to reindex
These flags are cheap to set in the patch and expensive to forget:
filterable => 1puts the attribute in layered navigation ("Filterable with results"). Use2for "no results" behavior. This only takes effect aftercatalog_product_attributeandcatalogsearch_fulltextreindex.used_in_product_listing => truecopies the value into the flat/listing scope so category templates and Hyvä listing cards can read it without an extra load per product.searchable => truefeeds the value into OpenSearch. Again: no reindex, no effect.
php bin/magento indexer:reindex catalog_product_attribute catalogsearch_fulltext
On a store in production mode I run the reindex as part of the deploy script, right after setup:upgrade. If layered navigation does not show your new filter, in my experience it is a missing reindex about nine times out of ten, and the tenth is filterable left at 0.
Remove a product attribute programmatically
Two supported paths. The first is the revert() method you already saw: it runs when you disable the module with the data flag, and it is why I implement PatchRevertableInterface on every attribute patch even when nobody plans to remove anything:
php bin/magento module:uninstall Panth_CatalogAttributes
# or for app/code modules without composer packaging:
php bin/magento module:disable Panth_CatalogAttributes
php bin/magento setup:upgrade
The second path is a dedicated removal patch, for when the attribute must go but the module stays. Same class shape, and apply() is one call:
public function apply(): void
{
$eavSetup = $this->eavSetupFactory->create(['setup' => $this->moduleDataSetup]);
$eavSetup->removeAttribute(\Magento\Catalog\Model\Product::ENTITY, 'warranty_info');
}
removeAttribute() deletes the attribute row and cascades to its values in the backend tables. It does not clean up hardcoded references in templates or import profiles, so grep the codebase before you ship it. I have seen a removal patch take a category page down because a third party extension read the attribute without checking for null.
The patch_list trap: my edited patch never re-ran on live
Real story. A client asked me to move metal_finish into a different attribute group and bump its sort order. I edited the patch, deployed, ran setup:upgrade on live, cleared caches. Nothing changed. Deployed again, still nothing. The patch was fine; PHPStan was happy; the same edit worked on my local machine, which conveniently made it look like a live-only caching bug.
It was not a cache. My local database was freshly reinstalled, so the patch had never run there and executed with the new code. On live, the patch class name was already recorded in patch_list, and Magento skips any patch it has already applied. Editing the file changes nothing: the unit of idempotency is the class name, not the file contents.
mysql> SELECT patch_id, patch_name FROM patch_list
WHERE patch_name LIKE '%AddMetalFinishAttribute%';
mysql> DELETE FROM patch_list
WHERE patch_name LIKE '%AddMetalFinishAttribute%';
php bin/magento module:status Panth_CatalogAttributes
php bin/magento setup:upgrade
After deleting the row, setup:upgrade treats the patch as new and runs it. Two warnings before you copy that DELETE statement. First, make sure the patch is safe to run twice: addAttribute() updates an existing attribute with the same code rather than failing, which is why this trick works for attribute patches, but a patch that inserts CMS blocks would duplicate them. Second, on a store where you cannot touch the database, the cleaner fix is a new patch class that declares the old one in getDependencies() and applies only the delta. I wrote up the whole mechanism in what setup:upgrade actually does if you want the internals.
Rule I now follow: a data patch is immutable once it has been applied on any shared environment. Need a change? New patch class, dependency on the old one. Editing patches is for local development only, paired with a patch_list delete.
Showing the attribute on a Hyvä PDP
On Hyvä there is no Knockout and no attribute UI component to fight with. Grab the product from the CurrentProduct view model in your PDP template override and print the value with Tailwind utility classes:
<?php
/** @var \Hyva\Theme\ViewModel\ProductPage $productViewModel */
$product = $productViewModel->getProduct();
$finish = $product->getAttributeText('metal_finish'); // label, not option ID
?>
<?php if ($finish): ?>
<div class="mt-4 flex items-center gap-2 text-sm text-gray-700">
<span class="font-semibold">Finish:</span> <?= $escaper->escapeHtml($finish) ?>
</div>
<?php endif; ?>
Two Hyvä specifics: use getAttributeText() for select and multiselect attributes so you render the label instead of the option ID, and remember that used_in_product_listing => true from the patch is what makes the same call work in listing card templates without extra loading. No mixins, no x-data needed for a static value.
This patch pattern is the foundation of almost every extension I build for clients, from shipping restrictions to product badges. If you would rather hand the whole module off, my Magento extension development service covers exactly this kind of work at a fixed hourly rate.
Frequently asked questions
How do I add a product attribute programmatically in Magento 2 without InstallData?
Create a class in your module's Setup/Patch/Data directory that implements DataPatchInterface, inject EavSetupFactory and ModuleDataSetupInterface through the constructor, and call addAttribute() inside apply(). Run bin/magento setup:upgrade and Magento executes the patch once, recording it in the patch_list table. InstallData and UpgradeData still execute on 2.4.x but have been deprecated since Magento 2.3.
Can I create EAV attributes programmatically for customers and categories too?
Yes, the same data patch works for any EAV entity. Swap the first argument of addAttribute(): use Category::ENTITY for category attributes, or the customer and customer_address entity codes for customer attributes (customer attributes also need attribute set and form assignment via the customer setup class). The array keys are largely identical across entities.
How do I add a dropdown attribute with options programmatically?
Set input to select, type to int, source to the Table source model, and pass your options as 'option' => ['values' => ['Option A', 'Option B']]. Magento inserts the options into eav_attribute_option and stores the selected option ID as the product value. Use getAttributeText() in templates to render the label.
How do I add an attribute to a specific attribute set programmatically?
Passing a 'group' key in addAttribute() assigns the attribute to that group in every existing attribute set. For one specific set, resolve the set ID with getAttributeSetId() and call addAttributeToSet() with the entity type ID, set ID, group name, attribute code, and sort order. Sets created later will not pick the attribute up automatically unless they inherit it from Default.
How do I remove a product attribute programmatically in Magento 2?
Either implement PatchRevertableInterface with a revert() method that calls removeAttribute(), which runs on module:uninstall, or ship a dedicated removal patch whose apply() calls removeAttribute() directly. The call deletes the attribute and its stored values, so check templates and import profiles for hardcoded references first, then reindex.
Need custom attributes, without the patch_list surprises? I build Magento 2 and Hyvä extensions for a living: data patches, EAV work, admin grids, the lot, all code-reviewed and revertable. Fixed fee from $499 audit · $2,499 sprint · ~4h @ $25/hr for a scoped attribute module. Tell me what the attribute needs to do and I will quote it straight.
Get a fixed quote