The definition engine¶
A definition is a JSON file at view/adminhtml/<type>/<id>.json in any module, where <type> is grid, form, snippet or component. This page explains how the files from every module become one definition, and how that result is cached in each deploy mode.
Everything lives in Nebula_Component, under Model/Definition.
Loader¶
Loader::load($type, $id) walks the enabled module list in load order, which is the order module.xml sequences produce. For each module it checks <module dir>/view/adminhtml/<type>/<id>.json, decodes it, and appends the array. A file that is not valid JSON is skipped with a warning in the log, and the rest still load.
The result is an ordered list of arrays: the first module that ships the file is the base, and every later file is an overlay.
Your module must load after the one it overlays
Declare the module you overlay in your module.xml <sequence>. Without it, Magento may load your module first, your file becomes the base, and the shipped definition overlays yours.
Merger¶
Merger::merge($definitions) folds the list with a deep merge, then sorts by position. The rules:
| Overlay value | Base value | Result |
|---|---|---|
| any | absent | added |
| scalar | anything | overlay wins |
| object (map) | object | deep-merged, recursively |
list without id keys |
list | overlay replaces the list |
list whose items have id |
list | merged by id: matching ids deep-merge, new ids append, {"id": "x", "$remove": true} removes |
{"$remove": true} |
anything | the key is removed |
Two consequences that trip people up:
- An empty object counts as a list. PHP cannot tell
{}from[]after decoding, and the merger treats an empty array as sequential. An overlay containing"columns": {}therefore replaces the whole columns map with nothing. To leave a map alone, omit the key. positiondecides order after the merge. Maps whose entries carrypositionare sorted by it, ascending; entries without one sort as0in maps and last in lists. That is how a column added by an overlay lands between two shipped columns.
A worked example¶
The shipped grid:
{
"id": "customer_listing",
"acl": "Magento_Customer::customer",
"dataSource": { "provider": "customer.grid" },
"settings": { "rowUrl": true, "pageSize": 20, "massActions": false },
"columns": {
"entity_id": { "label": "ID", "type": "text", "position": 10 },
"name": { "label": "Name", "type": "text", "position": 20 },
"email": { "label": "Email", "type": "text", "position": 30 },
"billing_postcode": { "label": "Zip", "type": "text", "position": 60 }
}
}
The overlay the passkey module ships:
{
"columns": {
"nebula_two_step": {
"label": "2-Step",
"type": "badge",
"options": { "0": { "label": "Off", "class": "neutral" }, "1": { "label": "On", "class": "success" } },
"filter": "select",
"filterOptions": { "1": "On", "0": "Off" },
"sortable": false,
"hiddenByDefault": true,
"position": 210
}
}
}
A third overlay from your module:
{
"settings": {
"pageSize": 50,
"massActions": [ { "id": "export_crm", "label": "Send to CRM", "url": "vendor/crm/export" } ]
},
"columns": {
"email": { "label": "E-mail address" },
"billing_postcode": { "$remove": true },
"nebula_two_step": { "hiddenByDefault": false },
"crm_id": { "label": "CRM ID", "type": "text", "position": 25 }
}
}
The merged result: page size 50; massActions was the scalar false and is now the list with one action; the email column relabelled with every other key intact; the ZIP column gone; the passkey column visible by default; and the columns ordered by position as ID, Name, CRM ID, Email, 2-Step.
The crm_id column needs a value in every row. Adding a column to the definition adds it to the request whitelist and the table; the collection or provider behind the grid has to supply the data. See Extending a shipped grid.
SnippetResolver¶
When the merged definition has a layout key, the snippet resolver walks the tree. A node with "@use": "<snippet id>" is replaced by that snippet's definition, with the node's own keys merged over it and its slots filled. A node with "@ref": "<path>" is replaced by the referenced part of the definition, and a child written as the string "@<path>" is the same thing in short form. Snippets are loaded and merged with the same Loader and Merger, so a snippet can be overlaid too.
The shipped definitions do not use @use or @ref today. The mechanism that every shipped screen does use is the renderer: "snippet.<id>" key on a column, fieldset or section, which is resolved at render time and described on Snippets.
Resolver and caching¶
Resolver::resolve($type, $id) chooses a strategy by deploy mode:
| Mode | Behaviour |
|---|---|
| developer | Loader → Merger → SnippetResolver on every request. No cache is read or written. |
| default | Cache-through. Key is a hash of type:id and the ordered list of enabled modules, tagged nebula_definitions. Enabling or disabling any module changes the hash and invalidates everything. |
| production | The compiled manifest at generated/nebula/definitions.php is the only source. A missing manifest or a missing entry throws with a message telling the operator to run bin/magento nebula:compile. |
bin/magento nebula:compile discovers every definition id across enabled modules, resolves each live, and writes the manifest. bin/magento cache:clean nebula_definitions clears the default-mode cache. See Caching and production mode.
Lifecycle events¶
An around-plugin on the resolver dispatches two events. An observer of nebula_definition_resolved_before that sets a non-empty definition short-circuits resolution, which is the hook for a preloaded or externally cached definition. Observers of nebula_definition_resolved_after receive the merged definition and may change it before a block sees it. The payload classes and mutable fields are listed in ACL resources and events.
Access control¶
Before a block renders a definition, DefinitionAccessControl reads its acl key and asks the authorization service. A missing key is a denial. The grid block and the form block both refuse to render on denial, and the quick-edit save checks its own separate editable.acl.
Registries¶
A definition refers to PHP through aliases only:
| Key in JSON | Registry | Bound in |
|---|---|---|
dataSource.provider |
DataProviderRegistry |
di.xml argument bindings |
dataSource.config.collection |
CollectionRegistry |
di.xml argument bindings |
optionsSource, filterOptionsSource |
OptionSourceRegistry, FilterOptionSourceRegistry |
di.xml |
renderer: "snippet.<id>" |
SnippetViewModelRegistry for the view model |
di.xml argument viewModels |
type on a column or field |
RendererPool |
di.xml arguments columnRenderers, fieldRenderers |
A class name in any of those places is rejected. The binding is a compile-time decision in DI, and the JSON only chooses among the bound names. Extension points lists every registry with an example.
Validating definitions¶
bin/magento nebula:lint validates every definition on the install against the JSON schemas shipped in Nebula_Component/schemas. The schemas are strict about unknown keys. Run it before you commit a definition, and in CI.