NebulaNebula
Developer guide

Widgets

Nebula replaces Magento's legacy widget admin (Content → Elements → Widgets) with a single clean wizard. Every widget declared in widget.xml works without code changes — including third-party ones — and choosers plug in via di.xml.

What you get out of the box

  • • Full-page wizard replacing the legacy 4-tab form, with the same POST shape so Magento's Save controller is untouched.
  • Every Magento widget renders — CMS Static Block, CMS Page Link, Catalog Category/Product Link, Recently Viewed/Compared, Catalog Products List (with rule editor), Sales Returns form — plus any widget your 3rd-party modules declare.
  • • All xsi:types the audit covers: text, select (inline + source_model), multiselect, block (chooser), conditions, with depends, visible, description, and sort_order respected.
  • • Layout Updates with full page-group support: All Pages, Specified Page, Page Layouts, Anchor/Non-Anchor Categories, All Product Types + per-type rows. Container dropdown loads from the actual storefront layout via Magento's /admin/widget_instance/blocks/ endpoint.
  • • 4 chooser snippets bundled: cms_block, cms_page, catalog_category, catalog_product. Conditions render inline via the same nebulaRuleEditor used by catalog/sales rules.

Nothing in this stack changes Magento's persistence, controllers, or widget_instance schema. The POST body is byte-compatible with what the legacy form produces.

The wizard

Routed via a layout-XML override of adminhtml_widget_instance_edit (which also covers the NewAction flow since it _forward('edit')s). The page renders in 3 stacked rows.

RowWidthContent
1. Type & ThemefullWidget Type select (locked after creation) + Design Theme select. The type code is also posted as the `code` form field so Magento's Save controller resolves the FQCN.
2. Parameters75%Dynamic — populated from the picked type's widget.xml. Text, select, multiselect, chooser, and inline conditions rule editor.
2. Storefront25%Title (required), Store Views (tree-aware multiselect — same widget the CMS forms use), Sort Order.
3. Layout UpdatesfullDynamic rows: Display On + Container + Template + Specific-entity selector. Container options load via AJAX from Magento's /widget_instance/blocks/ endpoint.

What works for free

If your third-party module declares a widget the standard way, Nebula renders it without any change to NebulaTheme. The wizard introspects widget.xml at boot via \\Magento\\Widget\\Model\\Widget::getConfigAsObject() and emits Nebula field rows for every parameter.

vendor/your_module/etc/widget.xml — works in Nebula with no code changes
<widget id="my_module_promo" class="Vendor\Module\Block\Widget\Promo">
    <label translate="true">Vendor Promo Banner</label>
    <description translate="true">A promo banner for the homepage.</description>
    <parameters>
        <parameter name="heading" xsi:type="text" required="true" visible="true">
            <label translate="true">Heading</label>
            <description translate="true">Shown at the top of the banner.</description>
        </parameter>
        <parameter name="show_pager" xsi:type="select" visible="true"
                   source_model="Magento\Config\Model\Config\Source\Yesno">
            <label translate="true">Display Page Control</label>
        </parameter>
        <parameter name="template" xsi:type="select" visible="true" required="true">
            <label translate="true">Template</label>
            <options>
                <option name="default" value="vendor/widget/promo/default.phtml" selected="true">
                    <label translate="true">Default Template</label>
                </option>
            </options>
        </parameter>
    </parameters>
    <containers>
        <container name="content">
            <template name="default" value="default"/>
        </container>
    </containers>
</widget>
The Nebula wizard reads source_model, visible, depends, description, and sort_order on every parameter. Layout Updates uses your <containers> declaration to cascade Container → Template. Widgets without a <containers> block get a curated fallback container list.

Adding a custom chooser

If your widget's parameter is xsi:type="block" with a non-core helper-block FQCN (or if you want to override one of the bundled choosers), three files + one di.xml binding plug it in — nothing in NebulaTheme needs editing.

1

ViewModel — the data source

Implement search($query, $page, $pageSize) + getByIdPath($id). The existing CmsBlock / CmsPage / CatalogCategory / CatalogProduct classes are reference shape.

app/code/Vendor/Module/ViewModel/MyChooser.php
<?php
declare(strict_types=1);

namespace Vendor\Module\ViewModel;

use Magento\Framework\View\Element\Block\ArgumentInterface;

class MyChooser implements ArgumentInterface
{
    public function __construct(
        private readonly \Vendor\Module\Api\ItemRepositoryInterface $itemRepository,
    ) {}

    /**
     * @return array{total: int, rows: list<array{value: string, label: string}>}
     */
    public function search(string $query, int $page, int $pageSize): array
    {
        // …query your data source…
        return ['total' => $total, 'rows' => $rows];
    }

    /** @return array{value: string, label: string}|null */
    public function getByIdPath(string $idPath): ?array
    {
        // Resolve a single row for label-display when editing.
    }
}
2

Snippet — JSON descriptor + phtml

The phtml lives outside the wizard's Alpine scope (it's rendered inline as a siblingx-data). It listens for an open-chooser-<alias> window event and dispatches chooser:selected when the admin picks a row.

app/code/Vendor/Module/view/adminhtml/snippet/widget_chooser_my_thing.json
{
  "id": "widget_chooser_my_thing",
  "template": "Vendor_Module::widget/chooser/my_thing.phtml"
}
app/code/Vendor/Module/view/adminhtml/templates/widget/chooser/my_thing.phtml
<?php
/** @var \Vendor\Module\ViewModel\MyChooser $vm */
$vm      = $block->getData('widget_chooser_my_thing');
$initial = $vm->search('', 1, 50);
?>
<div x-data="{ open: false, query: '', initial: <?= /* … */ ?>, fieldName: null }"
     @open-chooser-my-thing.window="
         open = true;
         fieldName = $event.detail.fieldName;
     "
     @keydown.escape.window="open = false">
    <!-- modal markup; on pick: $dispatch('chooser:selected', {fieldName, value, label}) -->
</div>
The block-data key is by convention widget_chooser_<alias>. The host phtml resolves this automatically; you do not declare it as a layout argument.
3

Register via di.xml

Append your alias to the registry's choosers array. The host phtml iterates the registry, so this single binding is enough to make the chooser appear — no patch to NebulaTheme, no edits to the wizard layout XML, no hardcoded list anywhere.

app/code/Vendor/Module/etc/adminhtml/di.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <type name="Qoliber\NebulaTheme\Model\Registry\WidgetChooserRegistry">
        <arguments>
            <argument name="choosers" xsi:type="array">
                <item name="my_thing" xsi:type="array">
                    <item name="helper_block" xsi:type="string">Vendor\Module\Block\Adminhtml\Item\Widget\Chooser</item>
                    <item name="template"     xsi:type="string">Vendor_Module::widget/chooser/my_thing.phtml</item>
                    <item name="view_model"   xsi:type="string">Vendor\Module\ViewModel\MyChooser</item>
                </item>
            </argument>
        </arguments>
    </type>
</config>

That binding does three things at once: maps the legacy helper-block FQCN to the chooser aliasmy_thing (so ParameterTranslator picks the right alias when it sees the widget.xml xsi:type="block"), tells the host phtml which template to mount, and which ViewModel to instantiate via ObjectManager.

Architecture

The widget wizard is intentionally split into focused units. No god-class — each ViewModel owns one concern, two registries make the chooser + container catalogs extensible from third-party di.xml.

UnitTypeOwns
ViewModel\Widget\TypeListingViewModelInstalled widget types, themes, FQCN→code map (Save controller reads `code`).
ViewModel\Widget\ParameterTranslatorViewModelwidget.xml → Nebula field-config shape. Reads source_model, depends, visible, sort_order. Delegates chooser-alias lookup to WidgetChooserRegistry.
ViewModel\Widget\ContainerCatalogViewModelContainers + per-container templates per widget type. Falls back to WidgetContainerFallbackRegistry when widget.xml declares no <containers>.
ViewModel\Widget\PageGroupCatalogViewModelDisplay On options (incl. dynamic product types) + layout-handle map + page-layout options.
ViewModel\WidgetInstanceDataViewModelReads current_widget_instance from the registry; round-trips DB columns (page_template / page_for) back to wizard form-input shape.
ViewModel\WidgetChooser\{CmsBlock, CmsPage, CatalogCategory, CatalogProduct}ViewModelOne per bundled chooser. Each has search() + getByIdPath() — same contract.
Model\Registry\WidgetChooserRegistryRegistrydi.xml-driven alias → {helper_block, template, view_model}. Single source of truth for all chooser wiring.
Model\Registry\WidgetContainerFallbackRegistryRegistrydi.xml-driven curated list of storefront containers, used when a widget.xml has no <containers>.

The host phtml is a 130-line shell that pulls four section includes (_column_type_theme, _column_parameters, _column_storefront,_section_layout_updates) and iterates the chooser registry to mount each chooser snippet inline. Adding rows or sections is a single-file edit.

Theme-specific containers

When a widget declares no <containers> block in its widget.xml (the CMS widgets are like this), the wizard falls back to a curated list of common storefront containers. Themes can extend that list via their own di.xml — without monkey-patching NebulaTheme.

app/design/frontend/Vendor/Theme/etc/di.xml — extend the fallback list
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <type name="Qoliber\NebulaTheme\Model\Registry\WidgetContainerFallbackRegistry">
        <arguments>
            <argument name="containers" xsi:type="array">
                <item name="homepage.feature.row" xsi:type="string">homepage.feature.row</item>
                <item name="category.feature.row" xsi:type="string">category.feature.row</item>
            </argument>
        </arguments>
    </type>
</config>

Note: theme-specific containers also surface dynamically when the user picks a page-group, because the wizard hits Magento's own /admin/widget_instance/blocks/ endpoint to discover what the frontend layout actually declares. The fallback is just a sensible default for the initial render.

The POST shape

The wizard's form posts to the unchanged Magento\\Widget\\Controller\\Adminhtml\\Widget\\Instance\\Save controller. The payload is byte-compatible with what Magento's legacy form produces — that's why no controller plugin or observer is needed on the save side.

Form POST shape — same keys Magento's legacy form uses
form_key        = …
instance_id     = 42                       # absent on the new-widget flow
instance_type   = Magento\Cms\Block\Widget\Block
code            = cms_static_block         # → _initWidgetInstance resolves FQCN
theme_id        = 4
title           = Footer Block
sort_order      = 0
store_ids[]     = 0
parameters[block_id]                    = 15
parameters[template]                    = widget/static_block/default.phtml
widget_instance[0][page_group]          = all_pages
widget_instance[0][all_pages][page_id]  = 0
widget_instance[0][all_pages][for]      = all
widget_instance[0][all_pages][block]    = content
widget_instance[0][all_pages][template] = widget/static_block/default.phtml
The page-group rows post under widget_instance[N][...], notpage_groups[N][...]. Magento's Save controller reads fromPOST['widget_instance'] — that's the actual contract.

What's deferred

  • • Live AJAX pagination on the chooser modals. First 50–100 entries are server-rendered; client-side filter only.
  • • PageBuilder-style preview rendering of widgets inside the wizard. Magento's MageOS_PageBuilderWidget hooks for previewTemplate are read but not honoured yet.
  • • Multi-entity picker UX. Specific-entity selection on Layout Updates uses repeated single-picks against a comma-list; a multi-select picker is a later UX pass.

Ship a widget you've been deferring

The widget admin used to be the gnarliest screen in core. With Nebula it's the same UX you get everywhere else — a JSON-and-Alpine page, themable, extensible, and consistent with the rest of the catalog admin.