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:
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.
fieldsis a list of attribute codes.groupsis a list of attribute group codes; every attribute of each group is rendered in order.childrenmay hold{"type": "fields", "fields": [...]}and{"type": "snippet", "renderer": "snippet.<id>"}nodes, so a section can mix attributes with custom markup.remainingGroups: trueon 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
tabsnode. The tab strip walks its own children, so the tab button and its panel always render. - A
{"type": "fields"}or{"type": "snippet"}child of asection. 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:
overrides.fields.<code>.$remove— stop, render nothing.- 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
typeor arenderer. - Field-level ACL: an attribute listed in the
protectedAttributesDI 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. overrides.fields.<code>.renderer— render that snippet or template instead.- Otherwise the field type comes from the
frontend_inputmap below, andoverrides.fields.<code>.typeoverrides 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_idsorquantity_and_stock_status(the engine renders these elsewhere);- of
frontend_inputgalleryormedia_image, or has nofrontend_inputat 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": trueand 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.
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'sattribute_set_id; nothing reads this key.hiddenFields— the EAV block builds its hidden inputs fromsettings.hiddenInputsand 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 passbin/magento nebula:lint. Prefer a per-attributeoverrides.fields.<code>.default, which is read on new records and does lint.
See also¶
- Form reference — the flat-form keys these settings are shared with.
- Saving and validation.
- Snippets — what a
rendererreceives.