Creating attributes in the admin works for one store, but it is not repeatable: staging, production and every developer machine end up different. A data patch creates the attribute in code, runs once per environment during setup:upgrade, and is tracked in the patch_list table so it never runs twice.

We will create two attributes in a module called MageServices_Catalog: a text attribute for a manufacturer part number and a dropdown attribute for warranty length that can be used in layered navigation.

Step 1: The Data Patch Class

Data patches live in Setup/Patch/Data and implement DataPatchInterface. Implementing PatchRevertableInterface as well lets you remove the attributes when the module is uninstalled.

Setup/Patch/Data/AddPartNumberAndWarrantyAttributes.php
<?php
declare(strict_types=1);

namespace MageServices\Catalog\Setup\Patch\Data;

use Magento\Catalog\Model\Product;
use Magento\Catalog\Model\ResourceModel\Eav\Attribute;
use Magento\Eav\Model\Entity\Attribute\ScopedAttributeInterface;
use Magento\Eav\Model\Entity\Attribute\Source\Table;
use Magento\Eav\Setup\EavSetup;
use Magento\Eav\Setup\EavSetupFactory;
use Magento\Framework\Setup\ModuleDataSetupInterface;
use Magento\Framework\Setup\Patch\DataPatchInterface;
use Magento\Framework\Setup\Patch\PatchRevertableInterface;

class AddPartNumberAndWarrantyAttributes implements DataPatchInterface, PatchRevertableInterface
{
    public function __construct(
        private readonly ModuleDataSetupInterface $moduleDataSetup,
        private readonly EavSetupFactory $eavSetupFactory
    ) {
    }

    public function apply(): self
    {
        $this->moduleDataSetup->getConnection()->startSetup();

        /** @var EavSetup $eavSetup */
        $eavSetup = $this->eavSetupFactory->create(['setup' => $this->moduleDataSetup]);

        $eavSetup->addAttribute(Product::ENTITY, 'mpn', [
            'type' => 'varchar',
            'label' => 'Manufacturer Part Number',
            'input' => 'text',
            'global' => ScopedAttributeInterface::SCOPE_GLOBAL,
            'required' => false,
            'user_defined' => true,
            'unique' => false,
            'searchable' => true,
            'visible_in_advanced_search' => false,
            'filterable' => false,
            'comparable' => false,
            'visible_on_front' => true,
            'used_in_product_listing' => false,
            'is_html_allowed_on_front' => false,
            'group' => 'General',
            'sort_order' => 60,
        ]);

        $eavSetup->addAttribute(Product::ENTITY, 'warranty_months', [
            'type' => 'int',
            'label' => 'Warranty',
            'input' => 'select',
            'source' => Table::class,
            'global' => ScopedAttributeInterface::SCOPE_GLOBAL,
            'required' => false,
            'user_defined' => true,
            'searchable' => false,
            'filterable' => 1,
            'filterable_in_search' => true,
            'comparable' => true,
            'visible_on_front' => true,
            'used_in_product_listing' => true,
            'is_used_in_grid' => true,
            'is_filterable_in_grid' => true,
            'group' => 'General',
            'sort_order' => 70,
            'option' => [
                'values' => ['12 months', '24 months', '36 months', '60 months'],
            ],
        ]);

        $this->moduleDataSetup->getConnection()->endSetup();

        return $this;
    }

    public function revert(): void
    {
        $this->moduleDataSetup->getConnection()->startSetup();

        /** @var EavSetup $eavSetup */
        $eavSetup = $this->eavSetupFactory->create(['setup' => $this->moduleDataSetup]);
        $eavSetup->removeAttribute(Product::ENTITY, 'mpn');
        $eavSetup->removeAttribute(Product::ENTITY, 'warranty_months');

        $this->moduleDataSetup->getConnection()->endSetup();
    }

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

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

Step 2: Run the Patch

bin/magento setup:upgrade
bin/magento indexer:reindex catalog_product_attribute catalogsearch_fulltext
bin/magento cache:flush

Check that the patch ran by looking for its class name in the patch_list table, then open any product in the admin: both attributes appear in the General group of every attribute set.

What the Key Options Mean

OptionEffect
typeBackend table: varchar, int, decimal, text or datetime. Dropdowns store the option ID, so use int.
inputAdmin input type: text, textarea, select, multiselect, boolean, price, date.
globalScope: global, website or store view. Use store view only for values that really differ per language.
groupAdds the attribute to this group in every attribute set. Omit it to add the attribute to sets yourself.
filterable1 = filterable with results, 2 = filterable without results. Needed for layered navigation.
used_in_product_listingLoads the value in category listings. Leave it off unless you display it there — it adds to collection size.
visible_on_frontShows the attribute in the "More Information" tab on product pages.
sourceSource model for options. Table stores options in eav_attribute_option.

Adding an Attribute to Specific Attribute Sets Only

If the attribute only applies to some products, leave out group and assign it explicitly:

$entityTypeId = $eavSetup->getEntityTypeId(Product::ENTITY);
$attributeSetId = $eavSetup->getAttributeSetId($entityTypeId, 'Electronics');
$groupId = $eavSetup->getAttributeGroupId($entityTypeId, $attributeSetId, 'General');

$eavSetup->addAttributeToGroup($entityTypeId, $attributeSetId, $groupId, 'warranty_months', 70);

Changing an Existing Attribute

Never edit a patch that has already run — it will not run again. Create a new patch and use updateAttribute():

$eavSetup->updateAttribute(Product::ENTITY, 'mpn', 'is_searchable', 1);
$eavSetup->updateAttribute(Product::ENTITY, 'mpn', 'frontend_label', 'MPN');

Showing the Attribute on Hyvä Product Pages

With visible_on_front enabled, the attribute appears automatically in the product attributes section of both Luma and Hyvä. To show it elsewhere, read it in a template through the product and escape it:

<?php
/** @var \Magento\Framework\Escaper $escaper */
/** @var \Magento\Catalog\Model\Product $product */
$warranty = $product->getAttributeText('warranty_months');
?>
<?php if ($warranty): ?>
    <p class="text-sm text-gray-700">
        <?= $escaper->escapeHtml(__('Warranty: %1', $warranty)) ?>
    </p>
<?php endif; ?>
In Hyvä, get the current product with the CurrentProduct view model: $viewModels->require(\Hyva\Theme\ViewModel\CurrentProduct::class)->get().

Troubleshooting

  • Patch did not run — the class name is already in patch_list. Create a new patch instead of editing the old one.
  • Attribute missing in layered navigation — check filterable, that the attribute is in the products' attribute sets, and reindex.
  • Options in the wrong order — pass 'order' alongside 'value' in the option array, or reorder them in the admin.

Frequently Asked Questions

Should I use InstallData or a data patch?

Use a data patch. InstallData and UpgradeData scripts are the old approach; data patches have been the standard since Magento 2.3.

How do I re-run a data patch?

Patches are recorded in the patch_list table. On a development machine you can delete the row and run setup:upgrade again; in production, write a new patch.

Can I create a multiselect attribute the same way?

Yes. Use type varchar, input multiselect, the Table source model and the ArrayBackend backend model.

🚀

Need help with this on your store?

Our Magento engineers can implement it for you, review your code or take on the whole project.

Explore Magento Extension Development
MS
About the author

Written by the Magento Services engineering team — Magento 2, Adobe Commerce and Hyvä specialists since 2014. We write about problems we solve on real client stores.