NebulaNebula
Architecture

Snippets

Reusable rendering components that bridge JSON definitions and phtml templates. The escape hatch for anything more complex than a single form field — without falling back to hand-wired blocks.

Why snippets exist

Built-in form types (text, select, etc.) cover the 80% case. Snippets cover the 20% where you need:

  • Complex stateful UI — media gallery, category tree, rule conditions builder
  • Server-side data prep — load related entities, build option lists, pre-compute config
  • Type-safe injection — ViewModels via DI, no ObjectManager::getInstance() in templates
  • Reuse across boundaries — the same snippet works in a form, a grid cell, a page block

Crucially, snippets are JSON-discoverable: a form or grid declares"renderer": "snippet.media_gallery" and Nebula resolves the snippet, injects the ViewModel, renders the template. No layout XML, no manual block wiring.

Two flavors

A. Static snippets

JSON config + phtml template. No ViewModel. Good for small reusable bits — a status badge format, a country picker, a help-text block.

snippet/status_badge.json
{
  "id": "status_badge",
  "template": "Acme_Module::snippet/status_badge.phtml",
  "options": {
    "1": { "label": "Enabled", "class": "success" },
    "0": { "label": "Disabled","class": "error" }
  }
}

B. Dynamic snippets (ViewModel)

JSON config + phtml + ViewModel class registered in SnippetViewModelRegistry. Use when you need server-side logic, type-safe helpers, or shared state across instances.

snippet/media_gallery.json + di.xml binding
{
  "id": "media_gallery",
  "template": "Acme_Module::snippet/media_gallery.phtml"
}

// di.xml
<type name="Qoliber\NebulaComponent\Model\SnippetViewModelRegistry">
  <arguments>
    <argument name="viewModels" xsi:type="array">
      <item name="media_gallery" xsi:type="object">
        Acme\Module\ViewModel\MediaGallery
      </item>
    </argument>
  </arguments>
</type>

The ViewModel registry, end to end

1

Form / grid references the snippet

"fields": {
  "media_gallery": {
    "label": "Media",
    "renderer": "snippet.media_gallery",
    "position": 50
  }
}
2

SectionRenderer resolves it

SnippetResolver::resolve('media_gallery') loads every JSON file atview/adminhtml/snippet/media_gallery.json across enabled modules, deep-merges them, and extracts the template path.

3

Registry hands over the ViewModel

SnippetViewModelRegistry::get('media_gallery') returns the bound class instance (constructor- injected like any Magento DI service). It's passed into the rendered block as view_model.

SectionRenderer.php
$viewModel = $this->viewModelRegistry->get($snippetId);
if ($viewModel !== null) {
    $blockData['view_model'] = $viewModel;
}
$blockData['entity']     = $entity;
$blockData['form_block'] = $formBlock;
$blockData['section']    = $sectionConfig;
4

Template uses it

The phtml retrieves the ViewModel, asks it for shaped data, and renders Alpine markup. A defensive fallback is allowed for the rare case where the snippet renders outside the registered path (e.g. a transient template block created at runtime).

snippet/media_gallery.phtml
<?php
declare(strict_types=1);
/** @var \Magento\Framework\View\Element\Template $block */
/** @var \Acme\Module\ViewModel\MediaGallery $viewModel */

$viewModel = $block->getData('view_model');
if (!$viewModel instanceof \Acme\Module\ViewModel\MediaGallery) {
    // nebula:allow-object-manager dynamic-snippet-view-model fallback
    $viewModel = \Magento\Framework\App\ObjectManager::getInstance()
        ->get(\Acme\Module\ViewModel\MediaGallery::class);
}

$entity = $block->getData('entity');
$config = $viewModel->buildConfig($entity);
$json   = json_encode($config, JSON_HEX_APOS | JSON_HEX_QUOT);
?>
<div x-data="acmeMediaGallery(<?= $block->escapeHtmlAttr($json) ?>)">
    ...
</div>
The fallback ObjectManager::getInstance() is only safe inside snippets — flagged with thenebula:allow-object-manager comment so PHPStan's ban rule lets it through. Anywhere else, inject normally.

Author your own snippet

1

Create the snippet JSON

YourModule/view/adminhtml/snippet/custom_widget.json
{
  "id": "custom_widget",
  "template": "Your_Module::snippet/custom_widget.phtml"
}

Anything else you put on this object is config the template can read via $block->getData('options') etc.

2

Write the phtml

YourModule/view/adminhtml/templates/snippet/custom_widget.phtml
<?php
declare(strict_types=1);
/** @var \Magento\Framework\View\Element\Template $block */

$entity  = $block->getData('entity');
$section = $block->getData('section');
$value   = $entity ? (string) $entity->getData($section['attribute_code'] ?? '') : '';
?>
<div x-data="{ open: false }">
    <button @click="open = !open" class="...">Toggle</button>
    <div x-show="open" x-cloak>
        <input type="text" name="<?= $block->escapeHtmlAttr($section['name'] ?? '') ?>"
               value="<?= $block->escapeHtmlAttr($value) ?>" />
    </div>
</div>
3

(optional) Bind a ViewModel

Whenever the template needs server data — collections, formatting, helpers — write a ViewModel that implements ArgumentInterface and register it.

YourModule/etc/di.xml
<type name="Qoliber\NebulaComponent\Model\SnippetViewModelRegistry">
  <arguments>
    <argument name="viewModels" xsi:type="array">
      <item name="custom_widget" xsi:type="object">
        Your\Module\ViewModel\CustomWidget
      </item>
    </argument>
  </arguments>
</type>
4

Reference it from a form / grid

"fields": {
  "my_field": {
    "label": "My Field",
    "renderer": "snippet.custom_widget",
    "attribute_code": "my_field",
    "position": 50
  }
}

Any extra keys on the field definition (here attribute_code) are passed straight through to the snippet's block data, so the template can use them as configuration.

Snippet or Alpine factory?

They're complementary, not alternatives. A snippet renders the HTML; an Alpine factory adds runtime behavior to it.

If you need…SnippetAlpine factory
Reuse across forms / gridsyesno — tied to one component
Server-side data (entities, collections)yesno — runs in the browser
Type-safe ViewModel injectionyesno — TS, but separate concern
Stateful client behavior (collapse, drag)usually pairs with Alpine inside the templateyes — registers with Alpine.data
Tiny one-off interactionoverkillyes — inline x-data may be enough
Complex UI with mixed server + clientyesyes — snippet renders, factory drives
The big stuff — media gallery, category tree, rule editor, product selector — is always snippet + factory together: the snippet template emits the structure and an x-data="nebulaXxx(...)" binding; the registered factory in web/ts owns the runtime state. See the JS architecture guide.

Snippets shipped with Nebula

snippet.media_gallery

Product image manager — drag/reorder, role assignment, alt text

ViewModel: yes
snippet.category_tree

Hierarchical category picker for product edit

ViewModel: yes
snippet.rule_conditions

Magento rule conditions builder (catalog/cart rules)

ViewModel: yes
snippet.product_selector

Search-and-pick UI for related/up-sell/cross-sell products

ViewModel: no — pure Alpine
snippet.searchable_multiselect

Multiselect with inline search + chips

ViewModel: no
snippet.attribute_options

Per-store-view label table for select/multiselect attributes

ViewModel: yes
snippet.seo_preview

Live Google-result preview that watches title/url/desc inputs

ViewModel: yes
snippet.status_badge

Reusable colored pill for grids

ViewModel: no

Reference paths

  • code/Qoliber/NebulaComponent/docs/SNIPPETS.md
  • code/Qoliber/NebulaComponent/Model/Definition/SnippetResolver.php
  • code/Qoliber/NebulaComponent/Model/SnippetViewModelRegistry.php
  • code/Qoliber/NebulaForm/Model/Form/SectionRenderer.php
  • code/Qoliber/NebulaTheme/view/adminhtml/snippet/* (real examples)
  • code/Qoliber/NebulaTheme/view/adminhtml/templates/eav/snippet/*