How to Create a Custom Product Type in Magento 2
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>
nameis the type code stored incatalog_product_entity.type_id. Choose it carefully, because changing it later means a data migration.labelis what admins see in the Add Product menu.isQtydecides whether the type has a stock quantity. With MSI, it also decides whether source items can be managed for the type.customAttributesare flags read by other parts of the system. The simplest approach is to openvendor/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(), whichVirtualalready 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_pricefor your products. If they are missing, they will not appear in category listings with prices or in price filters. - GraphQL. The
productsquery resolves each item to a concrete GraphQL type based on the product type. A new type needs its own GraphQL type implementingProductInterfaceand 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
How to Add a Custom Layered Navigation Filter in Magento 2
Add a custom filter to Magento 2 layered navigation: when an attribute is enough, how FilterList works, and a custom filter class backed by OpenSearch facets.
Magento 2 Cache Types Explained: Clean, Flush, Custom
What each Magento 2 cache type stores, the real difference between cache:clean and cache:flush, invalidated caches, and how to add your own cache type.
Magento 2 Cart Price Rules: Add a Custom Condition
Add a custom condition to Magento 2 cart price rules: the combine event, a condition model with validate, promo rule attributes and performance traps to avoid.
Magento 2 Configurable Products Performance: What to Fix
Why configurable products slow down Magento 2 pages, admin saves and indexers, and how to fix it: variation counts, swatches, price indexing and data modeling.