← Back to blog

Create a Product Attribute in Magento 2 with a Data Patch

Rodrigo Biazzi

Product attributes can be created in the admin in a minute, so why write code for them? Because an attribute created by hand on production does not exist on staging, on a developer's machine or in your CI database. If your module reads that attribute, it needs to create it. In Magento 2.3 and later the way to do that is a data patch. This guide shows how to write one for Magento Open Source and Adobe Commerce 2.4.8, with the options that matter and the mistakes that show up most often.

How data patches work

A data patch is a class in Setup/Patch/Data that implements Magento\Framework\Setup\Patch\DataPatchInterface. During bin/magento setup:upgrade, Magento runs every patch whose class name is not yet in the patch_list table, then records it there. A patch runs once per database. There is no version number to bump, as with the old InstallData and UpgradeData scripts.

A patch declares other patches it depends on with getDependencies(), and older names it replaces with getAliases(). For the bigger picture on schema and data setup, see the declarative schema and data patches article.

A text attribute

<?php
declare(strict_types=1);

namespace Acme\Catalog\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 AddManufacturerPartNumberAttribute implements DataPatchInterface, PatchRevertableInterface
{
    private const ATTRIBUTE_CODE = 'manufacturer_part_number';

    public function __construct(
        private readonly ModuleDataSetupInterface $moduleDataSetup,
        private readonly EavSetupFactory $eavSetupFactory
    ) {
    }

    public function apply(): self
    {
        $eavSetup = $this->eavSetupFactory->create(['setup' => $this->moduleDataSetup]);

        $eavSetup->addAttribute(Product::ENTITY, self::ATTRIBUTE_CODE, [
            'type' => 'varchar',
            'label' => 'Manufacturer Part Number',
            'input' => 'text',
            'required' => false,
            'global' => ScopedAttributeInterface::SCOPE_GLOBAL,
            'group' => 'General',
            'sort_order' => 50,
            'visible' => true,
            'user_defined' => true,
            'searchable' => true,
            'filterable' => false,
            'comparable' => false,
            'visible_on_front' => true,
            'used_in_product_listing' => false,
            'is_used_in_grid' => true,
            'is_visible_in_grid' => false,
            'is_filterable_in_grid' => true,
            'unique' => false,
        ]);

        return $this;
    }

    public function revert(): void
    {
        $eavSetup = $this->eavSetupFactory->create(['setup' => $this->moduleDataSetup]);
        $eavSetup->removeAttribute(Product::ENTITY, self::ATTRIBUTE_CODE);
    }

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

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

Run bin/magento setup:upgrade and the attribute appears in the General group of every product attribute set.

One detail to know before the first deploy: if an attribute with the same code already exists, addAttribute() updates it with the settings from your patch instead of failing. That is convenient when an attribute was first created by hand on production and you now want it in code. It also means your patch will overwrite whatever an admin changed. Compare the production settings with the patch before it ships.

The options that matter

The array passed to addAttribute() uses short keys that the catalog setup maps to columns in eav_attribute and catalog_eav_attribute. The ones worth thinking about each time:

  • type sets the backend table: varchar, int, decimal, text, datetime or static. Select attributes store option IDs, so they use int. Multiselect stores a comma separated list and uses varchar or text.
  • input is the admin field: text, textarea, select, multiselect, boolean, date, price and a few others.
  • global is the scope. SCOPE_GLOBAL, SCOPE_WEBSITE or SCOPE_STORE. Use global unless the value really differs per store view. Store scoped attributes multiply rows in the value tables and make indexing slower.
  • group puts the attribute in that group of every attribute set. Leave it out if you want to add the attribute to specific sets only.
  • user_defined controls whether admins can delete the attribute. Set it to true for normal custom attributes.
  • searchable, filterable and filterable_in_search affect the search index and layered navigation. Only enable them when needed, because each one adds data to OpenSearch and work to reindexing.
  • used_in_product_listing adds the attribute to the data loaded on category pages. It is easy to switch on and forget, and every enabled attribute makes listing queries heavier.
  • apply_to limits the attribute to product types, as a comma separated string like simple,virtual. Empty means all types.

More on the performance side of these flags in the product attributes best practices article.

A select attribute with options

For a dropdown with a fixed list that admins should be able to edit later, let Magento store the options in eav_attribute_option:

$eavSetup->addAttribute(Product::ENTITY, 'material', [
    'type' => 'int',
    'label' => 'Material',
    'input' => 'select',
    'source' => \Magento\Eav\Model\Entity\Attribute\Source\Table::class,
    'required' => false,
    'global' => ScopedAttributeInterface::SCOPE_GLOBAL,
    'group' => 'General',
    'user_defined' => true,
    'filterable' => 1,
    'searchable' => true,
    'visible_on_front' => true,
    'option' => [
        'values' => ['Cotton', 'Linen', 'Wool', 'Polyester'],
    ],
]);

The option key creates admin level labels for each value. Store view labels can be added later in the admin or in another patch. Because options are stored as data, admins can add new values without a deploy, which is usually what the business wants.

A select attribute with a source model

When the options come from code or from another system, write a source model and point source at it:

<?php
declare(strict_types=1);

namespace Acme\Catalog\Model\Attribute\Source;

use Magento\Eav\Model\Entity\Attribute\Source\AbstractSource;

class WarrantyPeriod extends AbstractSource
{
    public function getAllOptions(): array
    {
        if ($this->_options === null) {
            $this->_options = [
                ['value' => '', 'label' => __('-- Please Select --')],
                ['value' => 12, 'label' => __('12 months')],
                ['value' => 24, 'label' => __('24 months')],
                ['value' => 36, 'label' => __('36 months')],
            ];
        }
        return $this->_options;
    }
}

Options defined this way cannot be edited in the admin, and changing a value later means migrating stored data. Use it for lists that are part of the business logic, not for marketing values like colors or materials.

If the module that owns the source model is ever removed, the attribute still points to a class that no longer exists, and the product edit page breaks. Remove the attribute or switch its source before uninstalling, as explained in the article on removing a module safely.

Adding to specific attribute sets

With user_defined set to true and no group, the attribute is created but not assigned to any attribute set. To add it to one set:

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

$eavSetup->addAttributeToGroup($entityTypeId, $attributeSetId, $groupId, 'material', 60);

Attribute set names differ between projects. If the set might not exist, check first and skip, so the patch does not fail on a fresh install.

Updating an existing attribute

Never edit a patch that has already run. It will not run again on databases that recorded it, so environments drift apart. Write a new patch instead and use updateAttribute():

$eavSetup->updateAttribute(Product::ENTITY, 'material', 'is_filterable_in_search', 1);
$eavSetup->updateAttribute(Product::ENTITY, 'material', 'used_in_product_listing', 0);

Declare the original patch in getDependencies() so the order is guaranteed.

Common mistakes

  • Forgetting the source on a select attribute. The field appears without options, or the frontend shows option IDs instead of labels.
  • Using varchar for values that will be filtered or sorted as numbers. Choose the backend type for how the data is queried.
  • Store scope for everything by default. Most attributes are global in practice.
  • Deleting a row from patch_list to rerun a patch on production. That works on a local database for testing, but on shared environments it hides the real problem, which is that the change belongs in a new patch.
  • Running a reindex and cache flush afterwards is not optional if the attribute is searchable or filterable. Run bin/magento indexer:reindex catalogsearch_fulltext and the EAV indexers, or let cron pick them up if indexers are on schedule.

FAQ

How do I run a data patch again?

On a local database you can delete its row from patch_list and run setup:upgrade. For real changes, write a new patch, because the original will not run again on environments that already applied it.

What is the difference between global, website and store scope?

Global stores one value for all stores. Website and store scope allow different values per website or store view, at the cost of more rows and slower indexing. Use the smallest scope you actually need.

Does revert run automatically when I disable the module?

No. Revert runs when the module is uninstalled with bin/magento module:uninstall, which only works for Composer installed modules. Disabling a module does not remove its attributes.

Why does my new attribute not show on the frontend?

Check visible_on_front, used_in_product_listing for category pages, the attribute set assignment, and that the theme template actually renders it. Then reindex and clean the cache.

I am available for freelance Magento and Adobe Commerce work. If you need help with catalog structure or EAV attributes, get in touch.

Related articles