Verified on Shopware 6.7

Shopware's Custom Products plugin lets merchants configure product options - engraving text, material choices, file uploads. It's useful, but it requires the Rise plan, and more importantly, it solves a different problem than the one described here.

This article is about products that are a fundamentally different kind of thing from regular products - virtual goods, gift cards, digital licences, subscription tokens. Products where you need to branch on type in templates, handle them differently in the cart, and potentially give them a completely separate fulfillment path.

The solution is a two-table registry plugin. Part 1 covers the complete standalone implementation - enough to tag products with a type and detect that type anywhere in Shopware's stack: PHP services, Twig templates, cart, order line items. That alone is useful for a wide range of simpler requirements.

Part 2 covers how a second plugin can build on the registry to implement a fully-featured custom type with its own data schema, admin UI, and order lifecycle - using a gift card fulfillment integration as a concrete example.


Part 1 - The Registry Plugin

What It Provides

  • A product_custom_type table holding type definitions (gift-card, digital-licence, etc.)
  • A product_custom_type_assigned table linking products to types (one type per product)
  • Entity extensions on ProductEntity and OrderLineItemEntity so type data is accessible anywhere the product or order line item is loaded
  • A ProductLineItemFactory decorator that carries type info through the cart automatically
  • An admin product list override that adds "Add X" menu items for each active type
  • A Pinia store and product detail override that shows the type badge and disables incompatible actions

After installing the registry plugin, you can detect a product's type in Twig, PHP, and the cart with no additional code. If that is all you need, you are done.


Database

Two tables. First: type definitions.

CREATE TABLE `product_custom_type` (
    `id`              BINARY(16)   NOT NULL,
    `name`            VARCHAR(255),           -- display name: "Gift Card"
    `type`            VARCHAR(255),           -- slug: "gift-card"
    `active`          TINYINT(1)   DEFAULT 1,
    `digital_product` TINYINT(1)   DEFAULT 0, -- pass 'is-download' state on creation
    `created_at`      DATETIME(3)  NOT NULL,
    `updated_at`      DATETIME(3),
    PRIMARY KEY (`id`),
    UNIQUE KEY `uniq_type` (`type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

Second: the assignment join table.

CREATE TABLE `product_custom_type_assigned` (
    `id`                     BINARY(16)  NOT NULL,
    `product_id`             BINARY(16)  NOT NULL,
    `product_custom_type_id` BINARY(16)  NOT NULL,
    `created_at`             DATETIME(3) NOT NULL,
    `updated_at`             DATETIME(3),
    PRIMARY KEY (`id`),
    UNIQUE KEY `uniq_product_assignment` (`product_id`, `product_custom_type_id`),
    FOREIGN KEY (`product_id`)
        REFERENCES `product`(`id`) ON DELETE CASCADE,
    FOREIGN KEY (`product_custom_type_id`)
        REFERENCES `product_custom_type`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

The unique constraint on (product_id, product_custom_type_id) enforces one type per product. Cascade deletes keep the assignment table clean automatically.


Entity Definitions

Standard DAL definitions for both tables:

class ProductCustomTypeDefinition extends EntityDefinition
{
    public const ENTITY_NAME = 'product_custom_type';

    protected function defineFields(): FieldCollection
    {
        return new FieldCollection([
            (new IdField('id', 'id'))->addFlags(new Required(), new PrimaryKey()),
            (new StringField('name', 'name'))->addFlags(new ApiAware()),
            (new StringField('type', 'type'))->addFlags(new ApiAware(), new Required()),
            (new BoolField('active', 'active'))->addFlags(new ApiAware(), new Required()),
            (new BoolField('digital_product', 'digitalProduct'))->addFlags(new ApiAware()),
        ]);
    }

    public function getDefaults(): array
    {
        return ['active' => 1, 'digitalProduct' => 0];
    }
}
class ProductExtensionDefinition extends EntityDefinition
{
    public const ENTITY_NAME = 'product_custom_type_assigned';

    protected function defineFields(): FieldCollection
    {
        return new FieldCollection([
            (new IdField('id', 'id'))->addFlags(new Required(), new PrimaryKey(), new ApiAware()),
            new FkField('product_id', 'productId', ProductDefinition::class),
            new FkField('product_custom_type_id', 'productCustomTypeId', ProductCustomTypeDefinition::class),
            (new OneToOneAssociationField(
                'productCustomTypeEntity',
                'product_custom_type_id',
                'id',
                ProductCustomTypeDefinition::class,
                true
            ))->addFlags(new ApiAware()),
        ]);
    }
}

Register both in entities.xml:

<service id="Acme\ProductCustomTypes\ProductCustomTypeDefinition">
    <tag name="shopware.entity.definition" entity="product_custom_type"/>
</service>
<service id="Acme\ProductCustomTypes\ProductExtensionDefinition">
    <tag name="shopware.entity.definition" entity="product_custom_type_assigned"/>
</service>

Entity Extensions

These add a productCustomType field to ProductEntity and OrderLineItemEntity without modifying any core class.

class ProductExtension extends EntityExtension
{
    public function extendFields(FieldCollection $collection): void
    {
        $collection->add(
            (new OneToOneAssociationField(
                'productCustomType',       // field name on ProductEntity
                'id',                      // product.id
                'product_id',              // product_custom_type_assigned.product_id
                ProductExtensionDefinition::class,
                true
            ))->addFlags(new ApiAware())
        );
    }

    public function getEntityName(): string
    {
        return ProductDefinition::ENTITY_NAME;
    }
}
class OrderLineItemExtension extends EntityExtension
{
    public function extendFields(FieldCollection $collection): void
    {
        $collection->add(
            (new OneToOneAssociationField(
                'productCustomType',
                'product_id',             // order_line_item.product_id
                'product_id',             // product_custom_type_assigned.product_id
                ProductExtensionDefinition::class,
                true
            ))->addFlags(new ApiAware())
        );
    }

    public function getEntityName(): string
    {
        return OrderLineItemDefinition::ENTITY_NAME;
    }
}

Register both in entity-extensions.xml:

<service id="Acme\ProductCustomTypes\ProductExtension">
    <tag name="shopware.entity.extension"/>
</service>
<service id="Acme\ProductCustomTypes\OrderLineItemExtension">
    <tag name="shopware.entity.extension"/>
</service>

Carrying Type Info Through the Cart

Shopware's ProductLineItemFactory::update() builds the LineItem when a product is added to the cart. Decorating it copies the product's type assignment onto the line item so it travels through checkout into the order:

class ProductLineItemFactory extends DecoratedProductLineItemFactory
{
    public function __construct(
        PriceDefinitionFactory $priceDefinitionFactory,
        EntityRepository $productRepository,
        DecoratedProductLineItemFactory $decorated
    ) {
        parent::__construct($priceDefinitionFactory);
        $this->productRepository = $productRepository;
        $this->decorated = $decorated;
    }

    public function update(LineItem $lineItem, array $data, SalesChannelContext $context): void
    {
        $this->decorated->update($lineItem, $data, $context);

        if ($data['type'] !== 'product') {
            return;
        }

        $criteria = new Criteria([$data['id']]);
        $criteria->addAssociation('productCustomType.productCustomTypeEntity');

        $product = $this->productRepository
            ->search($criteria, $context->getContext())
            ->first();

        if ($extension = $product?->getExtension('productCustomType')) {
            $lineItem->addExtension('productCustomType', $extension);
        }
    }
}

Wire as a decorator:

<service id="Acme\ProductCustomTypes\Decorator\ProductLineItemFactory"
         decorates="Shopware\Core\Checkout\Cart\LineItemFactoryHandler\ProductLineItemFactory">
    <argument type="service" id="Shopware\Core\Checkout\Cart\PriceDefinitionFactory"/>
    <argument type="service" id="product.repository"/>
    <argument type="service"
              id="Acme\ProductCustomTypes\Decorator\ProductLineItemFactory.inner"/>
</service>

Detecting the Type in PHP

Wherever you have a ProductEntity, load the association explicitly with criteria before accessing it:

$criteria = new Criteria([$productId]);
$criteria->addAssociation('productCustomType.productCustomTypeEntity');

$product = $this->productRepository->search($criteria, $context)->first();

$type = $product
    ->getExtension('productCustomType')
    ?->getExtension('productCustomTypeEntity')
    ?->getType();  // 'gift-card', 'digital-licence', or null

if ($type === 'gift-card') {
    // type-specific handling
}

Important: Without addAssociation('productCustomType.productCustomTypeEntity') in the criteria, the extension is present on the entity but its nested productCustomTypeEntity association will be null. The double-association path (productCustomType.productCustomTypeEntity) is required because the assignment record is a join entity, not the type definition itself.

From a LineItem in the cart (no additional query needed - the decorator already populated it):

$typeEntity = $lineItem
    ->getExtension('productCustomType')
    ?->getExtension('productCustomTypeEntity');

$type = $typeEntity?->getType();          // 'gift-card'
$name = $typeEntity?->getName();          // 'Gift Card'
$isDigital = $typeEntity?->isDigitalProduct(); // false

From an OrderLineItemEntity:

// Association must be loaded - add to criteria when fetching the order:
$criteria->addAssociation('lineItems.productCustomType.productCustomTypeEntity');

$type = $orderLineItem
    ->getExtension('productCustomType')
    ?->getExtension('productCustomTypeEntity')
    ?->getType();

In a subscriber that receives a CheckoutOrderPlacedEvent:

public function onOrderPlaced(CheckoutOrderPlacedEvent $event): void
{
    foreach ($event->getOrder()->getLineItems() as $lineItem) {
        $type = $lineItem
            ->getExtension('productCustomType')
            ?->getExtension('productCustomTypeEntity')
            ?->getType();

        if ($type === 'gift-card') {
            // handle gift card fulfillment
        }
    }
}

Detecting the Type in Twig

In storefront templates, Shopware auto-loads the product via SalesChannelProductLoader, which must include the association. The cleanest way to ensure it is available on the product detail page is to extend the criteria via a ProductPageCriteriaEvent subscriber:

public static function getSubscribedEvents(): array
{
    return [
        ProductPageCriteriaEvent::class => 'onProductPageCriteria',
    ];
}

public function onProductPageCriteria(ProductPageCriteriaEvent $event): void
{
    $event->getCriteria()->addAssociation(
        'productCustomType.productCustomTypeEntity'
    );
}

Once loaded, the type is accessible directly in the template:

{# product detail page - page.product is a SalesChannelProductEntity #}
{% set customType = page.product.extensions.productCustomType
    .productCustomTypeEntity.type ?? null %}

{% if customType == 'gift-card' %}
    {% sw_include '@AcmeTheme/storefront/product/type/gift-card.html.twig' %}
{% elseif customType == 'digital-licence' %}
    {% sw_include '@AcmeTheme/storefront/product/type/digital-licence.html.twig' %}
{% endif %}

Or more cleanly, use the type slug to resolve the template dynamically:

{% if customType %}
    {% sw_include '@AcmeTheme/storefront/product/type/' ~ customType ~ '.html.twig'
       ignore missing %}
{% endif %}

ignore missing prevents a Twig error if no template exists for that type yet.

For cart line items in the cart/checkout templates:

{% for lineItem in page.cart.lineItems %}
    {% set customType = lineItem.extensions.productCustomType
        .productCustomTypeEntity.type ?? null %}

    {% if customType %}
        {# render differently from standard products #}
        {% sw_include '@AcmeTheme/storefront/cart/line-item/' ~ customType ~ '.html.twig'
           ignore missing %}
    {% else %}
        {# standard line item rendering #}
        {% sw_include '@Storefront/storefront/component/line-item/line-item.html.twig' %}
    {% endif %}
{% endfor %}

In order confirmation emails or the account order history, the same path applies via OrderLineItemEntity.extensions.productCustomType.productCustomTypeEntity.type.


Admin UI

The registry plugin overrides sw-product-list to dynamically add creation shortcuts for each active type:

// In sw-product-list override, on created():
const repo = this.repositoryFactory.create('product_custom_type');
const criteria = new Criteria();
criteria.addFilter(Criteria.equals('active', 1));

repo.search(criteria).then(result => {
    this.productCustomTypes = result;
});

Template:

<sw-context-menu-item
    v-for="customType in productCustomTypes"
    :router-link="{
        name: 'sw.product.create',
        query: {
            creationStates: [customType.digitalProduct ? 'is-download' : 'is-physical'],
            productCustomType: customType.type
        }
    }"
>
    Add {{ customType.name }}
</sw-context-menu-item>

The sw-product-detail override reads the productCustomType query parameter, looks up the type record, creates the assignment entity in memory, and attaches it to product.extensions.productCustomType. A Pinia store (swProductCustomType) broadcasts the active type status to all other components on the page - useful for hiding or adjusting form sections that don't apply to this type.

The detail override also disables the native duplicate action for custom type products, replacing it with a custom duplication route that passes duplicateSource as a query parameter so the new product can pre-populate its fields from the source.


That Is Part 1

At this point the registry plugin is a complete solution. You can:

  • Define any number of product types in the database
  • Assign them to products through the admin
  • Detect the type in any PHP service via the extension chain
  • Detect the type in any Twig template with ?? null guards
  • Branch on type in cart subscribers, order subscribers, and email templates
  • Conditionally include type-specific sub-templates

No type-specific data model, no custom admin forms, no fulfillment logic - just a clean type tag on the product that propagates everywhere.


Part 2 - Building a Concrete Type

When a product type needs its own data fields, its own admin interface, and its own order lifecycle, a second plugin builds on the registry. The registry plugin does not need to change. The type plugin depends on it, installs one row into product_custom_type, and does everything else independently.

The example here is a gift card integration with an external fulfillment API.


Registering the Type

The type plugin's migration inserts a single row:

$this->connection->executeStatement("
    INSERT INTO product_custom_type (id, name, type, active, digital_product, created_at)
    VALUES (:id, 'Gift Card', 'gift-card', 1, 0, :now)
", ['id' => Uuid::randomBytes(), 'now' => (new \DateTime())->format('Y-m-d H:i:s.v')]);

After this, the registry plugin's product list override shows "Add Gift Card" automatically. Nothing else in the registry plugin needs to know this type exists.


Type-Specific Product Data

Gift cards require data the generic assignment record does not have: a brand identifier, a denomination, and a currency. The type plugin extends product_custom_type_assigned with its own table:

CREATE TABLE `product_custom_type_assigned_giftcard` (
    `id`                              BINARY(16)   NOT NULL,
    `product_custom_type_assigned_id` BINARY(16)   NOT NULL UNIQUE,
    `brand_slug`                      VARCHAR(255) NOT NULL,
    `brand_label`                     VARCHAR(255) NOT NULL,
    `amount`                          INT          NOT NULL,  -- in pence/cents
    `currency`                        VARCHAR(10)  NOT NULL,
    `created_at`                      DATETIME(3)  NOT NULL,
    `updated_at`                      DATETIME(3),
    PRIMARY KEY (`id`),
    UNIQUE KEY `uniq_brand_amount_currency` (`brand_slug`, `currency`, `amount`),
    FOREIGN KEY (`product_custom_type_assigned_id`)
        REFERENCES `product_custom_type_assigned`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

An EntityExtension on ProductExtensionDefinition adds a giftCardType association to the assignment entity:

class GiftCardTypeExtension extends EntityExtension
{
    public function extendFields(FieldCollection $collection): void
    {
        $collection->add(
            new OneToOneAssociationField(
                'giftCardType',
                'id',
                'product_custom_type_assigned_id',
                GiftCardTypeDefinition::class,
                true
            )
        );
    }

    public function getEntityName(): string
    {
        return ProductExtensionDefinition::ENTITY_NAME; // 'product_custom_type_assigned'
    }
}

The data is then accessible as a three-level chain:

$product
    ->getExtension('productCustomType')   // ProductExtensionEntity (assignment)
    ->getExtension('giftCardType')        // GiftCardTypeEntity
    ->getBrandSlug();                     // 'amazon-uk'

In Twig:

{% set giftCard = page.product.extensions.productCustomType.extensions.giftCardType %}
{{ giftCard.brandLabel }} - {{ giftCard.amount / 100 }} {{ giftCard.currency }}

The Type-Specific Admin Interface

The type plugin overrides sw-product-basic-form and activates only when the shared Pinia store signals type === 'gift-card':

computed: {
    isGiftCard() {
        return Shopware.Store.get('swProductCustomType')?.status?.type === 'gift-card';
    }
}

The form fetches available brands from the fulfillment API, lets the admin select brand and denomination, auto-generates the product number from the combination, and syncs the amount to Shopware's price field. On save it intercepts onSave, calls the API's validation endpoint, and only proceeds with the Shopware write if the denomination is confirmed available.


The Order Lifecycle

Before order creation - a decorator on CheckoutController::order() dispatches a pre-create event. The type plugin subscribes:

public function onPreOrderCreate(PreOrderCreateEvent $event): void
{
    $giftCardItems = $this->extractGiftCardItems($event->getSalesChannelContext());

    if (empty($giftCardItems)) {
        return;
    }

    $result = $this->apiService->validate($giftCardItems);

    if ($result['status'] !== 200 || $this->hasUnavailableItems($result)) {
        $event->stopPropagation();  // prevents CheckoutController from creating the order
        $this->rejectWithFlashMessage($event->getRequest(), $result);
    }
}

After order placement - CheckoutOrderPlacedEvent triggers the purchase:

public function onOrderPlaced(CheckoutOrderPlacedEvent $event): void
{
    $payload = $this->buildPayload($event->getOrder());

    if (empty($payload['items'])) {
        return;
    }

    $result = $this->apiService->purchase($payload);

    if ($result['status'] !== 204) {
        $this->eventDispatcher->dispatch(
            new GiftCardPurchaseFailedEvent($event->getOrder(), $payload, $result)
        );
    }
}

Failure Logging and Retries

A subscriber on GiftCardPurchaseFailedEvent logs to a dedicated table. A retry service fetches unresolved failures, re-attempts the purchase, and either removes the log entry on success or increments the retry counter on failure. When the counter hits a configured threshold it dispatches a GiftCardPurchaseEscalatedEvent, which implements FlowEventAware and MailAware - meaning an admin can wire it to an email notification in Flow Builder without any code change.

Two CLI commands wrap the retry service:

php bin/console giftcard:list-failed-purchases
php bin/console giftcard:retry-failed-purchases [order-id]

What Part 2 Demonstrates

The type plugin is fully self-contained. It owns its schema, its admin form, its validation, its fulfillment, and its error handling. The registry plugin has no knowledge of it.

Adding another product type - a digital licence, a subscription token - means creating another plugin that installs another row in product_custom_type and implements whatever that type needs. No existing code changes. The registry's admin UI, PHP detection, and Twig detection work for all types without modification.