← Back to blog

How to Create a Custom Product Type in Magento 2

Rodrigo Biazzi

Magento 2 ships with simple, virtual, configurable, grouped, bundle and downloadable products, and Adobe Commerce adds gift cards. Every so often a project seems to need a new type: a service plan, a rental, a subscription box, a made to order item. Sometimes that is the right call. More often a simple or virtual product with a few attributes and some custom logic would have done the job with much less risk. This article covers both sides, for Magento Open Source and Adobe Commerce 2.4.8.

Do you really need a new type?

A product type changes how Magento treats a product everywhere: admin forms, cart, quote items, price indexing, stock, search, sales documents and every extension that checks the type code. Before creating one, check whether these simpler options cover the requirement:

  • A simple or virtual product with an attribute, such as is_service_plan, and plugins that change behavior when it is set. This keeps all core and third party support intact.
  • Custom options for small per order inputs like an engraving text.
  • Bundle or grouped products for sets of items.
  • Downloadable products for anything delivered as a file.

A custom type makes sense when the product's behavior is different in several places at once, for example a different price calculation, a different add to cart flow and different fulfilment, and when you need the type to be visible to integrations as a distinct thing. If only one of those is true, an attribute plus a plugin is usually the better design.

Declaring the type

A type is declared in etc/product_types.xml. This example creates a service_plan type that behaves like a virtual product with its own price model:

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Catalog:etc/product_types.xsd">
    <type name="service_plan"
          label="Service Plan"
          modelInstance="Acme\ServicePlan\Model\Product\Type\ServicePlan"
          indexPriority="80"
          sortOrder="80"
          isQty="true">
        <priceModel instance="Acme\ServicePlan\Model\Product\Type\ServicePlan\Price"/>
        <customAttributes>
            <attribute name="refundable" value="true"/>
            <attribute name="is_real_product" value="true"/>
        </customAttributes>
    </type>
</config>
  • name is the type code stored in catalog_product_entity.type_id. Choose it carefully, because changing it later means a data migration.
  • label is what admins see in the Add Product menu.
  • isQty decides whether the type has a stock quantity. With MSI, it also decides whether source items can be managed for the type.
  • customAttributes are flags read by other parts of the system. The simplest approach is to open vendor/magento/module-catalog/etc/product_types.xml, find the core type closest to yours, and copy its flags.

The same file can declare a price indexer with an indexerModel node, and a composableTypes section controls which types can be used as children of bundle and grouped products. Look at how core declares simple and virtual in the same file and follow that pattern rather than inventing your own indexer.

The type model

The type model is where type specific behavior lives. Extending the closest core type saves a lot of work:

<?php
declare(strict_types=1);

namespace Acme\ServicePlan\Model\Product\Type;

use Magento\Catalog\Model\Product\Type\Virtual;

class ServicePlan extends Virtual
{
    public const TYPE_CODE = 'service_plan';
}

That empty class already works. It inherits isVirtual(), so quotes with only service plans skip the shipping step, and it inherits the add to cart preparation from AbstractType.

The methods you are most likely to override:

  • _prepareProduct() to validate or transform the buy request when the product is added to the cart. Return a string to show an error, or an array of products on success.
  • getOrderOptions() to store extra information on the order item, so it appears in the admin and in emails.
  • isSalable() if availability depends on something other than stock and status.
  • hasWeight(), which Virtual already returns false for.

Keep the type model thin. Put business logic in services and call them from the type model, so it can be tested without building full product objects.

The price model

<?php
declare(strict_types=1);

namespace Acme\ServicePlan\Model\Product\Type\ServicePlan;

use Magento\Catalog\Model\Product;
use Magento\Catalog\Model\Product\Type\Price as BasePrice;

class Price extends BasePrice
{
    /**
     * @param float|null $qty
     * @param Product $product
     * @return float
     */
    public function getFinalPrice($qty, $product)
    {
        $finalPrice = (float) parent::getFinalPrice($qty, $product);
        return max(0.0, $finalPrice);
    }
}

Know where this price is used. The price model is called when a product is loaded and when quote items are totaled. Category pages, search results and sorting by price read from the price index, which is built by SQL in the indexer and does not call your PHP price model. If your type calculates price differently, the listing price and the cart price will disagree unless the indexer does the same calculation. This is the part of a custom type that most often goes wrong.

Making core attributes apply to the new type

Core attributes like price and tax_class_id have an apply_to list of product types. A new type is not in that list, so the admin form will not show a price field. Fix it with a data patch:

<?php
declare(strict_types=1);

namespace Acme\ServicePlan\Setup\Patch\Data;

use Acme\ServicePlan\Model\Product\Type\ServicePlan;
use Magento\Catalog\Model\Product;
use Magento\Eav\Setup\EavSetupFactory;
use Magento\Framework\Setup\ModuleDataSetupInterface;
use Magento\Framework\Setup\Patch\DataPatchInterface;

class ApplyPriceAttributesToServicePlan implements DataPatchInterface
{
    private const ATTRIBUTES = [
        'price',
        'special_price',
        'special_from_date',
        'special_to_date',
        'cost',
        'tier_price',
        'tax_class_id',
    ];

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

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

        foreach (self::ATTRIBUTES as $code) {
            $applyTo = (string) $eavSetup->getAttribute(Product::ENTITY, $code, 'apply_to');
            if ($applyTo === '') {
                continue;
            }
            $types = explode(',', $applyTo);
            if (!in_array(ServicePlan::TYPE_CODE, $types, true)) {
                $types[] = ServicePlan::TYPE_CODE;
                $eavSetup->updateAttribute(Product::ENTITY, $code, 'apply_to', implode(',', $types));
            }
        }

        return $this;
    }

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

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

An empty apply_to means the attribute already applies to all types, which is why those are skipped. Attributes from your own modules should follow the same rules, as described in the product attribute data patch article.

Frontend and admin

On the product page, Magento adds a layout handle per type: catalog_product_view_type_service_plan. Create a layout file with that name to add or remove blocks only for your type, without touching other products.

In the admin, the new type appears in the Add Product split button automatically. Type specific fields or panels in the product form are added through form modifiers registered in the Magento\Catalog\Ui\DataProvider\Product\Form\Modifier\Pool virtual type, which is the same mechanism core uses for configurable and bundle panels. Check the product type inside the modifier and return the meta unchanged for other types.

Inventory, indexing and integrations

  • Inventory. With isQty="true", MSI lets you assign sources and quantities to the type. Test salable quantity, reservations and stock indexing with your type before launch. The MSI article covers how those pieces fit together.
  • Price indexing. Run a full reindex and check catalog_product_index_price for your products. If they are missing, they will not appear in category listings with prices or in price filters.
  • GraphQL. The products query resolves each item to a concrete GraphQL type based on the product type. A new type needs its own GraphQL type implementing ProductInterface and a type resolver registered in the catalog GraphQL configuration, otherwise queries that return it fail. The pattern is similar to the one in the custom GraphQL resolver article.
  • Third party extensions. Feeds, ERP connectors, search modules and checkout extensions often have lists of known type codes. Audit the ones installed on the project for hard coded type checks.

FAQ

Should I extend AbstractType or an existing type model?

Extend the closest existing type, usually Simple or Virtual. AbstractType forces you to implement and test much more, and you rarely need that freedom.

Why does my custom type not show a price field in the admin?

The price attributes have an apply_to list that does not include your type code. Add it with a data patch, or set apply_to to empty if the attribute should apply to every type.

Why is the category page price different from the cart price?

Listings read from the price index, while the cart uses the price model. If your type changes the price calculation, the price indexer has to apply the same rules.

Can I change an existing product's type later?

Not safely through the admin. It requires updating type_id and cleaning type specific data, then reindexing. Choose the type and its code before importing products.

I am available for freelance Magento and Adobe Commerce work. If you are deciding between a custom product type and a simpler design, get in touch and I can help you weigh it.

Related articles