← Back to blog

How to Create a Custom Widget in Magento 2

Rodrigo Biazzi

Widgets let content editors place dynamic blocks in CMS pages, static blocks and layout positions, with options they fill in through a form in the admin. Magento ships a few, such as the catalog products list and CMS page link. When editors need something specific to your store, a custom widget is often the cleanest answer, because the logic stays in code and the editors only choose options.

This article builds a small widget module for Magento 2.4.8: a promo banner with a title, a text, a style option and a linked category. Along the way it covers parameter types, choosers, the widget directive and caching.

Widgets vs Page Builder content types

If your store uses Page Builder, editors may expect a drag and drop element instead of a widget. Widgets still work inside Page Builder through its HTML Code element or by inserting a widget into a text element, but the editing experience is not as good. For elements editors place often, a custom Page Builder content type is nicer to use. Widgets remain the better tool when the block must also be placed through widget instances on layout positions, or used outside Page Builder.

Module structure

app/code/Vendor/PromoWidget/
    registration.php
    etc/module.xml
    etc/widget.xml
    Block/Widget/PromoBanner.php
    view/frontend/templates/widget/promo-banner.phtml

The module needs Magento_Widget and Magento_Cms in the sequence of its module.xml. If you have not built a module before, the custom module guide covers registration.php and module.xml.

widget.xml

This file declares the widget, its label in the admin and its parameters. Each parameter becomes a field in the widget form.

<?xml version="1.0"?>
<widgets xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Widget:etc/widget.xsd">
    <widget id="vendor_promo_banner"
            class="Vendor\PromoWidget\Block\Widget\PromoBanner"
            is_email_compatible="false">
        <label translate="true">Promo Banner</label>
        <description translate="true">Banner with title, text and a category link</description>
        <parameters>
            <parameter name="title" xsi:type="text" required="true" visible="true" sort_order="10">
                <label translate="true">Title</label>
            </parameter>
            <parameter name="text" xsi:type="text" required="false" visible="true" sort_order="20">
                <label translate="true">Text</label>
            </parameter>
            <parameter name="style" xsi:type="select" required="true" visible="true" sort_order="30">
                <label translate="true">Style</label>
                <options>
                    <option name="light" value="light" selected="true">
                        <label translate="true">Light</label>
                    </option>
                    <option name="dark" value="dark">
                        <label translate="true">Dark</label>
                    </option>
                </options>
            </parameter>
            <parameter name="show_button" xsi:type="select" visible="true" sort_order="40"
                       source_model="Magento\Config\Model\Config\Source\Yesno">
                <label translate="true">Show Button</label>
            </parameter>
            <parameter name="id_path" xsi:type="block" required="true" visible="true" sort_order="50">
                <label translate="true">Category</label>
                <block class="Magento\Catalog\Block\Adminhtml\Category\Widget\Chooser">
                    <data>
                        <item name="button" xsi:type="array">
                            <item name="open" xsi:type="string" translate="true">Select Category...</item>
                        </item>
                    </data>
                </block>
            </parameter>
            <parameter name="template" xsi:type="select" required="true" visible="true" sort_order="60">
                <label translate="true">Template</label>
                <options>
                    <option name="default" value="Vendor_PromoWidget::widget/promo-banner.phtml" selected="true">
                        <label translate="true">Default</label>
                    </option>
                </options>
            </parameter>
        </parameters>
    </widget>
</widgets>

Notes on the parameter types:

  • text renders a single line input.
  • select and multiselect take either inline options or a source_model that implements Magento\Framework\Data\OptionSourceInterface.
  • block renders a custom admin block. Core ships choosers for categories, products and CMS pages, which return values such as category/12 or product/34.
  • conditions renders the rule condition tree, as the core catalog products list widget does.

Declaring template as a parameter lets editors switch between templates you provide, and lets other modules add their own template options. You can also add depends to show a field only when another field has a given value.

Options from a source model

Inline options are fine for a fixed list. When the list comes from data, such as a set of banner styles defined in configuration or a list of attribute values, write a source model and reference it with source_model:

<?php

declare(strict_types=1);

namespace Vendor\PromoWidget\Model\Config\Source;

use Magento\Framework\Data\OptionSourceInterface;

class BannerStyle implements OptionSourceInterface
{
    public function toOptionArray(): array
    {
        return [
            ['value' => 'light', 'label' => __('Light')],
            ['value' => 'dark', 'label' => __('Dark')],
            ['value' => 'brand', 'label' => __('Brand colour')],
        ];
    }
}

The same class can then serve a system configuration field or a UI component form, so the list of allowed values lives in one place. Keep the block getter in sync with it, so the template never receives a value the source model does not offer.

The block class

<?php

declare(strict_types=1);

namespace Vendor\PromoWidget\Block\Widget;

use Magento\Catalog\Api\CategoryRepositoryInterface;
use Magento\Framework\Exception\NoSuchEntityException;
use Magento\Framework\View\Element\Template;
use Magento\Framework\View\Element\Template\Context;
use Magento\Widget\Block\BlockInterface;

class PromoBanner extends Template implements BlockInterface
{
    protected $_template = 'Vendor_PromoWidget::widget/promo-banner.phtml';

    public function __construct(
        Context $context,
        private readonly CategoryRepositoryInterface $categoryRepository,
        array $data = []
    ) {
        parent::__construct($context, $data);
    }

    public function getTitle(): string
    {
        return (string) $this->getData('title');
    }

    public function getText(): string
    {
        return (string) $this->getData('text');
    }

    public function getStyle(): string
    {
        return $this->getData('style') === 'dark' ? 'dark' : 'light';
    }

    public function isButtonVisible(): bool
    {
        return (bool) $this->getData('show_button');
    }

    public function getCategoryUrl(): ?string
    {
        $categoryId = $this->getCategoryId();
        if ($categoryId === null) {
            return null;
        }

        try {
            $storeId = (int) $this->_storeManager->getStore()->getId();
            $category = $this->categoryRepository->get($categoryId, $storeId);
        } catch (NoSuchEntityException) {
            return null;
        }

        return $category->getUrl();
    }

    public function getCacheKeyInfo(): array
    {
        return array_merge(parent::getCacheKeyInfo(), [
            'vendor_promo_banner',
            $this->getTitle(),
            $this->getStyle(),
            (string) $this->getData('id_path'),
        ]);
    }

    private function getCategoryId(): ?int
    {
        $parts = explode('/', (string) $this->getData('id_path'));

        return isset($parts[1]) && ctype_digit($parts[1]) ? (int) $parts[1] : null;
    }
}

The widget class must implement Magento\Widget\Block\BlockInterface. The parameters arrive as block data, so getData('title') returns what the editor typed. Wrapping each one in a typed getter keeps the template simple and gives you one place to validate values. The style getter, for example, never returns anything outside the two allowed values, even if someone edits the directive by hand.

The template

<?php
/** @var \Vendor\PromoWidget\Block\Widget\PromoBanner $block */
/** @var \Magento\Framework\Escaper $escaper */
$url = $block->getCategoryUrl();
?>
<section class="promo-banner promo-banner--<?= $escaper->escapeHtmlAttr($block->getStyle()) ?>">
    <h2 class="promo-banner__title"><?= $escaper->escapeHtml($block->getTitle()) ?></h2>
    <?php if ($block->getText()): ?>
        <p class="promo-banner__text"><?= $escaper->escapeHtml($block->getText()) ?></p>
    <?php endif; ?>
    <?php if ($url && $block->isButtonVisible()): ?>
        <a class="action primary" href="<?= $escaper->escapeUrl($url) ?>">
            <?= $escaper->escapeHtml(__('Shop now')) ?>
        </a>
    <?php endif; ?>
</section>

Escape every parameter. Widget parameters are typed by admin users, but they are still input, and an unescaped title is an easy place for broken HTML or stored XSS to enter the storefront.

Using the widget

After bin/magento setup:upgrade and a cache flush, the widget appears in two places:

  • Insert Widget in the WYSIWYG editor of CMS pages and blocks. Magento writes a directive into the content.
  • Content, Widgets, where you create a widget instance and assign it to a theme, pages and layout containers, without touching CMS content.

Widget instances deserve a closer look, because editors often misunderstand them. An instance is tied to one theme. When you create it, you choose the theme first, then add layout updates: which pages it appears on (all pages, specified categories, specified products, or a page handle) and which container it goes into. Magento turns that into layout updates stored in the database, so the block is added to the page without anyone touching a template. If the store later switches to a new theme, the instances stay attached to the old one and must be recreated or edited.

Containers offered in the widget instance form come from the label attribute of containers in layout XML. If you want editors to place widgets in a position of your own theme, declare a container with a clear label there.

The directive in content looks like this:

{{widget type="Vendor\PromoWidget\Block\Widget\PromoBanner" title="Summer sale" style="dark" show_button="1" id_path="category/12" template="Vendor_PromoWidget::widget/promo-banner.phtml"}}

The type is the class name. If you rename the class later, every directive stored in CMS content breaks. Choose the name carefully, or keep the old class as a thin subclass when you need to move it.

Caching

Widgets rendered inside a CMS page are part of the page HTML, so full page cache stores them with the page. The block cache is a second layer. A block is only stored in block cache when it has a cache_lifetime, which you can set through data or by overriding getCacheLifetime(). If you enable it, include every parameter that changes the output in getCacheKeyInfo(), as the class above does. Otherwise two banners with different titles on the same store share one cache entry.

When the widget shows entity data, such as a category name, also implement Magento\Framework\DataObject\IdentityInterface and return the entity cache tags, so saving the category clears the cached HTML. The cache types article explains how those tags flow through block and page cache.

Never put customer specific content in a widget. It ends up in cached HTML and is shown to the next visitor.

Maintenance

A widget is a small public contract with your content team. Once directives are stored in hundreds of CMS blocks, changing it costs more than building it did. A few rules keep that cost down:

  • Add new parameters as optional, with a sensible default in the block getter, so existing directives keep working.
  • Never rename or remove a parameter that stored content uses. Read the old name as a fallback if you must change it.
  • Before removing a widget module, search cms_page and cms_block content and the widget_instance table for its class name. Leftover directives render nothing or throw errors in the log.

FAQ

Why does my widget not appear in the Insert Widget list?

Check that widget.xml is valid against the schema, that the module is enabled, and that you flushed the cache. Also confirm the class in widget.xml exists and implements BlockInterface.

Can a widget be used in transactional emails?

Only if it is declared with is_email_compatible set to true, and the template must work without the storefront CSS and JavaScript, using inline styles.

How do I add a product chooser to a widget?

Use a parameter of type block with the class Magento\Catalog\Block\Adminhtml\Product\Widget\Chooser. It returns a value like product/34 that you parse in the block class.

Do widgets work with Hyvä?

The PHP side works the same. The template needs Hyvä style markup, with Tailwind classes and Alpine.js instead of RequireJS, so ship a Hyvä version of the template if the store uses both.

I am available for freelance Magento and Adobe Commerce work, including custom CMS tools for content teams. If you need help with this, get in touch.

Related articles