Skip to content

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.

Loader → Merger → SnippetResolver → Resolver (cache) → Block

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.
  • position decides order after the merge. Maps whose entries carry position are sorted by it, ascending; entries without one sort as 0 in 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:

Nebula_Theme: grid/customer_listing.json (excerpt)
{
  "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:

Nebula_Passkey: grid/customer_listing.json (excerpt)
{
  "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:

Vendor_Module: grid/customer_listing.json
{
  "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.