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
switchinside 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:
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
V2of a generated interface. Templates emit plainApi\. - There are no
class_aliasshims. 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>
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:
<?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:
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.
Menu badges¶
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.
Menu icons¶
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): arrayreturning['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, tryingtitle,labelandnameand appendingidentifierorsku.
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:
- The button dispatches a window event named
open-chooser-plus the alias with its first underscore replaced by a dash, socms_pagebecomesopen-chooser-cms-page, carryingdetail: {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. - Your template listens with
@open-chooser-<alias>.window="open = true; fieldName = $event.detail.fieldName". - On selection it dispatches
chooser:selectedwithdetail: {fieldName, value, label}. - The wizard root listens on the window with
@chooser:selected.window="handleChooserSelected($event.detail)", writesvalueinto the parameter andlabelinto 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
SnippetViewModelRegistryabove. 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.
<?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.