Skip to content

Snippet examples

Every example on this page is adapted from a snippet that ships with Nebula, so the shape is one the engine renders today. The Snippets page explains the mechanism; this page shows it in use.

1. A badge with no template of its own

view/adminhtml/snippet/scope_badge.json
{
  "id": "scope_badge",
  "type": "badge",
  "options": {
    "0": { "label": "Store View", "class": "info" },
    "1": { "label": "Global",     "class": "success" },
    "2": { "label": "Website",    "class": "warning" }
  }
}
a column using it
"is_global": {
  "label": "Scope",
  "renderer": "snippet.scope_badge",
  "filter": "select",
  "filterOptions": { "0": "Store View", "1": "Global", "2": "Website" },
  "position": 40
}

Keys worth noticing:

  • type: "badge" needs no template; the shared badge template renders it.
  • class is one of success, error, warning, info, neutral.
  • A column names either a type or a renderer, not both; the schema rejects the pair. Filtering and sorting still work from the column's other keys.
  • The column's filterOptions win over the snippet's labels when both exist, so the filter dropdown and the cell always agree.

Source: Nebula_Component/view/adminhtml/snippet/scope_badge.json.

2. One template for a grid cell and a form section

The theme preview renders as a thumbnail in the themes grid and as a full-size image on the theme view page. One snippet, one template, one view model, and a branch on nebula_context.

view/adminhtml/snippet/theme_preview.json
{ "id": "theme_preview", "template": "Nebula_Store::snippet/theme_preview.phtml" }
view/adminhtml/templates/snippet/theme_preview.phtml
<?php
/** @var \Magento\Framework\View\Element\Template $block */
/** @var \Magento\Framework\Escaper $escaper */
$viewModel = $block->getData('view_model');
if (!$viewModel instanceof \Nebula\Store\ViewModel\ThemePreview) {
    throw new \RuntimeException('Snippet "theme_preview": view_model was not injected.');
}
$record = $block->getData('record');
$record = is_array($record) ? $record : [];
$themeId = $record['theme_id'] ?? null;
$title = (string) ($record['theme_title'] ?? '');
$isGridCell = $block->getData('nebula_context') === 'grid';

if (!$viewModel->hasPreview($themeId)) :
    ?><span class="nebula-tree__note"><?= $escaper->escapeHtml(__('No preview image')) ?></span><?php
    return;
endif;
$previewUrl = $viewModel->getPreviewUrl($themeId);
$alt = $title !== '' ? $title : (string) __('Theme preview');
?>
<?php if ($isGridCell) : ?>
<img src="<?= $escaper->escapeUrl($previewUrl) ?>" alt="<?= $escaper->escapeHtmlAttr($alt) ?>"
     class="nebula-image-thumb" loading="lazy">
<?php else : ?>
<a href="<?= $escaper->escapeUrl($previewUrl) ?>" target="_blank" rel="noopener">
    <img src="<?= $escaper->escapeUrl($previewUrl) ?>" alt="<?= $escaper->escapeHtmlAttr($alt) ?>"
         class="nebula-image-preview" loading="lazy">
</a>
<?php endif; ?>
etc/di.xml
<type name="Nebula\Component\Model\SnippetViewModelRegistry">
    <arguments>
        <argument name="viewModels" xsi:type="array">
            <item name="theme_preview" xsi:type="string">Nebula\Store\ViewModel\ThemePreview</item>
        </argument>
    </arguments>
</type>

Used from a grid column and from a form fieldset:

grid/design_theme_listing.json (excerpt)
"preview_image": { "label": "Preview", "renderer": "snippet.theme_preview", "sortable": false, "position": 50 }
form/design_theme_edit.json (excerpt)
"preview": { "label": "Theme Preview Image", "position": 20, "open": true, "renderer": "snippet.theme_preview" }

Keys worth noticing:

  • record is the row in a grid and the entity data in a form. Read it instead of item or entity_data when a snippet serves both.
  • nebula_context is the only thing the template branches on, and only for a real difference in presentation.
  • The throw on a missing view model is deliberate. Both renderers inject it; a hand-built block would not, and the template should say so rather than render half a widget.

Source: Nebula_Store/view/adminhtml/snippet/theme_preview.json and its template.

3. A panel with a view model, a script, and the CSP nonce

A form section that shows an entity's state and offers one action. The view model answers the questions; the template asks them.

view/adminhtml/snippet/vendor_sync_status.json
{ "id": "vendor_sync_status", "template": "Vendor_Module::snippet/sync-status.phtml" }
ViewModel/SyncStatus.php
namespace Vendor\Module\ViewModel;

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

class SyncStatus implements ArgumentInterface
{
    public function __construct(
        private readonly \Vendor\Module\Model\SyncRepository $syncs,
        private readonly \Magento\Framework\UrlInterface $url,
        private readonly \Magento\Framework\Data\Form\FormKey $formKey,
    ) {
    }

    public function lastSyncFor(int $productId): ?string
    {
        return $this->syncs->lastRunFor($productId)?->format('Y-m-d H:i');
    }

    public function syncUrl(int $productId): string
    {
        return $this->url->getUrl('vendor_module/sync/run', ['id' => $productId]);
    }

    public function formKey(): string
    {
        return $this->formKey->getFormKey();
    }
}
view/adminhtml/templates/snippet/sync-status.phtml
<?php
/** @var \Magento\Framework\View\Element\Template $block */
/** @var \Magento\Framework\Escaper $escaper */
/** @var \Magento\Framework\View\Helper\SecureHtmlRenderer $secureRenderer */
$viewModel = $block->getData('view_model');
if (!$viewModel instanceof \Vendor\Module\ViewModel\SyncStatus) {
    throw new \RuntimeException('Snippet "vendor_sync_status": view_model was not injected.');
}
$record = $block->getData('record');
$productId = (int) ($record['entity_id'] ?? 0);
if ($productId === 0) {
    return; // a new record has nothing to sync yet
}
$config = [
    'url' => $viewModel->syncUrl($productId),
    'formKey' => $viewModel->formKey(),
    'labels' => ['done' => (string) __('Sync queued.'), 'failed' => (string) __('The sync could not be queued.')],
];
$last = $viewModel->lastSyncFor($productId);
?>
<div class="nebula-action-row" x-data="vendorSyncStatus(<?= $escaper->escapeHtmlAttr(json_encode($config)) ?>)">
    <div class="nebula-action-row__label">
        <?= $escaper->escapeHtml(__('Last synced')) ?>:
        <strong><?= $escaper->escapeHtml($last ?? __('never')) ?></strong>
    </div>
    <div class="nebula-action-row__controls">
        <button type="button" class="nebula-btn nebula-btn--soft" :disabled="busy" @click="run()">
            <?= $escaper->escapeHtml(__('Sync now')) ?>
        </button>
    </div>
</div>
<?php
$js = <<<'SCRIPT'
document.addEventListener('alpine:init', () => {
    Alpine.data('vendorSyncStatus', (config) => ({
        busy: false,
        async run() {
            this.busy = true;
            try {
                const response = await fetch(config.url, {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Requested-With': 'XMLHttpRequest' },
                    body: new URLSearchParams({ form_key: config.formKey }),
                });
                const body = await response.json();
                window.nebulaToast(body.success ? 'success' : 'error', body.success ? config.labels.done : (body.message || config.labels.failed));
            } finally {
                this.busy = false;
            }
        },
    }));
});
SCRIPT;
?>
<?= /* @noEscape */ $secureRenderer->renderTag('script', [], $js, false) ?>
overlay on the product form
{
  "layout": [
    {
      "id": "main_row",
      "children": [
        {
          "id": "sidebar_col",
          "children": [
            { "id": "vendor_sync_section", "type": "section", "label": "Feed sync", "renderer": "snippet.vendor_sync_status", "position": 90 }
          ]
        }
      ]
    }
  ]
}

Keys worth noticing:

  • The template contains no <form>. It sits inside the product form, and a nested form element would break the save.
  • Translated strings go into the config the component receives, so the script is a plain string with no PHP inside it and the nonce covers it.
  • The button is type="button". A bare <button> inside a form submits it.

Pattern source: Nebula_Theme/view/adminhtml/templates/customer/snippet/*.phtml and Nebula_Passkey/view/adminhtml/templates/snippet/*.phtml.

4. A lazily loaded panel

The customer page's Orders tab does not query orders until the panel scrolls into view. The template renders a placeholder, watches it with an IntersectionObserver, and fetches the rendered panel once.

the shape, trimmed from customer/snippet/orders_lazy.phtml
<?php
$formBlock = $block->getData('form_block');
if (!$formBlock) {
    return;
}
$entityId = (int) ($formBlock->getEntityId() ?? 0);
if ($entityId === 0) {
    return;
}
$panelUrl = $escaper->escapeUrl($formBlock->getUrl('vendor_module/panel/orders', ['id' => $entityId]));
?>
<div x-data="{
        loaded: false, loading: false, html: '',
        async load() {
            if (this.loaded || this.loading) return;
            this.loading = true;
            const response = await fetch('<?= /* @noEscape */ $panelUrl ?>', { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
            this.html = await response.text();
            this.loaded = true; this.loading = false;
        },
        init() {
            new IntersectionObserver((entries) => { if (entries.some(e => e.isIntersecting)) this.load(); }).observe(this.$el);
        }
     }">
    <p x-show="!loaded" class="nebula-tree__note"><?= $escaper->escapeHtml(__('Scroll to load orders')) ?></p>
    <div x-show="loaded" x-html="html"></div>
</div>

Keys worth noticing:

  • form_block gives the entity id through getEntityId(), which is present for every form. Do not require an entity object; some providers do not set one.
  • The observer is written out rather than using an Alpine intersect plugin, because the theme does not ship one and Alpine ignores an unknown directive silently.
  • The controller behind the URL returns an HTML fragment and declares its own ACL resource.

Source: Nebula_Theme/view/adminhtml/snippet/customer_orders_lazy.json and its template.

5. Snippet and field nodes in a layout tree

A layout tree can place a snippet between groups of fields without wrapping it in a section:

form/catalog_category_edit.json (excerpt)
{
  "type": "section",
  "label": "Display Settings",
  "children": [
    { "type": "fields", "fields": ["display_mode", "is_anchor"] },
    { "type": "snippet", "renderer": "snippet.category_sortby_fields" }
  ]
}

The fields node renders the named fields; the snippet node renders the template in place. The schema allows the node types row, column, section, tabs, tab, fields and snippet.

In an EAV form the snippet receives entity as well as form_block, and the shipped templates read the store id from either:

$entity = $block->getData('entity');
$formBlock = $block->getData('form_block');
$storeId = $entity instanceof \Magento\Catalog\Model\Category
    ? (int) $entity->getStoreId()
    : (int) ($formBlock?->getFieldValue('store_id') ?? 0);

Source: Nebula_Theme/view/adminhtml/form/catalog_category_edit.json and templates/eav/snippet/category_sortby_fields.phtml.

6. A layout snippet with slots

view/adminhtml/snippet/two_column_sidebar.json
{
  "id": "two_column_sidebar",
  "type": "row",
  "children": [
    { "type": "column", "width": "2/3", "slot": "main" },
    { "type": "column", "width": "1/3", "slot": "sidebar" }
  ]
}
a form layout using it
"layout": [
  {
    "@use": "two_column_sidebar",
    "slots": {
      "main":    [ { "id": "general", "type": "section", "label": "General", "fields": ["name", "sku", "description"] } ],
      "sidebar": [ { "id": "status",  "type": "section", "label": "Status",  "fields": ["is_active", "position"] } ]
    }
  }
]

The snippet resolver replaces the @use node with the row and fills each named slot. The shipped forms write their rows out in full instead, so treat this as a shorthand that is available rather than the convention.

Source: Nebula_Component/view/adminhtml/snippet/two_column_sidebar.json.

7. Changing a shipped snippet's words

Vendor_Module/view/adminhtml/snippet/status_badge.json
{
  "options": {
    "1": { "label": "Live",  "class": "success" },
    "0": { "label": "Draft", "class": "neutral" }
  }
}

Snippets merge like grids and forms. The module must sequence after Nebula_Component, and the result only shows on columns that do not declare their own filterOptions, because a column's map wins.

Checklist for a new snippet

  1. id equals the file name.
  2. The template guards its view model with a throw, reads record or form_block, and contains no <form>.
  3. Inline scripts go through $secureRenderer->renderTag().
  4. The view model is bound in SnippetViewModelRegistry under the snippet id.
  5. bin/magento nebula:lint passes.