Skip to content

Extension points

Every seam a third-party module can use to extend Nebula without editing it. They all obey one rule:

Adding a field, a column, a save behaviour or a grid should take one adapter and one definition — never an edit to a switch inside the engine.

Each entry below says what the seam is, which interface or class it runs through, a minimal working example adapted from a real in-tree usage, and its honest status: wired and used in-tree (something in this package binds into it, so it is exercised on every install) or wired, no in-tree binding (the seam is live and tested, but nothing here uses it yet).


Two rules that apply everywhere

Registry alias, not class name

The engine rejects fully qualified class names in definition JSON

dataSource.provider and dataSource.config.collection take registered aliases, not class names. A registry resolves an alias it knows; anything else raises UnknownAliasException naming the alias and listing the ones that are registered.

{
  "dataSource": {
    "provider": "grid.collection",
    "config": { "collection": "acme_blog.post.collection" }
  }
}
{
  "dataSource": {
    "provider": "grid.collection",
    "config": { "collection": "Acme\\Blog\\Model\\ResourceModel\\Post\\Collection" }
  }
}

The second form is rejected at render time, and the health check only half covers it. bin/magento nebula:doctor reports Grid collections (disallowed FQCN) for a collection value that contains a backslash and starts with none of the namespaces in its allowedCollectionNamespaces argument, which is Magento\, Nebula\, Qoliber\ and Siteation\ by default and is bound on Nebula\Component\Console\Command\DoctorCommand. A class name inside one of those three namespaces is not counted, so doctor exits 0 on it. nebula:lint does not catch it either, because the grid schema types dataSource.config.collection as a plain string. Such a definition fails at render instead: Nebula\Grid\Model\DataProvider\CollectionProvider catches the UnknownAliasException, logs NebulaGrid: unknown collection alias and returns zero rows, so the grid renders empty with no visible error and no failed request.

Widen or tighten the gate through that same argument. A vendor that namespaces its own collections adds its prefix; an install that wants every FQCN reported removes the entries.

<type name="Nebula\Component\Console\Command\DoctorCommand">
    <arguments>
        <argument name="allowedCollectionNamespaces" xsi:type="array">
            <item name="acme" xsi:type="string">Acme\</item>
        </argument>
    </arguments>
</type>

The indirection is what makes a collection swappable: a merchant or a Pro package rebinds the alias in di.xml and every definition naming it follows, with no JSON edit.

Source: code/Nebula/Grid/Model/Registry/CollectionRegistry.php, code/Nebula/Component/Model/Registry/AbstractRegistry.php, code/Nebula/Component/Console/Command/DoctorCommand.php.

Bind class names, not objects

Alias registries take xsi:type="string" and resolve through the ObjectManager on first use:

<item name="acme.thing" xsi:type="string">Acme\Module\Model\Thing</item>

An xsi:type="object" binding still works, but it forfeits the laziness. Magento resolves the object items of a DI array argument when it builds the type that owns the argument, not when something reads them — so an object-bound registry constructs every binding in the estate on every page that touches the registry at all. That is a slow page right up until one binding depends on a module the install has disabled, at which point it is a fatal one on a page that never asked the question.

The pools that take xsi:type="object" (renderer pool, media capability pool, menu badge and dashboard action providers) do so because their contents are asked for on the page that owns them.

Source: code/Nebula/Component/Model/Registry/AbstractRegistry.php, code/Nebula/Component/Model/SnippetViewModelRegistry.php.

Declare registries in etc/di.xml, never in an area file

Every registry on this page is an array argument that many modules contribute to. Magento merges the di.xml files of one area deeply, but an argument declared in etc/adminhtml/di.xml replaces the merged global declaration of that argument for adminhtml requests. One module declaring fieldRenderers under etc/adminhtml/ therefore wipes every field type registered globally by anyone else — Nebula's own included — and a module declaring DataProviderRegistry bindings there would empty every Nebula grid.

Measured on 2026-09-13, when an extension's custom field type rendered as a text input: the merged adminhtml configuration held one registration and nothing else, because a module then in the package had declared fieldRenderers in an area file. Nebula's own modules now declare all eight engine registries globally only — RendererPool, DataProviderRegistry, OptionSourceRegistry, FilterOptionSourceRegistry, SaveHandlerRegistry, ReferenceResolverRegistry, CollectionRegistry and Translations's providers — and a repository test forbids the area form. For modules on an install, bin/magento nebula:doctor prints a red Registries declared in an area di.xml line naming the module, the area and the type.

The rule holds for any array argument another module also contributes to — the menu icon registry, the snippet view model registry, a capability pool. Put it in etc/di.xml.

Source: code/Nebula/Component/Model/Diagnostics/AreaRegistryCheck.php, code/Nebula/Component/Test/Unit/Etc/RegistriesAreDeclaredGloballyTest.php.

The Api\V1 versioning policy

Every public namespace lives under Api\V1 — Nebula\Component\Api\V1\ColumnRendererInterface, Nebula\Grid\Api\V1\Data\GridViewInterface, and so on. Existing sub-namespaces nest inside (Api\V1\Data).

The reason is that a breaking change to a versioned contract is additive: a V2 ships beside V1 and consumers move when they are ready. Unversioned, the first breaking change to a public interface forces a major version of the whole package.

The cost accepted with it is that V1 on an internal seam implies a stability promise it may not need. Two boundaries are deliberate:

  • CRUD generator output is not versioned. The generator scaffolds interfaces in your module's own vendor namespace. That is your contract, not Nebula's, and nobody ships a V2 of a generated interface. Templates emit plain Api\.
  • There are no class_alias shims. Pre-1.0 there are no external implementers, and an alias layer nobody needs is a second convention to explain.

Every interface on this page is a supported third-party contract. Treat the Api\V1 namespace itself as the stability signal, not the @api annotation. The annotation is applied unevenly in the current tree, where 11 of the 51 Api\V1 interfaces carry it, so its absence on a seam documented here does not mean the seam is internal. Inside Nebula\Media\Api\V1, MediaCapabilityInterface, MediaThumbnailProviderInterface and MediaPathSubscriberInterface carry @api while MediaUsageSourceInterface, documented in the same table below, does not.

One seam on this page sits outside Api\V1 altogether, the rich-text editor renderer, and carries no versioning promise. Its entry says so.


Rendering

Grid column renderers

What it is. A grid column "type" maps to a renderer that knows its Alpine component name, its template, and how to render one cell.

Contract. Nebula\Component\Api\V1\ColumnRendererInterface — getComponentName(): string, getTemplate(): string, render(array $column, string $key, array $item): string. Registered on the columnRenderers argument of Nebula\Component\Model\RendererPool.

Example (the in-tree seeding is code/Nebula/Grid/etc/di.xml, which registers ten built-in types and a default):

<type name="Nebula\Component\Model\RendererPool">
    <arguments>
        <argument name="columnRenderers" xsi:type="array">
            <item name="sparkline" xsi:type="object">Acme\Blog\Renderer\Column\SparklineRenderer</item>
        </argument>
    </arguments>
</type>
{ "views": { "label": "Views", "type": "sparkline", "sortable": false, "position": 40 } }

A registered type renders, but the definition naming it will not lint

grid.schema.json closes a column's type to the ten built-ins, so "type": "sparkline" fails bin/magento nebula:lint. --no-strict does not help: the flag is reserved and relaxes nothing today, and a closed enum would not be relaxed by it in any case.

If CI has to pass, omit type and render the cell through a snippet instead, with "renderer": "snippet.sparkline". Take the DI-bound renderer when you want a reusable type in PHP and can live with excluding your own module's definitions from the lint step. See Custom column renderer for the worked pair.

Status. Wired and used in-tree — text, select, badge, boolean, price, date, thumbnail, link, actions and html are all registered this way, with TextRenderer as defaultColumnRenderer.

Source: code/Nebula/Grid/etc/di.xml, code/Nebula/Component/Api/V1/ColumnRendererInterface.php.

Form field types

What it is. A form field "type" maps to a template and an Alpine component.

Contract. Either Nebula\Component\Api\V1\FieldRendererInterface (getComponentName(), getTemplate()) for a type with real behaviour, or — when the type is just a template and a component name — a virtual type of Nebula\Component\Model\Registry\FieldType with no PHP class at all. Registered on the fieldRenderers argument of RendererPool.

Example: register a custom rating field in etc/di.xml, not an area file — see the rule above:

<virtualType name="Acme\Blog\Model\Registry\RatingFieldType"
             type="Nebula\Component\Model\Registry\FieldType">
    <arguments>
        <argument name="componentName" xsi:type="string">nebulaField_rating</argument>
        <argument name="template" xsi:type="string">Acme_Blog::field/rating.phtml</argument>
    </arguments>
</virtualType>

<type name="Nebula\Component\Model\RendererPool">
    <arguments>
        <argument name="fieldRenderers" xsi:type="array">
            <item name="rating" xsi:type="object">Acme\Blog\Model\Registry\RatingFieldType</item>
        </argument>
    </arguments>
</type>

The pool's fieldRenderers array ships empty on purpose: NebulaForm resolves its built-in types itself, and this map is where everything else registers.

There is a second, older map

NebulaForm\Block\Form::$fieldTemplates is a public constructor argument that does the same job for forms only. It still functions, but the engine's registry is the one anything outside a form can see, and it wins when a type is registered both ways. Register on RendererPool.

A registered type lints

form.schema.json keeps its enum of the seventeen built-in types and, since 2026-09-13, also accepts a lower-case identifier (^[a-z][a-z0-9_]*$) for a type a module registered through RendererPool, so "type": "rating" passes bin/magento nebula:lint. The cost is that a typo in a built-in name passes too — the engine falls back to text for a type no renderer answers, and the page is the place that shows it. "renderer": "snippet.rating" remains the route for a one-off template that is not a reusable type.

What your template receives. form.phtml does not build a block for a field. It sets local variables and includes your partial, so there is no view model and no getData():

Variable What it holds
$fKey The field key from the definition.
$field The whole field node, including label and validation.
$fieldType The resolved type string.
$fieldName The POST name, such as general[title].
$fieldId The DOM id.
$fieldValue The current stored value.
$isRequired, $isDisabled, $isReadonly The resolved flags, with disableOnEdit and readonlyOnEdit already applied.

$block is the Nebula\Form\Block\Form, and $escaper and $isNew are in scope too.

A field that registers no model posts nothing, and says nothing

Nebula field templates write no hidden inputs of their own. On submit, Alpine.store('nebulaModels').serializeAll(form) injects them from the registered models. A component that drops the base init() or serialize() registers no model, so its value is simply absent from the POST. There is no console error and no validation message.

Making the value post. Two ways, both used in-tree.

The first is a plain named input. Write an ordinary control with name="<fieldName>" and the native form posts it. The shipped file field does exactly that, with <input type="file" name="<fieldName>"> and no Alpine component at all.

The second is an Alpine model. Render the control inside an x-data naming your component and hand it the same config bag field/text.phtml builds. Extra keys pass through, and default is honoured as a fallback for value:

view/adminhtml/templates/field/rating.phtml
<?php
$config = json_encode([
    'fieldName' => $fieldName,
    'value' => (string) ($fieldValue ?? ''),
    'disabled' => $isDisabled,
    'required' => $isRequired,
    'validation' => $field['validation'] ?? [],
], JSON_UNESCAPED_SLASHES);
?>
<div x-data="nebulaField_rating(<?= $escaper->escapeHtmlAttr((string) $config) ?>)">
    <input type="hidden" x-model="value">
</div>

Then register a component under that exact name. window.NebulaField.base(config) is installed on every admin page by nebula-core.js and hands back a ready object:

view/adminhtml/web/js/rating-field.js
const register = () => window.Alpine.data('nebulaField_rating', (config) => Object.assign(
    window.NebulaField.base(config),
    { stars: [1, 2, 3, 4, 5] }
));

document.addEventListener('alpine:init', register);
if (window.Alpine) {
    register();
}

It carries value, fieldName, disabled, required, validation and error, plus four methods:

Method What it does
init() Registers the model on the store under 'field:' + fieldName.
serialize() Returns [{name: fieldName, value}], or [] when the field is disabled or has no fieldName.
validate() Returns true for a disabled field. Otherwise runs Nebula.validateField() on a [data-validate] descendant, falling back to Nebula.validateValue(value, validation).
destroy() Unregisters the model.

Object.assign on top of the base is the safe shape, because your own state is added and all four methods survive. A literal that spreads only some of them is what produces the silent empty POST above.

A model can also be registered by hand without the factory. The store methods are register(name, model) and unregister(name), the key is 'field:' followed by the POST name, and the model owes serialize() plus an optional validate() and destroy(). See CSS and JavaScript in your module.

componentName is a declaration, not a binding

Block\Form::getFieldTemplateOverride() reads only RendererPool::getFieldTemplate(). Nothing in the form path calls getFieldComponent(), so the componentName argument on a FieldType virtual type never reaches the browser. What the browser resolves is whatever your template writes in x-data. Keep the two identical by convention.

Status. Available for extension modules. No module in the metapackage registers a field type, so fieldRenderers is empty on a stock install and every type a form asks for is one of the sixteen NebulaForm ships.

pagebuilder is in the schema enum and renders as text

form.schema.json still lists pagebuilder among the built-in types, left over from Nebula_PageBuilder, which was removed from the free package. Nothing registers it and NebulaForm ships no field/pagebuilder.phtml, so a field declaring it lints and renders as a text input. Do not write it into a new definition.

Source: code/Nebula/Component/etc/di.xml, code/Nebula/Component/Api/V1/FieldRendererInterface.php, code/Nebula/Form/view/adminhtml/templates/form.phtml, code/Nebula/Form/view/adminhtml/templates/field/text.phtml.

The rich-text editor

What it is. What a wysiwyg field renders as. Swapping it replaces the editor everywhere a Nebula form asks for one, with no definition edit.

Contract. Nebula\Form\Model\Field\WysiwygRendererInterface, one method returning the complete HTML of the control:

public function render(
    string $fieldId,       // the element id
    string $fieldName,     // the POST field name
    string $value,         // the current stored value
    string $json,          // the pre-encoded config blob for the Alpine component
    string $validateAttr,  // the pre-built validation attribute string
    string $inputClass     // the CSS class for the input
): string;

Unlike every other seam on this page it is swapped by <preference>, not by a registry binding. code/Nebula/Form/etc/di.xml declares the global default, DefaultWysiwygRenderer, which is a plain nebulaField_textarea textarea. A module overrides it in its own etc/adminhtml/di.xml:

<!-- etc/adminhtml/di.xml -->
<preference for="Nebula\Form\Model\Field\WysiwygRendererInterface"
            type="Acme\Editor\Model\Field\AcmeWysiwygRenderer"/>

Nebula_Quill is the worked in-tree case, and it shows the manners expected of an override: QuillWysiwygRenderer delegates back to DefaultWysiwygRenderer whenever cms/wysiwyg/editor is not set to its own adapter. Keep a fallback of your own.

This contract is outside Api\V1

It lives in Model\Field and carries no @api annotation, so it has no versioning promise. It is the one exception to both rules stated at the top of this page.

Status. Wired and used in-tree. The default ships, and Nebula_Quill overrides it.

Source: code/Nebula/Form/etc/di.xml, code/Nebula/Form/Model/Field/WysiwygRendererInterface.php, code/Nebula/Quill/etc/adminhtml/di.xml.

EAV field type mapping

What it is. Which Nebula field type an EAV attribute's frontend_input renders as, on EAV forms (product, category, customer).

Contract. The frontendInputMap constructor argument on Nebula\Form\Model\Field\EavFieldTypeMapper. Your map is merged over the built-in defaults (text, textarea, select, multiselect, boolean → toggle, date, datetime → date, price/weight → number, media_image → image, gallery → skip, hidden), with text as the fallback for anything unknown.

<type name="Nebula\Form\Model\Field\EavFieldTypeMapper">
    <arguments>
        <argument name="frontendInputMap" xsi:type="array">
            <item name="swatch_visual" xsi:type="string">swatch</item>
        </argument>
    </arguments>
</type>

A per-field "type" override in the form definition beats the map entirely.

Status. Wired, no in-tree binding — the defaults live in the class, and no shipped module overrides them.

Source: code/Nebula/Form/Model/Field/EavFieldTypeMapper.php.


Data

Grid and form data providers

What it is. The alias in dataSource.provider naming what fetches rows or an entity.

Contract. Nebula\Component\Api\V1\DataProviderRegistryInterface, implemented by Model\Registry\DataProviderRegistry; providers implement Api\V1\GridDataProviderInterface or Api\V1\FormDataProviderInterface.

<type name="Nebula\Component\Model\Registry\DataProviderRegistry">
    <arguments>
        <argument name="bindings" xsi:type="array">
            <item name="acme.blog.stats" xsi:type="string">Acme\Blog\Model\DataProvider\StatsProvider</item>
        </argument>
    </arguments>
</type>

Status. Wired and used in-tree — grid.collection (the generic collection-backed provider that gives any grid pagination, sort, search and filtering for free) and nebula.grid.row (the inline-edit row provider) are both registered this way, and six more modules add their own.

Source: code/Nebula/Grid/etc/di.xml, code/Nebula/Store/etc/di.xml, code/Nebula/User/etc/di.xml.

Grid collections

What it is. The alias in dataSource.config.collection, resolved to a collection class.

Contract. Nebula\Grid\Api\V1\CollectionRegistryInterface, implemented by Nebula\Grid\Model\Registry\CollectionRegistry. It also exposes register(string $alias, string $className) for runtime registration.

<type name="Nebula\Grid\Model\Registry\CollectionRegistry">
    <arguments>
        <argument name="bindings" xsi:type="array">
            <item name="acme_blog.post.collection"
                  xsi:type="string">Acme\Blog\Model\ResourceModel\Post\Collection</item>
        </argument>
    </arguments>
</type>

Status. Wired and used in-tree — seeded from NebulaTheme, NebulaStore, NebulaUser, NebulaSystem, NebulaReports, NebulaLandingPage and NebulaCrudFixture.

Source: code/Nebula/Grid/etc/di.xml, code/Nebula/CrudFixture/etc/di.xml.

Option sources

What it is. The class behind a field's optionsSource (form selects) or a column's filterOptionsSource (grid filters).

Contract. Nebula\Component\Api\V1\OptionSourceRegistryInterface and Api\V1\FilterOptionSourceRegistryInterface. A source is any class with the method the definition names in optionsMethod, defaulting to toOptionArray().

<type name="Nebula\Component\Model\Registry\OptionSourceRegistry">
    <arguments>
        <argument name="bindings" xsi:type="array">
            <item name="acme.blog.category" xsi:type="string">Acme\Blog\Model\Source\Category</item>
        </argument>
    </arguments>
</type>

Adapted from code/Nebula/Store/etc/adminhtml/di.xml, which binds nebula.store.group.

Verify the binding with the linter

A missing alias and a misspelled optionsMethod both render as a select with no options, with no error anywhere. bin/magento nebula:lint:options resolves every alias in every definition and checks the method exists — run it in CI. See CLI reference.

Status. Wired and used in-tree, in both registries, from NebulaStore, NebulaTheme, NebulaSystem, NebulaReports and NebulaComponent itself.

Source: code/Nebula/Store/etc/adminhtml/di.xml, code/Nebula/Component/etc/adminhtml/di.xml.

Selection picker sources

What it is. A searchable source of selectable items for the nebulaSelection picker, served by two generic endpoints under the nebula front name.

Contract. Nebula\Component\Api\V1\SelectionSourceInterface — search(string $query, int $page, int $pageSize, array $exclude): array, resolve(array $values): array to render already-chosen values, and getAclResource(): string, which both endpoints check before answering. An unknown source alias is denied.

<type name="Nebula\Component\Model\Selection\SelectionSourceRegistry">
    <arguments>
        <argument name="sources" xsi:type="array">
            <item name="acme_blog_post" xsi:type="object">Acme\Blog\Model\Selection\PostSource</item>
        </argument>
    </arguments>
</type>

The two endpoints. Registering a source serves both at once.

Endpoint Calls Returns
nebula/selection/search?source=<alias>&q=&page=&limit=&exclude= search() { items, hasMore }. The controller fetches one row past limit to compute hasMore. limit defaults to 50 and is capped at 100.
nebula/selection/resolve?source=<alias>&values=a,b,c resolve() { items }, turning stored values back into labelled rows for an already-populated control.

The interface parameter is named $pageSize but the query parameter is limit. In-tree, resolve is what the rule editor uses to show saved SKUs as product names.

A grid filter: "picker" does not call the resolve endpoint for its closed-control label: Nebula\Grid\Model\PickerLabelResolver calls the same source's resolve() server-side while rendering, and shows "no longer listed" when a value resolves to nothing.

One legacy endpoint survives

nebula/grid/productSearch?q=&limit=&exclude= is a product-only autocomplete kept for back-compat. It delegates to the same ProductSource, defaults limit to 20 and caps it at 100, and is still fetched by the bundle-options wizard. New code uses nebula/selection/search with the catalog_product source, which is what snippet/product_selector.phtml already does.

Status. Wired and used in-tree. Three aliases ship: catalog_product from NebulaGrid, catalog_category from NebulaTheme, and nebula_landing_page from NebulaLandingPage, whose Model\Selection\LandingPageSource is what the hub field on the landing-page form searches through the snippet.landing_page_hub renderer. The alias string is what goes in a picker filter's filterSource, so "a landing-page source" is not something you can type there.

Source: code/Nebula/Grid/etc/adminhtml/di.xml, code/Nebula/Theme/etc/adminhtml/di.xml, code/Nebula/LandingPage/etc/adminhtml/di.xml, code/Nebula/Component/Api/V1/SelectionSourceInterface.php.

Entity references

What it is. Which of a component's stored fields hold a reference to another entity, so export and import can rewrite ids without the exporter knowing the component exists.

Contract. Nebula\Component\Api\V1\ReferenceResolverInterface, three methods:

Method Used by Returns
getSupportedMatches(): array nebula:lint The stable keys this resolver understands, such as ['url_key', 'path']. A match outside the list is rejected.
toKey(int\|string $id, string $match): ?string Export The stable key, or null when the entity no longer exists.
toId(string $key, string $match): int\|string\|null Import The local id, or null when nothing on this install matches.

Neither toKey() nor toId() throws. The caller reports the null rather than writing an unresolvable key or a dangling id.

Bind on Nebula\Component\Model\Registry\ReferenceResolverRegistry under the alias a definition uses as type. Put it in your module's global etc/di.xml, because the resolvers serve the CLI linter and the export and import paths, not only adminhtml. An etc/adminhtml/di.xml declaration would replace the global bindings array rather than extend it:

<!-- etc/di.xml -->
<type name="Nebula\Component\Model\Registry\ReferenceResolverRegistry">
    <arguments>
        <argument name="bindings" xsi:type="array">
            <item name="catalog_category" xsi:type="string">Acme\Blog\Model\Reference\CategoryResolver</item>
        </argument>
    </arguments>
</type>

The declaration lives in the component's own JSON:

"references": {
  "category_id": { "type": "catalog_category", "match": "url_key" },
  "categories":  { "type": "catalog_category", "match": "url_key", "many": true },
  "identifier":  { "type": "cms_block",        "match": "identifier" },
  "image":       { "type": "media",            "match": "path" }
}

match names the stable key the id is rewritten to, and must be one the resolver returns from getSupportedMatches(). nebula:lint fails on a type no resolver is registered for and on a match no resolver supports, so a declaration cannot sit quietly unbacked.

What it deliberately does not cover. A field whose stored value is a directive string ({{widget …}}, {{customVar …}}) or rich text containing one carries its ids inside the directive, not in the field value, so no field-level declaration can remap it. Widget, variable, raw-HTML and WYSIWYG fields are in that class and need a directive-level pass.

Status. Wired, no in-tree binding — no resolver ships in this package, and the linter is what keeps the seam from decaying.

Source: code/Nebula/Component/Api/V1/ReferenceResolverInterface.php, code/Nebula/Component/etc/di.xml, code/Nebula/Component/Model/Component/ReferenceValidator.php.


Forms

Save handlers

What it is. A form declares "saveHandler": "acme.thing" and your module owns persistence. Without one, the pipeline saves through dataSource.repository.

Contract. Nebula\Component\Api\V1\FormSaveHandlerInterface — save(array $data, array $context = []): array, returning at least entityId. Registered on Model\Registry\SaveHandlerRegistry.

<type name="Nebula\Component\Model\Registry\SaveHandlerRegistry">
    <arguments>
        <argument name="bindings" xsi:type="array">
            <item name="acme.blog.post" xsi:type="string">Acme\Blog\Model\Form\PostSaveHandler</item>
        </argument>
    </arguments>
</type>

Either way, SavePipeline dispatches nebula_form_save_before and nebula_form_save_after around the persistence step, and what a _before observer leaves in setData() is what gets saved. SavePipeline::around() is a third entry point: an entity-specific save controller that owns its own persistence hands the pipeline a callback and gets the events without inventing a save handler.

Status. Wired, no in-tree binding — the registry ships empty, because every shipped surface has an entity-specific save controller. The seam is live for third-party forms and for CRUD generator output, and the events fire for the shipped controllers that route through SavePipeline::around().

Source: code/Nebula/Component/etc/di.xml, code/Nebula/Component/Api/V1/FormSaveHandlerInterface.php, code/Nebula/Form/Model/Form/SavePipeline.php.

Record pagers

What it is. The previous / next record arrows on an edit form. What "previous" and "next" mean is the implementation's decision — usually the order of the grid the user came from.

Contract. Nebula\Form\Api\V1\RecordPagerInterface — getPrevId(string $currentId): ?string, getNextId(string $currentId): ?string. Bound by alias on Nebula\Form\Model\Registry\RecordPagerRegistry, and named from the form definition as settings.pager.provider.

<type name="Nebula\Form\Model\Registry\RecordPagerRegistry">
    <arguments>
        <argument name="bindings" xsi:type="array">
            <item name="acme_post" xsi:type="string">Acme\Blog\Model\RecordPager\PostRecordPager</item>
        </argument>
    </arguments>
</type>

Status. Wired and used in-tree — NebulaTheme binds review.

Source: code/Nebula/Theme/etc/di.xml, code/Nebula/Form/Api/V1/RecordPagerInterface.php.

Field validators

What it is. Server-side validation for one field, beyond the rules the validation object already expresses.

Contract. Nebula\Form\Api\V1\FieldValidatorInterface, one method: validate(string $field, mixed $value, array $rules): ?string. Return null when the value is valid, or the message string to fail the field. $field is the field key and $rules is the whole validation object for that field, so a validator can read its own extra keys out of it.

Binding. None. This is the one seam on the page with no registry and no alias: Nebula\Form\Model\Validator resolves the raw class name from validation.custom through the ObjectManager, so the class needs no di.xml entry at all.

{
  "vat_id": {
    "label": "VAT ID",
    "validation": { "required": true, "custom": "Acme\\Tax\\Model\\Validator\\VatId" }
  }
}

validation.custom is therefore the one definition key that legitimately carries a class name rather than an alias. The rule about registry aliases at the top of this page applies to dataSource, not to this.

A wrong class name here fails open

A class named in validation.custom that does not implement FieldValidatorInterface is skipped in silence, and the field simply passes validation. A typo produces no error.

Status. Wired, no in-tree binding. No shipped form declares validation.custom. See Saving and validation.

Source: code/Nebula/Form/Api/V1/FieldValidatorInterface.php, code/Nebula/Form/Model/Validator.php.

Field-level ACL

What it is. Attributes that a role must hold a specific ACL resource to write, enforced on the form and on the API and import paths.

Contract. The protectedAttributes argument on Nebula\Form\Model\Field\FieldAclGuard — a map of ACL resource id to the attribute codes it protects.

<type name="Nebula\Form\Model\Field\FieldAclGuard">
    <arguments>
        <argument name="protectedAttributes" xsi:type="array">
            <item name="Acme_Blog::post_publish" xsi:type="array">
                <item name="published_at" xsi:type="string">published_at</item>
                <item name="is_featured" xsi:type="string">is_featured</item>
            </item>
        </argument>
    </arguments>
</type>

Use attribute codes, not form field ids: the API and import paths never see a form, and those are exactly the paths a form-only gate misses.

Status. Wired and used in-tree — Nebula_Roles protects six price attributes and two inventory attributes this way. See ACL resources.

Source: code/Nebula/Roles/etc/di.xml.


Chrome and surfaces

Snippet view models

What it is. The ViewModel a snippet's template receives as view_model.

Contract. Nebula\Component\Model\SnippetViewModelRegistry, keyed by snippet id.

<type name="Nebula\Component\Model\SnippetViewModelRegistry">
    <arguments>
        <argument name="viewModels" xsi:type="array">
            <item name="acme_blog_stats" xsi:type="string">Acme\Blog\ViewModel\Stats</item>
        </argument>
    </arguments>
</type>

Adapted from code/Nebula/Roles/etc/di.xml (role_template_picker). Registering the same key from a later module replaces the binding, which is how a Pro package takes a tab over from the free one.

Status. Wired and used in-tree — around forty bindings across NebulaTheme, NebulaRoles, NebulaStore, NebulaUser, NebulaLandingPage, NebulaMerchandiser and NebulaPasskey.

Source: code/Nebula/Roles/etc/di.xml, code/Nebula/Merchandiser/etc/di.xml, code/Nebula/Passkey/etc/di.xml.

What it is. The live count badge next to a sidebar menu item.

Contract. Nebula\Core\Api\V1\MenuBadgeProviderInterface — getBadges(): array, keyed by menu resource id. Bound on the badgeProviders argument of Nebula\Core\Block\Menu.

<type name="Nebula\Core\Block\Menu">
    <arguments>
        <argument name="badgeProviders" xsi:type="array">
            <item name="draft_posts" xsi:type="object">Acme\Blog\Model\MenuBadge\DraftPostsBadge</item>
        </argument>
    </arguments>
</type>

Status. Wired and used in-tree — NebulaTheme registers pending_reviews.

Dashboard action cards

What it is. A "needs attention" card on the Nebula dashboard — an actionable count with a link.

Contract. Nebula\Core\Api\V1\DashboardActionProviderInterface — getActions(): array. Bound on the actionProviders argument of Nebula\Core\ViewModel\Dashboard.

<type name="Nebula\Core\ViewModel\Dashboard">
    <arguments>
        <argument name="actionProviders" xsi:type="array">
            <item name="draft_posts" xsi:type="object">Acme\Blog\Model\DashboardAction\DraftPostsAction</item>
        </argument>
    </arguments>
</type>

Status. Wired and used in-tree — NebulaTheme registers pending_reviews.

Source: code/Nebula/Theme/etc/adminhtml/di.xml, code/Nebula/Core/Api/V1/MenuBadgeProviderInterface.php, code/Nebula/Core/Api/V1/DashboardActionProviderInterface.php.

What it is. The icon rendered beside every sidebar menu item, keyed by menu resource id.

Contract. Nebula\Core\Model\Menu\MenuIconRegistry. Two arguments matter: icons adds or overrides an icon, renderers adds a whole icon type (Nebula\Core\Api\V1\MenuIconRendererInterface). fontawesome and image ship.

<type name="Nebula\Core\Model\Menu\MenuIconRegistry">
    <arguments>
        <argument name="icons" xsi:type="array">
            <item name="Acme_Blog::posts" xsi:type="array">
                <item name="class" xsi:type="string">fa-solid fa-rocket</item>
            </item>
            <item name="Acme_Blog::brand" xsi:type="array">
                <item name="type" xsi:type="string">image</item>
                <item name="src"  xsi:type="string">Acme_Blog::images/logo.svg</item>
                <item name="alt"  xsi:type="string">Acme</item>
            </item>
        </argument>
        <argument name="renderers" xsi:type="array">
            <item name="inline-svg" xsi:type="object">Acme\Blog\Model\Menu\InlineSvgRenderer</item>
        </argument>
    </arguments>
</type>

No JS rebuild and no template edit — picked up on the next request after cache:clean config. Unmapped items fall back to fa-solid fa-circle-dot.

Status. Wired and used in-tree — the whole stock menu is iconified this way.

Source: code/Nebula/Core/etc/adminhtml/di.xml.

The customer grid

What it is. Adding a filterable column to the admin customer grid.

Contract. Nebula\Theme\Api\V1\CustomerGridExtensionInterface, three methods, all required. Collection is Magento\Customer\Model\ResourceModel\Customer\Collection.

Method When it runs
extend(Collection $collection): void Once per grid load, after the shipped joins and before any filter or sort, so an alias added here is available to both.
handlesFilter(string $field): bool Per filter field, extensions in binding order. The first to claim a field wins.
applyFilter(Collection $collection, string $field, mixed $value): void Only for a field this extension claimed. Everything unclaimed falls through to addAttributeToFilter.

An extension that contributes a display-only column still has to implement the last two, returning false and doing nothing. Omitting them is a PHP fatal the moment the class loads, which on a Magento install usually surfaces as setup:di:compile failing.

Bound on the extensions argument of Nebula\Theme\Model\DataProvider\CustomerGridProvider.

<type name="Nebula\Theme\Model\DataProvider\CustomerGridProvider">
    <arguments>
        <argument name="extensions" xsi:type="array">
            <item name="acme_loyalty" xsi:type="object">Acme\Loyalty\Model\Grid\LoyaltyColumns</item>
        </argument>
    </arguments>
</type>

The seam exists because the provider builds its collection from customer attributes, which is a closed door to anything that is not one. Filtering has to happen in the query, so it has to happen inside the provider — the alternatives are replacing the provider by DI preference (breaking the next package that does the same), post-processing the rows (losing filtering and pagination), or copying it.

extend() runs on every load of the customer grid, including the ones nobody filtered. Joining a table is fine; a query per row is not.

It is named for one grid on purpose

There is no generic "extend any Nebula grid" contract yet. One can be lifted out of this when a second grid asks for it, and will be better for having two cases to answer to.

Status. Wired and used in-tree — the passkey package adds two security columns through it.

Source: code/Nebula/Theme/Api/V1/CustomerGridExtensionInterface.php, code/Nebula/Passkey/etc/di.xml.

Widget choosers

What it is. The Pick… button beside a widget parameter in the widget instance wizard, and the modal or inline editor behind it.

Nebula does not render Magento's widget.xml chooser block. Nebula\Theme\ViewModel\Widget\ParameterTranslator sees a parameter declared with xsi:type="block", reads that block's class name, and maps it to a Nebula chooser alias through WidgetChooserRegistry::aliasForHelperBlock(), falling back to the alias generic when nothing matches. A third-party widget whose widget.xml declares a chooser block needs a registry binding, or the wizard renders a disabled placeholder instead of a button.

Contract. Nebula\Theme\Api\V1\WidgetChooserViewModelInterface, which extends Magento\Framework\View\Element\Block\ArgumentInterface:

  • search(string $query, int $page, int $pageSize): array returning ['total' => int, 'rows' => list<array>], which populates the chooser's row list;
  • getByValue(string $value): ?array, which the wizard calls on edit to turn a stored value back into a readable label, trying title, label and name and appending identifier or sku.

The format of a row's value is owned by the implementation, whether that is a bare id or a category/<id> path, and it is what getByValue() is handed back.

Binding. The choosers argument on Nebula\Theme\Model\Registry\WidgetChooserRegistry, keyed by alias, in your own module's etc/adminhtml/di.xml:

<type name="Nebula\Theme\Model\Registry\WidgetChooserRegistry">
    <arguments>
        <argument name="choosers" xsi:type="array">
            <item name="acme_post" xsi:type="array">
                <item name="helper_block" xsi:type="string">Acme\Blog\Block\Adminhtml\Post\Widget\Chooser</item>
                <item name="template" xsi:type="string">Acme_Blog::widget/chooser/post.phtml</item>
                <item name="view_model" xsi:type="string">Acme\Blog\ViewModel\WidgetChooser\Post</item>
                <item name="mode" xsi:type="string">modal</item>
            </item>
        </argument>
    </arguments>
</type>

template and view_model are required. helper_block is the legacy chooser block FQCN that identifies this chooser type in a widget.xml parameter, and it is omitted only for an alias matched some other way, as the shipped conditions alias is. mode is modal by default.

View models resolve lazily through Magento's TMap, which enforces WidgetChooserViewModelInterface at lookup time, so a mis-registered view_model fails loudly with a typed exception rather than rendering the wrong thing.

The JavaScript contract for mode: "modal". The wizard's Pick… button renders only when the alias is in availableChoosers, which the block fills from WidgetChooserRegistry::aliases(). Registering in di.xml is all it takes to appear. From there:

  1. The button dispatches a window event named open-chooser- plus the alias with its first underscore replaced by a dash, so cms_page becomes open-chooser-cms-page, carrying detail: {fieldName}. Only the first underscore is rewritten, so an alias with two of them produces an event name a dashed listener never matches. Keep an alias to one underscore.
  2. Your template listens with @open-chooser-<alias>.window="open = true; fieldName = $event.detail.fieldName".
  3. On selection it dispatches chooser:selected with detail: {fieldName, value, label}.
  4. The wizard root listens on the window with @chooser:selected.window="handleChooserSelected($event.detail)", writes value into the parameter and label into the displayed text.

A fieldName prefixed pg:<rowIndex>:<page|entities> addresses a page-group row rather than a parameter. Reuse that convention rather than inventing another.

mode: "inline" renders your template inside the parameter row with field and state in Alpine scope and uses no events at all. The shipped conditions rule editor is the in-tree example.

Status. Wired and used in-tree. cms_block, cms_page, catalog_category and catalog_product ship as modal choosers, and conditions as an inline one.

Source: code/Nebula/Theme/Api/V1/WidgetChooserViewModelInterface.php, code/Nebula/Theme/Model/Registry/WidgetChooserRegistry.php, code/Nebula/Theme/etc/adminhtml/di.xml.

Widget container fallback

What it is. The Container dropdown in the Layout Updates section of a widget instance. A widget type whose widget.xml declares a <containers> block offers exactly those containers. One that declares none falls back to a curated storefront list.

Contract. The containers argument on Nebula\Theme\Model\Registry\WidgetContainerFallbackRegistry, read by Nebula\Theme\ViewModel\Widget\ContainerCatalog. Declare it in etc/adminhtml/di.xml, the same scope this package declares its own items in, for the merge reason described under Media below.

<!-- etc/adminhtml/di.xml -->
<type name="Nebula\Theme\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>

The item values, not the item names, are the container references shown in the dropdown. The registry drops non-strings, empty strings and duplicates.

Status. Wired and used in-tree. NebulaTheme seeds twelve: content, content.top, content.bottom, sidebar.main, sidebar.additional, header.panel, header.container, footer, footer-container, page.bottom, before.body.end and after.body.start.

Source: code/Nebula/Theme/etc/adminhtml/di.xml, code/Nebula/Theme/Model/Registry/WidgetContainerFallbackRegistry.php, code/Nebula/Theme/ViewModel/Widget/ContainerCatalog.php.

Insert Variable and Insert Widget

What it is. The two dialogs the rich-text and email editors open from their toolbars. A module adding a widget type does not call these routes. It declares the type in widget.xml as usual, and the type then travels the three widget routes in order.

Four admin routes under the nebuladirective front name, all gated on Magento_Variable::variable:

Route Method Parameters Returns
nebuladirective/variable/index GET none Every store variable, for the Insert Variable list.
nebuladirective/widget/types GET none Every widget type declared in widget.xml on the install, unfiltered, as {code, name, description, placeholderUrl}.
nebuladirective/widget/params GET type required, current_values an optional JSON object That type's parameter form, pre-filled from current_values when a directive is being edited.
nebuladirective/widget/build POST widget_type, parameters[] The {{widget …}} directive text the editor inserts at the cursor.

An empty type raises "Widget type is required." A current_values that does not decode is ignored in silence. All four return {"success": true, "data": …} through Controller/Adminhtml/AbstractJson.php, with a LocalizedException becoming HTTP 400 and anything else becoming a logged HTTP 500.

The editor learns the four from Nebula\Directive\Block\Adminhtml\Assets, which hands them over as variableUrl, widgetTypesUrl, widgetParamsUrl and widgetBuildUrl alongside a formKey.

Source: code/Nebula/Directive/etc/adminhtml/routes.xml, code/Nebula/Directive/Block/Adminhtml/Assets.php, code/Nebula/Directive/Controller/Adminhtml/AbstractJson.php.

Media

Four pools, all on the media surface. Declare your contribution in the same file this package declares the pool's own items in: etc/adminhtml/di.xml for the capability pool, etc/di.xml for the thumbnail and usage pools. Magento merges DI files within a scope and then lets the area scope override the global one argument by argument — it does not merge an array argument across scopes, so declaring in the other scope discards the items already there.

Nothing in this package declares the path subscriber pool, so there are no items to discard there. Use etc/di.xml for it anyway and keep every contributor in that one scope, because two modules split across etc/di.xml and etc/adminhtml/di.xml would override rather than merge.

Pool Contract What it adds Status
MediaCapabilityPool Api\V1\MediaCapabilityInterface — getCode(), isAvailable(), getConfig() A capability the media page advertises to its Alpine component. Used in-tree (insert)
MediaThumbnailPool Api\V1\MediaThumbnailProviderInterface A way to produce a thumbnail. Providers are asked in sortOrder order and the first non-null answer wins. Used in-tree (cache, registered last so a better provider gets first refusal)
Usage\MediaUsagePool Api\V1\MediaUsageSourceInterface Where a media file can be referenced from. Every source is asked and the results merge. Used in-tree (core_content)
MediaPathSubscriberPool Api\V1\MediaPathSubscriberInterface Notification that a media path moved or was deleted, so a module can update its own references. Wired, no in-tree binding
<!-- etc/di.xml -->
<type name="Nebula\Media\Model\Usage\MediaUsagePool">
    <arguments>
        <argument name="sources" xsi:type="array">
            <item name="acme_banners" xsi:type="object">Acme\Banners\Model\Media\BannerUsageSource</item>
        </argument>
    </arguments>
</type>

An empty usage pool is not permissive: it reports an incomplete answer, and every destructive action refuses on incomplete.

Path subscribers in full

Nebula\Media\Model\MediaPathSubscriberPool takes its items on a subscribers argument, and they must be xsi:type="object". The constructor rejects anything that is not a MediaPathSubscriberInterface instance with a LocalizedException naming the item key, so a string binding is not an option here. The pool then re-keys by each subscriber's own getCode(), which makes the DI item name only a merge key.

<!-- etc/di.xml -->
<type name="Nebula\Media\Model\MediaPathSubscriberPool">
    <arguments>
        <argument name="subscribers" xsi:type="array">
            <item name="acme_focal_points" xsi:type="object">Acme\Images\Model\Media\FocalPointSubscriber</item>
        </argument>
    </arguments>
</type>

Api\V1\MediaPathSubscriberInterface declares five methods: getCode(): string, onMoved(array $moves) where each entry is ['from' => …, 'to' => …], onDirectoryMoved(string $from, string $to), onDeleted(array $paths) and onDirectoryDeleted(string $path). Every path is media-relative with no leading slash, and directories carry no trailing slash.

Two contract facts matter more than the signatures. Each callback fires once per operation and never once per file, so a folder rename arrives as one prefix update rather than an enumeration of everything beneath it. And the bytes move before subscribers run, so a subscriber that throws is caught, logged with its code and the failing method, and the operation still reports the success that actually happened. Your own rows are then stale with no replay path in this package, and that log line is the only signal.

The HTTP surface in front of the pools

A module embedding the picker in its own screen talks to the nebulamedia front name. Every action below is gated on Magento_Cms::media_gallery.

Route Method Parameters
nebulamedia/gallery/index GET None. This one is the media library screen, not a JSON endpoint.
nebulamedia/browser/tree GET None. Returns the folder tree.
nebulamedia/browser/contents GET path, the base64 path id.
nebulamedia/browser/upload POST path, plus the multipart file.
nebulamedia/browser/createDirectory POST path, name
nebulamedia/browser/deleteDirectory POST path
nebulamedia/browser/renameDirectory POST path, name
nebulamedia/browser/deleteFile POST file, path
nebulamedia/browser/rename POST file, name, path
nebulamedia/browser/move POST files, target, path
nebulamedia/browser/saveAltText POST file, alt. Returns the single file row.
nebulamedia/browser/trash POST files, path
nebulamedia/browser/restore POST batch, path

The ten POST actions need the admin form key, which the block hands out as formKey alongside the URLs and a defaultPathId. Every mutation that takes a path returns the rebuilt listing for the slice the caller was on, read from page, limit, search, sort and direction on each request. tree and saveAltText are the two that return something else.

Every browser response is the envelope {"success": true, "data": …}. A LocalizedException becomes HTTP 400 with {"success": false, "message": …}, and anything else becomes HTTP 500 with the message swallowed to "An unexpected media error occurred." and the detail logged.

Two routes ship with no URL emitted

Block\Adminhtml\Assets hands the client ten URLs. trash and restore are not among them, so a caller has to build those two routes itself.

Source: code/Nebula/Media/etc/di.xml, code/Nebula/Media/etc/adminhtml/di.xml, code/Nebula/Media/etc/adminhtml/routes.xml, code/Nebula/Media/Block/Adminhtml/Assets.php, code/Nebula/Media/Controller/Adminhtml/Browser/AbstractJson.php, code/Nebula/Media/Api/V1/*.php.

Category merchandising strategies

What it is. A way of ordering the products in a category.

Contract. Nebula\Merchandiser\Api\V1\OrderStrategyInterface — getCode(), getLabel(), order(array $productIds, int $categoryId, int $storeId): array. An implementation is a pure function of the ids it is given: it does not read or write the category itself.

<type name="Nebula\Merchandiser\Model\Strategy\StrategyPool">
    <arguments>
        <argument name="strategies" xsi:type="array">
            <item name="best_sellers" xsi:type="object">Acme\Merch\Model\Strategy\BestSellersStrategy</item>
        </argument>
    </arguments>
</type>

How Apply reaches it. The merchandiser page is nebula/merchandiser/index/id/<categoryId>. The Order by control POSTs {category_id, operation: "strategy", strategy: "<code>"} to nebula/merchandiser/bulk, and Renumber POSTs operation: "normalise" to the same endpoint. A registered strategy is therefore reached by its getCode() over that one route, and an unknown operation raises Unknown merchandising operation. Neither operation adds or removes a product.

The screen's other four routes are nebula/merchandiser/page, which always returns total beside items; nebula/merchandiser/save, which takes the position diff {category_id, moved, added, place, removed} and is the only writer that deletes; nebula/merchandiser/search; and nebula/merchandiser/preferences, which returns the effective clamped preference set rather than what was posted.

Status. Wired and used in-tree — manual ships, and nothing here changes when a package adds more.

Source: code/Nebula/Merchandiser/etc/di.xml, code/Nebula/Merchandiser/Api/V1/OrderStrategyInterface.php, code/Nebula/Merchandiser/Controller/Adminhtml/Merchandiser/Bulk.php.

Email template deletion

What it is. A hook for a module that keeps its own rows keyed to an email_template.template_id, so deleting the template cleans them up.

Contract. Nebula\EmailTemplateEditor\Api\V1\TemplateDeleteCleanerInterface — cleanForTemplate(int $templateId): void. Bound on the cleaners argument of the delete controller.

<type name="Nebula\EmailTemplateEditor\Controller\Adminhtml\Template\Delete">
    <arguments>
        <argument name="cleaners" xsi:type="array">
            <item name="acme_snapshots" xsi:type="object">Acme\Mail\Model\SnapshotCleaner</item>
        </argument>
    </arguments>
</type>

Status. Wired, no in-tree binding — the pool is empty here, and deletion is a plain delete. The seam exists so this package need not know what another package stores.

Source: code/Nebula/EmailTemplateEditor/Api/V1/TemplateDeleteCleanerInterface.php, code/Nebula/EmailTemplateEditor/Controller/Adminhtml/Template/Delete.php.

Email template variables

What it is. The variable palette offered in the email builder for a given template family.

Contract. The builders argument on Nebula\EmailTemplateEditor\Model\Vars\VarBuilderRegistry, keyed by family.

<type name="Nebula\EmailTemplateEditor\Model\Vars\VarBuilderRegistry">
    <arguments>
        <argument name="builders" xsi:type="array">
            <item name="acme_subscription" xsi:type="object">Acme\Mail\Model\Vars\SubscriptionVarBuilder</item>
        </argument>
    </arguments>
</type>

Status. Wired and used in-tree — six builders ship (order, invoice, shipment, creditmemo, customer_account_new, customer_password_reset).

Source: code/Nebula/EmailTemplateEditor/etc/di.xml.


Theme-level seams

Is Nebula the one rendering?

What it is. The single question every Nebula interception asks before emitting Nebula markup. Nebula replaces core admin blocks by DI preference and intercepts core plugins, and those replacements emit nebula-* classes, Alpine x-data and native <details> disclosures — all styled and driven by the Nebula theme's own stylesheet, none of which exists under Magento's admin theme or Mage-OS's. A replacement that runs anyway does not degrade; it renders an unstyled, inert version of a screen that is now gone.

Contract. Nebula\Core\Api\V1\AdminThemeStateInterface — isNebulaRendering(): bool. The default implementation answers true, because a package that ships an admin theme and forces it is always the one rendering. The preference is declared globally, not in adminhtml: a block or plugin that asks can be constructed in any area (a cron rendering an email template reaches admin blocks), and an unbound interface there is a fatal rather than a wrong answer.

public function __construct(
    private readonly \Nebula\Core\Api\V1\AdminThemeStateInterface $themeState
) {
}

public function aroundRender(Subject $subject, callable $proceed): string
{
    if (!$this->themeState->isNebulaRendering()) {
        return $proceed();
    }

    return $this->renderNebulaMarkup();
}

Status. Wired and used in-tree — every interception that emits Nebula markup asks it, and Nebula_ThemeSwitcher overrides the preference, which is the only way the answer becomes false.

Source: code/Nebula/Core/etc/di.xml, code/Nebula/Core/Api/V1/AdminThemeStateInterface.php, code/Nebula/ThemeSwitcher/etc/adminhtml/di.xml.

Layout XML scoping

What it is. A module's layout XML merges into every theme in its area and has no native condition, so Nebula's admin layout would follow a merchant into Magento's admin theme, where the stylesheet that makes it legible does not load — and where Nebula's removal of the core js/bootstrap files takes away script that theme still wants.

Nebula\Core\Plugin\ScopeAdminLayoutToNebula is the one place that can express the condition: an after plugin on the layout file collector that drops files belonging to gated modules whenever the theme being built is an admin theme that does not inherit Nebula/backend. A merchant theme declaring <parent>Nebula/backend</parent> counts as a Nebula theme and keeps everything.

Contract. The gatedModulePrefixes constructor argument, default ['Nebula_Core'].

<type name="Nebula\Core\Plugin\ScopeAdminLayoutToNebula">
    <arguments>
        <argument name="gatedModulePrefixes" xsi:type="array">
            <item name="qoliber" xsi:type="string">Nebula_Core</item>
            <item name="acme" xsi:type="string">Acme_NebulaExtra</item>
        </argument>
    </arguments>
</type>

Join the gate if your module's admin layout only makes sense under the Nebula theme. Two things stay ungated deliberately: the theme switcher, which is how you get back, and the passkey module, which is how you get in. Neither replaces a core screen.

Status. Wired and used in-tree — registered on Magento\Framework\View\Layout\File\Collector\Aggregated at sortOrder="10".

Source: code/Nebula/Core/Plugin/ScopeAdminLayoutToNebula.php, code/Nebula/Core/etc/adminhtml/di.xml.


Events

Seven events are dispatched. The five below let an observer change a definition, a save payload or a grid's rows without touching the code that produced them. Two more, nebula_customer_password_set and nebula_passkey_credentials_revoked, report a sensitive act to an audit trail and carry nothing mutable. All seven are listed in ACL resources and events.

Event Payload Mutable
nebula_definition_resolved_before DefinitionResolveEvent setDefinition() — a non-empty value short-circuits resolution
nebula_definition_resolved_after DefinitionResolveEvent setDefinition()
nebula_form_save_before FormSaveEvent setData() — what you leave is what gets saved
nebula_form_save_after FormSaveEvent setSavedEntityId(), addMessage()
nebula_grid_data_fetched_after GridDataEvent setItems(), setTotalCount()
<!-- etc/adminhtml/events.xml -->
<event name="nebula_grid_data_fetched_after">
    <observer name="acme_blog_enrich_rows" instance="Acme\Blog\Observer\EnrichRows"/>
</event>

The export caveat

nebula_grid_data_fetched_after is dispatched from GridDataLoader, which serves the rendered grid. The grid export controller calls the data provider directly and never goes through the loader, so an observer that masks or enriches rows does not run for an export. If masking is a security requirement, put it in the data provider or the collection.

Full payload field tables are in ACL resources and events.


Extending without PHP

Two seams need no class at all, and are documented with the feature they extend:

  • JSON overlays. Drop a definition with the same id in your own module and it merges over the shipped one — add a column, retitle a field, change a default. See Extending a shipped grid and Extending a shipped form.
  • Snippets. Inject your own template into a shipped screen at a declared point, with a ViewModel bound through SnippetViewModelRegistry above. See Snippets.

A third needs neither a class nor a definition, only XML.

Role templates

A module contributes admin role templates by shipping etc/roles.xml, validated against urn:magento:module:Nebula_Roles:etc/roles.xsd. It is an ordinary merged Magento XML config, a reader over every module's etc/roles.xml, so a third-party extension can contribute a template for its own resources and the shipped set is not privileged over anyone else's. A duplicate template/@id across modules is rejected by the schema's uniqueTemplateId constraint rather than silently letting the later module win.

etc/roles.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:module:Nebula_Roles:etc/roles.xsd">
    <template id="acme_blog_editor"
              label="Blog Editor"
              description="Writes and publishes blog posts. Does not enforce per-author ownership."
              sortOrder="50">
        <grant resource="Acme_Blog::posts" subtree="true">
            <except resource="Acme_Blog::post_delete"/>
        </grant>
        <grant resource="Magento_Cms::media_gallery"/>
    </template>
</config>

Each <template> requires id, which is lowercase [a-z][a-z0-9_]*, plus label and description. sortOrder orders the picker and carries no permission meaning. version is provenance only, because applying a template copies and never links, so bumping it can never change a permission on a live role. description is required because it is where a template admits what it does not enforce: Magento Open Source ACL has no field-level permission, no approval workflow, no store scoping and no monetary limit.

subtree="true" is expanded against the live ACL tree at resolve time rather than written out here, so a template does not need rewriting every time Magento adds a resource under a node it already grants, and it matches what a person ticking the parent checkbox in the role form would save. <except> subtracts from its own parent grant and nothing else. There is deliberately no top-level deny: a template describes what a role can do, and the resources it omits are the ones it withholds.

TemplateRegistry resolves every template against this install's own ACL tree, skips resource ids the install does not have rather than failing, and reports them through getDrift() as a map of template id to missing resources. A template may therefore safely name resources from optional modules. See Authorization.

For scaffolding a whole module rather than extending one, see Generators. For assets, see CSS and JavaScript in your module.