Skip to content

EAV forms

An EAV form is a form whose fields come from Magento's attribute tree rather than from the definition. You do not list the fields; you say which attribute groups and attribute codes go where, and the engine renders each one with the control its frontend_input asks for.

Products, categories and customers are the screens this exists for.

The file

An EAV form is a form definition with type: "eav". There is no eav-form directory — the file lives beside every other form:

<module>/view/adminhtml/form/<id>.json
{
  "id": "customer_edit",
  "type": "eav",
  "entity": "customer",
  "acl": "Magento_Customer::manage"
}

bin/magento nebula:lint picks the EAV schema for any form/*.json whose type is eav, so the same command validates both kinds.

Key Type Default What it does
id string — Definition identifier. A base file sets it; an overlay omits it.
type "eav" — Required. Selects the EAV runtime.
entity string — Required. EAV entity code: catalog_product, catalog_category, customer, customer_address, order. The attribute set is taken from the record's own attribute_set_id.
acl string none — denied ACL resource guarding the form.
dataSource object — Same shape as a flat form: provider + config.identifierParam.
settings object {} See settings.
layout array — The layout tree. See The layout tree.
excludeGroups array<string> [] Attribute group codes dropped from the tree entirely.
overrides.fields.<code> object {} Per-attribute patches. See overrides.
defaults object {} Field values for a new record, merged under whatever the provider returns. Read by the engine, not in the schema.

Source: code/Nebula/Form/Block/EavForm.php, code/Nebula/Component/Console/Command/LintCommand.php.

Mounting an EAV form

An EAV form is mounted from layout XML, with the shared nebula_eav_form handle for the page chrome:

<?xml version="1.0"?>
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
      layout="admin-1column"
      xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
    <update handle="nebula_eav_form"/>
    <body>
        <referenceBlock name="customer_form" remove="true"/>
        <referenceBlock name="customer_edit_tabs" remove="true"/>
        <referenceContainer name="left" remove="true"/>
        <referenceContainer name="content">
            <block class="Nebula\Form\Block\EavForm" name="nebula.customer_edit" after="-">
                <arguments>
                    <argument name="form_id" xsi:type="string">customer_edit</argument>
                </arguments>
            </block>
        </referenceContainer>
    </body>
</page>

Two things the layout file owns: removing the core blocks this screen replaces, and naming the block class.

Never put route on an EAV definition

Route claiming injects Nebula\Form\Block\Form — the flat-form block — and nothing else. An EAV definition mounted that way would be handed to a block that knows nothing about attributes, entity or overrides: no attribute would render. route, routeWhen, replaces and replacesClass belong to flat forms. An EAV screen removes the core blocks in its own layout XML instead, as above.

settings

The EAV block reads a subset of the shared form settings.

Key Type Default What it does
title string — Page heading (the shell adds the record's own name).
saveUrl string */*/save Route the form posts to; hiddenParams and the identifier param are injected into it.
backUrl string */*/ Back button target.
deleteUrl string — Delete button target. Suppressed when the provider returns _can_delete: false.
identifierField string entity_id Name of the hidden POST field carrying the record id — customer[entity_id], id. Core reads the id from the body on several of these screens, so getting it wrong turns every save into a create.
identifierParam string id Request param carrying the id, when dataSource.config.identifierParam does not say.
fieldPrefix string — Wraps every attribute as prefix[attribute_code] — product, customer.
hiddenParams array<string> [] Request params copied onto the save URL, falling back to the record (type → type_id, set → attribute_set_id).
hiddenInputs object {} Extra hidden inputs; same literal: / array: / param-or-record syntax as a flat form.
readOnly boolean false Drops the save controls.
saveLabel string Save Label of the primary submit.
saveAndContinue boolean true false removes Save & Continue.
template string EAV form template An alternative block template — this is how the sidebar shells are selected.
sidebarRenderer string — snippet.<id> rendered as the sidebar by the shell templates that have one.

Source: code/Nebula/Form/Block/EavForm.php, code/Nebula/Form/Model/Form/HiddenInputResolver.php, code/Nebula/Theme/view/adminhtml/templates/eav/form-with-sidebar-tabbed.phtml.

{
  "settings": {
    "title": "Category",
    "saveUrl": "catalog/category/save",
    "backUrl": "catalog/category/index",
    "fieldPrefix": "general",
    "template": "Nebula_Theme::eav/form-with-sidebar-tabbed.phtml",
    "sidebarRenderer": "snippet.category_navigation_tree"
  }
}

Reserved keys a provider may return

A data key beginning with _ is read by the block instead of being rendered as a field. The EAV block reads three of them.

Key What it does
_entity The loaded model itself, as a DataObject.
_can_delete false hides the Delete button for this record. The controller behind the button still decides.
_notices A list of {type, message} rendered above the form. warning, error and success; anything else is clamped to warning.

_entity is the one an EAV screen cannot do without. The block adopts it, and it is the only thing $block->getEntity() ever returns. It is also what every snippet on the form receives as entity: the sidebar renderer, a section renderer and a per-attribute renderer are all handed the same object. A provider that returns no _entity leaves entity null in all three.

Both the create and the edit path adopt it. A provider that can describe a record it has not saved yet should return _entity for a new one too, otherwise snippets that guard on entity render nothing on the create page.

_options is flat-form only and the EAV block does not read it. The flat-form side of this list is in Form reference.

Source: code/Nebula/Form/Block/EavForm.php (assignEntityFromData()), code/Nebula/Theme/Model/DataProvider/ProductFormProvider.php.

The layout tree

layout is a list of nodes. Unlike a flat form, the tree is the only dialect: an EAV form has no fieldsets to fall back to, and fieldsets on an EAV definition is not rendered.

Node type Keys read What it does
row children, condition A horizontal band of columns. Columns that render nothing are dropped, and a row left with one column gives it the full width.
column width, children, condition 1/2, 1/3, 2/3, 1/4, 3/4; anything else flexes.
section label, open, renderer, children, fields, groups, condition A panel. Titled sections are collapsible <details>; the first titled section of a group opens and the rest start closed, unless open says otherwise.
tabs children, condition A tab strip; each child is a tab.
(a tab) label, renderer, fields, children, groups, remainingGroups The first of those five that is present wins.

Any other type simply renders its children.

A section's body comes from the first of these that is present: renderer, then children, then fields, then groups.

  • fields is a list of attribute codes.
  • groups is a list of attribute group codes; every attribute of each group is rendered in order.
  • children may hold {"type": "fields", "fields": [...]} and {"type": "snippet", "renderer": "snippet.<id>"} nodes, so a section can mix attributes with custom markup.
  • remainingGroups: true on a tab places everything no other node claimed inside that tab — see Remaining groups.

Source: code/Nebula/Form/Model/Form/LayoutTreeRenderer.php, code/Nebula/Form/view/adminhtml/templates/eav/form.phtml.

condition

condition is a map of record field to expected value, compared as strings. A list of values means "any of these". Every entry must hold or the node and its subtree render nothing.

It is evaluated on every node the renderer dispatches through renderNode(): the top-level nodes, row, column, section and tabs. Two places accept a condition and then ignore it.

  • An individual tab inside a tabs node. The tab strip walks its own children, so the tab button and its panel always render.
  • A {"type": "fields"} or {"type": "snippet"} child of a section. The section renders those two kinds itself.

bin/magento nebula:lint flags neither, because every layout node validates against one shared schema. To make a tab conditional, put the condition on the section nodes inside it, which still leaves the tab button over an empty panel, or put it on the whole tabs node. To make a snippet or a field list conditional, wrap it in a section that carries the condition.

{
  "id": "bundle_items",
  "type": "section",
  "label": "Bundle Items",
  "condition": {"type_id": "bundle"},
  "renderer": "snippet.bundle_options"
}
{
  "id": "pricing",
  "type": "section",
  "label": "Pricing",
  "condition": {"type_id": ["simple", "virtual", "downloadable", "bundle"]},
  "fields": ["price", "cost", "tax_class_id"]
}

overrides

overrides.fields.<attribute_code> patches one attribute. Only these keys are read:

Key Type Default What it does
$remove boolean false The attribute is not rendered at all.
type string mapped Forces the Nebula field type, overriding the frontend_input map.
renderer string — snippet.<id> or Vendor_Module::x.phtml rendered instead of the control. The snippet receives attribute_code, entity, entity_data, config (these overrides) and form_block.
label string store label Relabels the field, when the attribute's own label is too generic in this context.
default scalar — Seed value, applied to a new record only, and only when the stored value is empty.
uploadUrl string — Upload endpoint for an image field.
disabled boolean false Renders the control disabled.
disableOnEdit boolean false Disabled only when editing an existing record.
trueValue / falseValue string 1 / 0 What a toggle stores — including a pair that runs the other way to the label.
disabledWhen object — {"field": "<attribute>", "equals": "<value>"}: disabled while that attribute holds that value.

Source: code/Nebula/Form/Model/Field/EavFieldRenderer.php, code/Nebula/Form/Model/Field/EavFieldResolver.php, code/Nebula/Form/Model/Field/EavFieldTypeMapper.php.

{
  "overrides": {
    "fields": {
      "description": {"type": "wysiwyg"},
      "weight": {"renderer": "snippet.weight_field"},
      "price_type": {"disableOnEdit": true, "trueValue": "0", "falseValue": "1", "default": "0"},
      "price": {"disabledWhen": {"field": "price_type", "equals": "0"}},
      "news_to_date": {"$remove": true}
    }
  }
}

How an attribute becomes a field

For each attribute the engine walks this order:

  1. overrides.fields.<code>.$remove — stop, render nothing.
  2. Applicability: an attribute that does not apply to this entity (a product type it is not used by) is skipped, unless the definition explicitly configured it with a type or a renderer.
  3. Field-level ACL: an attribute listed in the protectedAttributes DI map is omitted entirely for a role without the matching resource — omitted, not disabled, because a disabled control still shows the value. The POST is guarded separately.
  4. overrides.fields.<code>.renderer — render that snippet or template instead.
  5. Otherwise the field type comes from the frontend_input map below, and overrides.fields.<code>.type overrides it.

The .fields segment is not optional

overrides.<code> is never a valid path. The resolver reads overrides.fields.<code> and nothing else, so a patch written one level short is silently ignored, and bin/magento nebula:lint rejects the file as well because overrides allows no property but fields.

frontend_input map

frontend_input Nebula field type
text text
textarea textarea
select select
multiselect multiselect
boolean toggle
date date
datetime date
price number
weight number
media_image image
gallery skipped
hidden hidden
anything else text

Options for a select or multiselect come from the attribute's own source model; a source that raises yields an empty list rather than a broken page.

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

The map is open. A module that introduces an EAV input type contributes one DI entry rather than patching the mapper:

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

Entries are merged over the defaults, so the same mechanism changes how an existing input renders.

Remaining groups

An attribute set usually holds more groups than a designed screen wants to place. What no node claimed is the remaining set, and it is rendered as a sub-tab strip so nothing silently disappears.

A group is remaining when it is not named in any node's groups, and not listed in excludeGroups. Within such a group, an attribute is dropped when it is:

  • named in any node's fields (it is already on screen);
  • category_ids or quantity_and_stock_status (the engine renders these elsewhere);
  • of frontend_input gallery or media_image, or has no frontend_input at all;
  • not visible (is_visible);
  • not applicable to this record;
  • withheld by the field-level ACL.

A group left with no attributes is not shown.

Where they appear is the definition's choice:

  • mark one tab "remainingGroups": true and they render inside it;
  • say nothing, and they render below the layout tree, at the end of the form.

Only one of the two happens: if the tree claims them, the template does not render them again.

Source: code/Nebula/Form/Model/Eav/AttributeGroupRenderer.php, code/Nebula/Form/Block/EavForm.php (layoutConsumesRemainingGroups()), code/Nebula/Form/Model/Form/LayoutTreeRenderer.php.

{
  "id": "attributes_tab",
  "label": "Attributes",
  "remainingGroups": true
}

A worked example

Adapted from the shipped product editor. A two-thirds / one-third first row, sections conditional on the product type, snippets for the parts no attribute can express, a tab strip below, five groups excluded, and a handful of attribute overrides.

{
  "id": "catalog_product_edit",
  "type": "eav",
  "entity": "catalog_product",
  "acl": "Magento_Catalog::products",
  "dataSource": {
    "provider": "product.form",
    "config": {"identifierParam": "id"}
  },
  "settings": {
    "title": "Product",
    "saveUrl": "catalog/product/save",
    "backUrl": "catalog/product/index",
    "deleteUrl": "catalog/product/delete",
    "identifierField": "id",
    "identifierParam": "id",
    "fieldPrefix": "product",
    "hiddenParams": ["type", "set", "store"]
  },
  "layout": [
    {
      "id": "main_row",
      "type": "row",
      "children": [
        {
          "id": "main_column",
          "type": "column",
          "width": "2/3",
          "children": [
            {
              "id": "product_details",
              "type": "section",
              "label": "Product Details",
              "fields": ["name", "sku", "status", "visibility", "weight"]
            },
            {
              "id": "pricing",
              "type": "section",
              "label": "Pricing",
              "condition": {"type_id": ["simple", "virtual", "downloadable", "bundle"]},
              "children": [
                {"type": "fields", "fields": ["price_type", "price", "cost", "tax_class_id"]},
                {"type": "snippet", "renderer": "snippet.special_price"},
                {"type": "snippet", "renderer": "snippet.tier_prices"}
              ]
            },
            {
              "id": "configurable_variations",
              "type": "section",
              "label": "Configurable Variations",
              "condition": {"type_id": "configurable"},
              "renderer": "snippet.configurable_options"
            }
          ]
        },
        {
          "id": "sidebar_column",
          "type": "column",
          "width": "1/3",
          "children": [
            {"id": "categories", "type": "section", "label": "Categories",
             "renderer": "snippet.category_tree"},
            {"id": "websites", "type": "section", "label": "Websites",
             "renderer": "snippet.website_checkboxes"}
          ]
        }
      ]
    },
    {
      "id": "tabs",
      "type": "tabs",
      "children": [
        {
          "id": "seo_tab",
          "label": "SEO",
          "children": [
            {
              "id": "seo_section",
              "type": "section",
              "label": "Search Engine Optimization",
              "children": [
                {"type": "fields", "fields": ["url_key", "meta_title", "meta_description"]},
                {"type": "snippet", "renderer": "snippet.indexing_toggles"}
              ]
            }
          ]
        },
        {"id": "design_tab", "label": "Design", "groups": ["design", "schedule-design-update"]},
        {"id": "attributes_tab", "label": "Attributes", "remainingGroups": true}
      ]
    }
  ],
  "excludeGroups": [
    "image-management",
    "bundle-items",
    "autosettings",
    "advanced-pricing",
    "search-engine-optimization"
  ],
  "overrides": {
    "fields": {
      "description": {"type": "wysiwyg"},
      "short_description": {"type": "wysiwyg"},
      "weight": {"renderer": "snippet.weight_field"},
      "price_type": {"disableOnEdit": true, "trueValue": "0", "falseValue": "1", "default": "0"},
      "price": {"disabledWhen": {"field": "price_type", "equals": "0"}},
      "news_to_date": {"$remove": true}
    }
  }
}

Accepted by the schema but not read

  • attributeSet — the attribute set comes from the record's attribute_set_id; nothing reads this key.
  • hiddenFields — the EAV block builds its hidden inputs from settings.hiddenInputs and the identifier only.
  • fieldsets — the EAV renderer draws the layout tree and the remaining groups; a fieldset map is never rendered.

Read by the engine, rejected by the schema

  • defaults — the block merges it under the provider's data for a new record, but the EAV schema has no such property, so a definition using it does not pass bin/magento nebula:lint. Prefer a per-attribute overrides.fields.<code>.default, which is read on new records and does lint.

See also