Skip to content

Form examples

Twelve worked examples, each adapted from a definition that ships with Nebula. The Form reference lists every key on its own; this page shows the keys that belong together, in the shape a working file has. Copy one, change the ids and the routes, and you have a screen.

Every example below parses and validates against form.schema.json, or against eav-form.schema.json where the definition declares type: "eav". Keys the engine reads but the schema rejects do not appear in any of them; where one is worth knowing about it is called out in a note.

About the ids

An example whose id is a shipped one is a trim of that shipped file. Dropping such a file into your own module does not create a second form: the merge layer joins it to the shipped definition by id, and you get an overlay. Rename the id and the file together when you adapt one. The other examples use a vendor_module_ namespace and are free to copy. A base definition's id must match its file name; bin/magento nebula:lint checks that.

1. A flat form with several fieldsets

The starting shape. Three fieldsets, the first two open and the third collapsed, ordered by position. It carries a select with its options written inline, a select and a multiselect that name a registered option source, and a toggle whose stored values are not 1 and 0.

Vendor_Module/view/adminhtml/form/vendor_promo_edit.json
{
  "id": "vendor_promo_edit",
  "acl": "Vendor_Module::promo",
  "route": ["vendor_module_promo_edit", "vendor_module_promo_new"],
  "dataSource": {
    "provider": "vendor_module_promo.form",
    "config": {
      "identifierParam": "promo_id"
    }
  },
  "settings": {
    "title": "Promotion",
    "titleField": "title",
    "identifierField": "promo_id",
    "backUrl": "vendor_module/promo/index",
    "saveUrl": "vendor_module/promo/save",
    "deleteUrl": "vendor_module/promo/delete"
  },
  "fieldsets": {
    "general": {
      "label": "General",
      "position": 10,
      "open": true,
      "fields": {
        "title": {
          "label": "Title",
          "type": "text",
          "required": true,
          "placeholder": "Spring Sale",
          "position": 10,
          "validation": {"required": true, "maxLength": 255}
        },
        "url_key": {
          "label": "URL Key",
          "type": "text",
          "required": true,
          "position": 20,
          "note": "Relative URL of the page, for example 'spring-sale'.",
          "validation": {"required": true, "pattern": "/^[a-z0-9\\-]+$/"}
        },
        "is_active": {
          "label": "Enabled",
          "type": "toggle",
          "default": "1",
          "position": 30
        },
        "store_id": {
          "label": "Store View",
          "type": "multiselect",
          "optionsSource": "nebula.store.view",
          "required": true,
          "position": 40
        }
      }
    },
    "targeting": {
      "label": "Targeting",
      "position": 20,
      "open": false,
      "fields": {
        "display_mode": {
          "label": "This page shows",
          "type": "select",
          "default": "both",
          "position": 10,
          "options": [
            {"value": "content", "label": "Content only"},
            {"value": "products", "label": "Products only"},
            {"value": "both", "label": "Content and products"}
          ]
        },
        "page_layout": {
          "label": "Layout",
          "type": "select",
          "optionsSource": "magento.cms.page.layout",
          "default": "1column",
          "position": 20
        },
        "merge_on_conflict": {
          "label": "Merge existing promotions",
          "type": "toggle",
          "toggleLabel": "Merge on conflict",
          "trueValue": "true",
          "falseValue": "false",
          "position": 30,
          "note": "The save controller reads the string 'true', so the switch stores words rather than 1 and 0."
        }
      }
    },
    "content": {
      "label": "Content",
      "position": 30,
      "open": true,
      "fields": {
        "content": {
          "label": "Page Content",
          "type": "wysiwyg",
          "rows": 20,
          "full_width": true,
          "position": 10
        }
      }
    }
  }
}

Keys worth noticing

  • route takes a list, so one definition serves both the create and the edit action. No layout XML is needed once a form claims a route.
  • open: false collapses a titled fieldset on load. A fieldset with no label renders as a plain card and cannot collapse at all.
  • default is merged under whatever the data provider returns, so it only shows up on a record the provider knows nothing about.
  • options wins over optionsSource when both are present, and a provider's _options for the field wins over both.
  • trueValue and falseValue are compared as strings. They are what lets a switch stand for a value that runs the other way to its label.
  • full_width moves the label above the control. Use it for anything wide: a rich text editor, a long textarea, a snippet-rendered panel.

Source: Nebula_Theme/view/adminhtml/form/cms_block_edit.json, Nebula_LandingPage/view/adminhtml/form/nebula_landing_page_edit.json, Nebula_Theme/view/adminhtml/form/search_synonyms_edit.json.

2. Two columns

settings.layout: "two-column" turns on the row builder. Fieldsets sharing a row value render side by side, and width gives each one its share. A fieldset with no row stacks full width underneath. Nothing else changes: the fields, their types and their order are declared exactly as in example 1.

Vendor_Module/view/adminhtml/form/vendor_article_edit.json
{
  "id": "vendor_article_edit",
  "acl": "Vendor_Module::article",
  "route": "vendor_module_article_edit",
  "dataSource": {
    "provider": "vendor_module_article.form",
    "config": {
      "identifierParam": "article_id"
    }
  },
  "settings": {
    "title": "Article",
    "identifierField": "article_id",
    "backUrl": "vendor_module/article/index",
    "saveUrl": "vendor_module/article/save",
    "deleteUrl": "vendor_module/article/delete",
    "layout": "two-column"
  },
  "fieldsets": {
    "general": {
      "label": "General",
      "position": 10,
      "open": true,
      "row": "top",
      "width": "3/4",
      "fields": {
        "title": {"label": "Article Title", "type": "text", "required": true, "position": 10},
        "identifier": {"label": "URL Key", "type": "text", "required": true, "position": 20},
        "is_active": {"label": "Enabled", "type": "toggle", "position": 30},
        "store_id": {
          "label": "Store View",
          "type": "multiselect",
          "optionsSource": "nebula.store.view",
          "required": true,
          "position": 40
        }
      }
    },
    "design": {
      "label": "Design",
      "position": 10,
      "row": "top",
      "width": "1/4",
      "fields": {
        "page_layout": {
          "label": "Layout",
          "type": "select",
          "optionsSource": "magento.cms.page.layout",
          "position": 10
        }
      }
    },
    "seo": {
      "label": "Search Engine Optimization",
      "position": 20,
      "row": "seo",
      "width": "1/2",
      "fields": {
        "meta_title": {"label": "Meta Title", "type": "text", "position": 10},
        "meta_keywords": {"label": "Meta Keywords", "type": "textarea", "position": 20},
        "meta_description": {"label": "Meta Description", "type": "textarea", "rows": 3, "position": 30}
      }
    },
    "seo_preview": {
      "label": "Google Preview",
      "position": 20,
      "row": "seo",
      "width": "1/2",
      "renderer": "snippet.seo_preview",
      "entity_type": "cms_page",
      "entity_noun": "article",
      "title_field": "title",
      "url_field": "identifier",
      "title_placeholder": "Article Title",
      "url_placeholder": "article-url"
    },
    "content": {
      "label": "Content",
      "position": 30,
      "open": true,
      "fields": {
        "content_heading": {"label": "Content Heading", "type": "text", "position": 10},
        "content": {"label": "Content", "type": "wysiwyg", "full_width": true, "position": 20}
      }
    }
  }
}

Keys worth noticing

  • row is an arbitrary key. Any number of rows is possible, and the two fieldsets in row: "top" are a 3/4 and a 1/4 rather than an even split.
  • Only 1/2, 1/3, 2/3, 1/4 and 3/4 map to a width class. Any other fraction renders full width.
  • column: "left" | "right" | "third" is the older dialect for the same idea and is still read. It gives one row of equal columns and nothing more, so prefer row and width.
  • settings.layout also accepts one-column and three-column. Neither changes anything; only two-column turns the row builder on.
  • A fieldset with a renderer and no fields hands its whole body to a snippet. The extra keys beside renderer here are read by snippet.seo_preview itself.

Source: Nebula_Theme/view/adminhtml/form/cms_page_edit.json.

3. The layout tree

The second dialect. layout is a tree of tabs, row, column and section nodes that arranges fields; fieldsets still declares them. A section names its fields as a list of keys, and a section's children may mix a fields node with a snippet node so custom markup sits among the inputs. Every node carries an id, which is what an overlay from another module merges onto.

Vendor_Module/view/adminhtml/form/vendor_article_edit_tabs.json
{
  "id": "vendor_article_edit_tabs",
  "acl": "Vendor_Module::article",
  "dataSource": {
    "provider": "vendor_module_article.form",
    "config": {"identifierParam": "article_id"}
  },
  "settings": {
    "title": "Article",
    "identifierField": "article_id",
    "backUrl": "vendor_module/article/index",
    "saveUrl": "vendor_module/article/save",
    "deleteUrl": "vendor_module/article/delete"
  },
  "fieldsets": {
    "general": {
      "label": "General",
      "position": 10,
      "fields": {
        "title": {"label": "Article Title", "type": "text", "required": true, "position": 10},
        "identifier": {"label": "URL Key", "type": "text", "required": true, "position": 20},
        "is_active": {"label": "Enabled", "type": "toggle", "position": 30},
        "store_id": {
          "label": "Store View",
          "type": "multiselect",
          "optionsSource": "nebula.store.view",
          "required": true,
          "position": 40
        },
        "page_layout": {
          "label": "Layout",
          "type": "select",
          "optionsSource": "magento.cms.page.layout",
          "position": 50
        }
      }
    },
    "seo": {
      "label": "Search Engine Optimization",
      "position": 20,
      "fields": {
        "meta_title": {"label": "Meta Title", "type": "text", "position": 10},
        "meta_description": {"label": "Meta Description", "type": "textarea", "rows": 3, "position": 20}
      }
    },
    "content": {
      "label": "Content",
      "position": 30,
      "fields": {
        "content_heading": {"label": "Content Heading", "type": "text", "position": 10},
        "content": {"label": "Content", "type": "wysiwyg", "full_width": true, "position": 20}
      }
    }
  },
  "layout": [
    {
      "id": "vendor_article_tabs",
      "type": "tabs",
      "children": [
        {
          "id": "general_tab",
          "label": "General",
          "children": [
            {
              "id": "general_row",
              "type": "row",
              "children": [
                {
                  "id": "general_left",
                  "type": "column",
                  "width": "3/4",
                  "children": [
                    {
                      "id": "general_section",
                      "type": "section",
                      "label": "General",
                      "fields": ["title", "identifier", "is_active", "store_id"]
                    }
                  ]
                },
                {
                  "id": "general_right",
                  "type": "column",
                  "width": "1/4",
                  "children": [
                    {
                      "id": "design_section",
                      "type": "section",
                      "label": "Design",
                      "fields": ["page_layout"]
                    }
                  ]
                }
              ]
            }
          ]
        },
        {
          "id": "seo_tab",
          "label": "SEO",
          "children": [
            {
              "id": "seo_row",
              "type": "row",
              "children": [
                {
                  "id": "seo_left",
                  "type": "column",
                  "width": "1/2",
                  "children": [
                    {
                      "id": "seo_section",
                      "type": "section",
                      "label": "Search Engine Optimization",
                      "children": [
                        {"type": "fields", "fields": ["meta_title", "meta_description"]},
                        {"type": "snippet", "renderer": "snippet.vendor_article_checklist"}
                      ]
                    }
                  ]
                },
                {
                  "id": "seo_right",
                  "type": "column",
                  "width": "1/2",
                  "children": [
                    {
                      "id": "seo_preview_section",
                      "type": "section",
                      "label": "Google Preview",
                      "renderer": "snippet.seo_preview",
                      "entity_type": "cms_page",
                      "entity_noun": "article",
                      "title_field": "title",
                      "url_field": "identifier",
                      "title_placeholder": "Article Title",
                      "url_placeholder": "article-url"
                    }
                  ]
                }
              ]
            }
          ]
        },
        {
          "id": "content_tab",
          "label": "Content",
          "children": [
            {
              "id": "content_section",
              "type": "section",
              "label": "Content",
              "fields": ["content_heading", "content"]
            }
          ]
        }
      ]
    }
  ]
}

Keys worth noticing

  • A tab is a child of a tabs node with a label and children. It needs no type of its own; the renderer reads the children of a tabs node as tabs whatever they say.
  • A section's fields is a list of keys, not field definitions. The configuration stays in fieldsets, and a field named by no node simply does not render.
  • Fields inside a section are ordered by their own position, not by the order of the list.
  • condition on a flat form's layout node is accepted by the schema and ignored by the renderer. It is evaluated on EAV forms only. Use disabledWhen on the fields instead.
  • There is no overrides key on a flat form. It belongs to EAV definitions, where the fields come from attribute metadata rather than from the file.

open on a layout section does not lint

The flat form template reads open on a section and the schema has no such property, so a definition that sets it fails nebula:lint. A titled section starts open either way, which is what open: true would have asked for.

Source: Nebula_Theme/view/adminhtml/form/cms_page_edit_tabs.json.

4. Claiming a route that is not an identity

A full action name usually identifies a page. On two core screens it does not, and routeWhen is how a form says which of the requests on that action are its own. Core forwards the website, store and store view editors to a single editStore action and tells them apart by a value left in the registry. Core's URL rewrite screen picks one of four edit blocks off request params, so the generic form claims the action only when none of the specific ones apply. Several forms may declare the same route as long as each guards it differently; the most specific match wins and an unguarded form is the fallback.

Nebula_Store/view/adminhtml/form/store_view_edit.json
{
  "id": "store_view_edit",
  "acl": "Magento_Backend::store",
  "route": "adminhtml_system_store_editstore",
  "routeWhen": {
    "registry": {"store_type": "store"}
  },
  "replaces": "adminhtml_admin_save_confirm",
  "dataSource": {
    "provider": "store_view.form",
    "config": {"identifierParam": "store_id"}
  },
  "settings": {
    "title": "Store View",
    "identifierField": "store[store_id]",
    "backUrl": "adminhtml/system_store/index",
    "saveUrl": "adminhtml/system_store/save"
  },
  "hiddenFields": {
    "store_type": "store",
    "store_action": "edit"
  },
  "fieldsets": {
    "general": {
      "label": "General",
      "position": 10,
      "open": true,
      "fields": {
        "group_id": {
          "label": "Store",
          "type": "select",
          "submitName": "store[group_id]",
          "optionsSource": "nebula.store.group",
          "required": true,
          "position": 10
        },
        "name": {
          "label": "Name",
          "type": "text",
          "submitName": "store[name]",
          "required": true,
          "position": 20
        },
        "code": {
          "label": "Code",
          "type": "text",
          "submitName": "store[code]",
          "required": true,
          "position": 30
        },
        "is_active": {
          "label": "Status",
          "type": "toggle",
          "submitName": "store[is_active]",
          "position": 40
        }
      }
    }
  }
}
Nebula_Theme/view/adminhtml/form/url_rewrite_edit.json
{
  "id": "url_rewrite_edit",
  "acl": "Magento_UrlRewrite::urlrewrite",
  "route": "adminhtml_url_rewrite_edit",
  "routeWhen": {
    "param": {"product": null, "category": null, "cms_page": null}
  },
  "replacesClass": "Magento\\UrlRewrite\\Block\\Edit",
  "dataSource": {
    "provider": "url_rewrite.form",
    "config": {"identifierParam": "id"}
  },
  "settings": {
    "title": "URL Rewrite",
    "identifierField": "url_rewrite_id",
    "backUrl": "adminhtml/url_rewrite/index",
    "saveUrl": "adminhtml/url_rewrite/save",
    "deleteUrl": "adminhtml/url_rewrite/delete"
  },
  "fieldsets": {
    "general": {
      "label": "General",
      "position": 10,
      "open": true,
      "fields": {
        "store_id": {
          "label": "Store",
          "type": "select",
          "required": true,
          "optionsSource": "magento.store.system",
          "optionsMethod": "getStoreValuesForForm",
          "position": 10
        },
        "request_path": {"label": "Request Path", "type": "text", "required": true, "position": 20},
        "target_path": {"label": "Target Path", "type": "text", "required": true, "position": 30},
        "redirect_type": {
          "label": "Redirect Type",
          "type": "select",
          "optionsSource": "magento.urlrewrite.option",
          "position": 40
        },
        "description": {"label": "Description", "type": "textarea", "rows": 3, "position": 50}
      }
    }
  }
}

Keys worth noticing

  • A routeWhen value is one of three things: a scalar the key must equal, null for a key that must be absent, or true for a key that must be present with any value. Every entry must hold.
  • null mirrors Request::has(), so a URL ending in ?product= counts as present and the generic form stands aside.
  • replaces removes a core block by its layout name. replacesClass suppresses one by class, for controllers that build their layout in PHP and leave the block with a generated name nothing can match.
  • hiddenFields posts literal values. Core's store save controller branches on store_type and store_action, and neither is a field an administrator edits.
  • A field key must match [a-z][a-z0-9_]*. Bracket notation belongs in submitName, which replaces the POST name outright.

Source: Nebula_Store/view/adminhtml/form/store_view_edit.json, Nebula_Theme/view/adminhtml/form/url_rewrite_edit.json.

5. Extra toolbar buttons

The shell draws Back, Delete, Save and Save and Continue. settings.buttons adds the rest. A button either navigates to a route, posts to one, or re-targets this form at another route so the controller behind it receives the fields. Core's admin user screen needs all three kinds: a POST that acts on an id alone, and a delete that verifies the current password out of the form it was posted from.

Nebula_User/view/adminhtml/form/admin_user_edit.json
{
  "id": "admin_user_edit",
  "acl": "Magento_User::user",
  "route": "adminhtml_user_edit",
  "replaces": ["adminhtml.user.edit.tabs", "adminhtml.user.edit", "adminhtml.user.roles.grid.js"],
  "dataSource": {
    "provider": "admin_user.form",
    "config": {"identifierParam": "user_id"}
  },
  "settings": {
    "title": "Admin User",
    "identifierField": "user_id",
    "backUrl": "adminhtml/user/index",
    "saveUrl": "adminhtml/user/save",
    "buttons": [
      {
        "label": "Force Sign-In",
        "url": "adminhtml/user/invalidatetoken",
        "method": "post",
        "class": "ghost",
        "showWhen": "existing",
        "acl": "Magento_User::user",
        "confirmTitle": "Revoke this user's tokens?",
        "confirm": "They will be signed out of the admin and any API session, and will need to sign in again."
      },
      {
        "label": "Delete User",
        "submitsForm": true,
        "action": "adminhtml/user/delete",
        "class": "danger",
        "showWhen": "existing",
        "confirmTitle": "Delete this user?",
        "confirm": "This cannot be undone. Your own current password must be filled in above for the deletion to be accepted."
      }
    ]
  },
  "fieldsets": {
    "user_info": {
      "label": "User Information",
      "position": 10,
      "open": true,
      "fields": {
        "username": {"label": "User Name", "type": "text", "required": true, "position": 10},
        "firstname": {"label": "First Name", "type": "text", "required": true, "position": 20},
        "lastname": {"label": "Last Name", "type": "text", "required": true, "position": 30},
        "email": {
          "label": "Email",
          "type": "email",
          "required": true,
          "position": 40,
          "validation": {"required": true, "email": true}
        },
        "is_active": {"label": "This Account is", "type": "toggle", "default": "1", "position": 50}
      }
    },
    "verification": {
      "label": "Current User Identity Verification",
      "position": 20,
      "open": true,
      "fields": {
        "current_password": {
          "label": "Your Password",
          "type": "password",
          "required": true,
          "position": 10,
          "note": "The delete and save controllers both read this out of the POST."
        }
      }
    }
  }
}

A submit that should not leave the page adds target: "modal". The form posts into an iframe inside a dialog, which is how a preview works without losing the edit behind it:

Nebula_Theme/view/adminhtml/form/newsletter_template_edit.json, settings only
{
  "settings": {
    "buttons": [
      {
        "label": "Preview Template",
        "submitsForm": true,
        "action": "newsletter/template/drop",
        "target": "modal",
        "class": "ghost",
        "showWhen": "always"
      }
    ]
  }
}

Keys worth noticing

  • showWhen defaults to existing. An action on a record has nothing to act on until the record is saved, so a button meant for a create page must say always or new.
  • A navigating button carries the record id as a route param under idParam. A submitsForm button posts it as a field instead, never both.
  • target is only read alongside submitsForm. On a plain link it does nothing.
  • class: "danger" sets the tone and arms the destructive styling on the confirmation.
  • acl on a button hides it from roles that could not use it. The controller behind the route is still what decides.
  • emits makes a button client-side only. It posts nothing and navigates nowhere, it dispatches a DOM event on window that a panel elsewhere on the page listens for. A button that emits cannot also submit.
  • For a screen that performs an action rather than editing a record, pair the buttons with settings.saveAndContinue: false, as in example 7.

Source: Nebula_User/view/adminhtml/form/admin_user_edit.json, Nebula_Theme/view/adminhtml/form/newsletter_template_edit.json.

6. Shaping the POST for a core save controller

Nebula forms mostly post to controllers written for the core UI components, and those controllers expect a particular request shape. Four settings between them cover it: fieldPrefix namespaces every field, identifierField names the input carrying the record id, hiddenParams copies request params onto the save URL, and hiddenInputs adds inputs whose values come from somewhere other than a field.

Vendor_Module/view/adminhtml/form/vendor_source_edit.json
{
  "id": "vendor_source_edit",
  "acl": "Vendor_Module::source",
  "route": ["vendor_module_source_new", "vendor_module_source_edit"],
  "dataSource": {
    "provider": "vendor_module_source.form",
    "config": {"identifierParam": "source_code"}
  },
  "settings": {
    "title": "Source",
    "titleField": "name",
    "identifierField": "general[id_field_name]",
    "fieldPrefix": "general",
    "backUrl": "vendor_module/source/index",
    "saveUrl": "vendor_module/source/save",
    "deleteUrl": "vendor_module/source/delete",
    "deleteParam": "source_code",
    "hiddenParams": ["store"],
    "hiddenInputs": {
      "template_id": "template_id",
      "back": "literal:edit",
      "channel_codes": "array:sales_channels"
    }
  },
  "fieldsets": {
    "general": {
      "label": "General",
      "position": 10,
      "open": true,
      "fields": {
        "source_code": {
          "label": "Code",
          "type": "text",
          "required": true,
          "readonlyOnEdit": true,
          "position": 10,
          "note": "Letters, digits and underscores. It cannot be changed once the source is saved."
        },
        "name": {"label": "Name", "type": "text", "required": true, "position": 20},
        "enabled": {
          "label": "Is Enabled",
          "type": "checkbox",
          "toggleLabel": "Enabled",
          "default": "1",
          "position": 30
        },
        "is_pickup_location_active": {
          "label": "Use as Pickup Location",
          "type": "checkbox",
          "toggleLabel": "Pickup location",
          "submitName": "general[extension_attributes][is_pickup_location_active]",
          "default": "0",
          "position": 40
        }
      }
    }
  }
}

That definition posts general[source_code], general[name], general[enabled], general[extension_attributes][is_pickup_location_active], general[id_field_name], the form key, and the three hidden inputs.

Keys worth noticing

  • hiddenInputs reads three source forms. literal:edit posts the raw string edit. array:sales_channels comma-splits a value into channel_codes[]. Anything else is the request param of that name, falling back to the record field it names.
  • hiddenParams goes on the save URL, not into the body. The identifier param is added to that URL automatically when a record is being edited, because a core save controller that sees no id treats the POST as a create.
  • identifierField is the body-level id. Native save controllers call setData(getPostValue()), so without it an edit silently becomes an insert.
  • deleteParam covers a delete controller that reads the id under a different name than the edit route uses.
  • readonlyOnEdit locks a field and still posts it. Use it, not disableOnEdit, whenever the save controller reads the value out of the body: a disabled control is not submitted.
  • submitName replaces the POST name outright, fieldPrefix included, which is the only way to reach a nested key.

Source: Nebula_Theme/view/adminhtml/form/inventory_source_form.json, Nebula_Theme/view/adminhtml/form/newsletter_queue_edit.json, Nebula_Theme/view/adminhtml/form/inventory_stock_form.json.

7. A screen with no save, and a screen that is all action

Two forms that are not record editors. The first is a detail view of something the admin cannot change: settings.readOnly drops Save, Save and Continue, Delete and the pager's save variants, and each field says readonly for itself. The second performs an import. It has no record and no id at all, which singleton tells the shell, and running it twice is a real mistake rather than a cosmetic one, which is what saveAndContinue: false is for.

Nebula_Store/view/adminhtml/form/design_theme_edit.json
{
  "id": "design_theme_edit",
  "acl": "Magento_Theme::theme",
  "route": "adminhtml_system_design_theme_edit",
  "replaces": "theme_edit_tabs",
  "dataSource": {
    "provider": "design_theme.form",
    "config": {"identifierParam": "id"}
  },
  "settings": {
    "title": "Theme",
    "titleField": "theme_title",
    "identifierField": "theme_id",
    "readOnly": true,
    "backUrl": "adminhtml/system_design_theme/index"
  },
  "fieldsets": {
    "general": {
      "label": "Theme Settings",
      "position": 10,
      "open": true,
      "fields": {
        "theme_title": {"label": "Theme Title", "type": "text", "readonly": true, "position": 10},
        "parent_title": {"label": "Parent Theme", "type": "text", "readonly": true, "position": 20},
        "theme_path": {"label": "Theme Path", "type": "text", "readonly": true, "position": 30},
        "area": {"label": "Area", "type": "text", "readonly": true, "position": 40}
      }
    },
    "preview": {
      "label": "Theme Preview Image",
      "position": 20,
      "open": true,
      "renderer": "snippet.theme_preview"
    }
  }
}
Nebula_System/view/adminhtml/form/tax_rate_import_export.json
{
  "id": "tax_rate_import_export",
  "acl": "Magento_TaxImportExport::import_export",
  "route": "tax_rate_importexport",
  "replacesClass": [
    "Magento\\TaxImportExport\\Block\\Adminhtml\\Rate\\ImportExport",
    "Magento\\TaxImportExport\\Block\\Adminhtml\\Rate\\ImportExportHeader"
  ],
  "settings": {
    "title": "Import and Export Tax Rates",
    "singleton": true,
    "saveUrl": "tax/rate/importPost",
    "saveLabel": "Import Tax Rates",
    "saveAndContinue": false,
    "backUrl": "tax/rate/index",
    "buttons": [
      {
        "label": "Export Tax Rates",
        "url": "tax/rate/exportPost",
        "method": "post",
        "class": "ghost",
        "showWhen": "always"
      }
    ]
  },
  "fieldsets": {
    "import": {
      "label": "Import Tax Rates",
      "position": 10,
      "open": true,
      "fields": {
        "import_rates_file": {
          "label": "CSV File",
          "type": "file",
          "required": true,
          "position": 10,
          "note": "Export first to see the exact column shape."
        }
      }
    }
  }
}

Keys worth noticing

  • readOnly governs the chrome only. Without a readonly on each field the controls are still editable, and there is simply no button to save them with.
  • A read-only form needs no saveUrl and no deleteUrl. Omitting deleteUrl is what removes the Delete button; a provider returning _can_delete: false removes it for one record.
  • singleton says the record always exists and has no id. Without it the shell reads the missing id as a create and titles the page "New ...".
  • saveLabel renames the primary submit for a page whose action has a name of its own.
  • The action button uses showWhen: "always" because a singleton screen is never an existing record in the sense the default means.
  • replacesClass takes a list. One screen often draws two core blocks, a body and a header, and both have to go.

Source: Nebula_Store/view/adminhtml/form/design_theme_edit.json, Nebula_System/view/adminhtml/form/tax_rate_import_export.json.

8. Every control, once

A settings screen that happens to use most of the field types: an image with an upload endpoint, a colour picker, a date and a datetime, a number with bounds, an email, a password, a sized textarea and a rich text editor.

Vendor_Module/view/adminhtml/form/vendor_theme_settings_edit.json
{
  "id": "vendor_theme_settings_edit",
  "acl": "Vendor_Module::settings",
  "route": "vendor_module_settings_edit",
  "dataSource": {
    "provider": "vendor_module_settings.form",
    "config": {"identifierParam": "scope"}
  },
  "settings": {
    "title": "Storefront Settings",
    "identifierField": "scope",
    "backUrl": "vendor_module/settings/index",
    "saveUrl": "vendor_module/settings/save"
  },
  "fieldsets": {
    "branding": {
      "label": "Branding",
      "position": 10,
      "open": true,
      "fields": {
        "scope": {"type": "hidden", "position": 1},
        "logo_src": {
          "label": "Logo Image",
          "type": "image",
          "uploadUrl": "vendor_module/settings_fileUploader/save",
          "position": 10,
          "note": "Allowed file types: png, gif, jpg, jpeg, svg."
        },
        "accent_color": {
          "label": "Accent Colour",
          "type": "color",
          "default": "#4f46e5",
          "position": 20
        },
        "welcome_text": {
          "label": "Welcome Text",
          "type": "text",
          "placeholder": "Default welcome message",
          "position": 30
        }
      }
    },
    "campaign": {
      "label": "Campaign Window",
      "position": 20,
      "fields": {
        "active_from": {
          "label": "Live From",
          "type": "date",
          "position": 10,
          "note": "Leave empty to go live as soon as the banner is enabled."
        },
        "send_at": {
          "label": "Announce At",
          "type": "datetime",
          "position": 20
        },
        "sort_order": {
          "label": "Sort Order",
          "type": "number",
          "position": 30,
          "validation": {"min": 0, "max": 999}
        }
      }
    },
    "contact": {
      "label": "Contact",
      "position": 30,
      "fields": {
        "contact_email": {
          "label": "Contact Email",
          "type": "email",
          "required": true,
          "position": 10,
          "validation": {"required": true, "email": true}
        },
        "api_secret": {
          "label": "API Secret",
          "type": "password",
          "position": 20,
          "note": "Leave empty to keep the stored secret."
        },
        "head_includes": {
          "label": "Scripts and Style Sheets",
          "type": "textarea",
          "rows": 8,
          "full_width": true,
          "position": 30
        }
      }
    },
    "content": {
      "label": "Footer Content",
      "position": 40,
      "fields": {
        "footer_content": {
          "label": "Footer Content",
          "type": "wysiwyg",
          "rows": 20,
          "full_width": true,
          "position": 10
        }
      }
    }
  }
}

Keys worth noticing

  • validation.min and validation.max on a number become the input's own min and max attributes as well as client and server rules. The template always adds the number rule on top.
  • uploadUrl is the admin route a file or an image field uploads to. An image field also renders a preview of whatever is stored.
  • rows sizes a textarea and a wysiwyg, defaulting to 5.
  • A hidden field renders no row. It is the way to carry a scope or a type through the POST without showing it.
  • date and datetime post in the admin locale's numeric format, so the receiving controller should parse rather than assume.
  • An unknown type silently renders as text. A typo costs you a control, not a page.

step is read but does not lint

The number template reads a field-level step and defaults it to 1. The schema has no such property and every schema object is strict, so a definition declaring step fails bin/magento nebula:lint. Express the bounds with validation.min and validation.max, which do lint, and leave the step alone until the schema carries it.

Source: Nebula_Store/view/adminhtml/form/design_config_form.json, Nebula_Theme/view/adminhtml/form/newsletter_queue_edit.json, Nebula_User/view/adminhtml/form/admin_account_edit.json, Nebula_System/view/adminhtml/form/variable_edit.json. The color type has a template and an Alpine component but no shipped form uses it yet.

9. Fields that depend on other fields

Three different problems that look alike. A field that must never change after the first save is disableOnEdit when the controller does not need it, and readonlyOnEdit when it does. A field that is meaningless while another field holds a particular value is disabledWhen, which is re-evaluated live rather than only at render. A field whose option list depends on the record is left to the data provider.

Vendor_Module/view/adminhtml/form/vendor_attribute_edit.json
{
  "id": "vendor_attribute_edit",
  "acl": "Vendor_Module::attribute",
  "route": ["vendor_module_attribute_new", "vendor_module_attribute_edit"],
  "dataSource": {
    "provider": "vendor_module_attribute.form",
    "config": {"identifierParam": "attribute_id"}
  },
  "settings": {
    "title": "Attribute",
    "identifierField": "attribute_id",
    "backUrl": "vendor_module/attribute/index",
    "saveUrl": "vendor_module/attribute/save",
    "deleteUrl": "vendor_module/attribute/delete"
  },
  "fieldsets": {
    "properties": {
      "label": "Properties",
      "position": 10,
      "open": true,
      "fields": {
        "attribute_code": {
          "label": "Attribute Code",
          "type": "text",
          "required": true,
          "disableOnEdit": true,
          "position": 10,
          "note": "Letters, digits and underscores. It cannot be changed once the attribute exists.",
          "validation": {"required": true, "maxLength": 30, "pattern": "/^[a-z][a-z0-9_]*$/"}
        },
        "source_code": {
          "label": "Source Code",
          "type": "text",
          "required": true,
          "readonlyOnEdit": true,
          "position": 20,
          "note": "Locked after the first save, but still posted, because the save controller looks the record up by it."
        },
        "frontend_input": {
          "label": "Input Type",
          "type": "select",
          "required": true,
          "disableOnEdit": true,
          "position": 30,
          "options": [
            {"value": "text", "label": "Text Field"},
            {"value": "select", "label": "Dropdown"},
            {"value": "price", "label": "Price"}
          ]
        },
        "price_type": {
          "label": "Dynamic Price",
          "type": "toggle",
          "trueValue": "0",
          "falseValue": "1",
          "default": "0",
          "position": 40,
          "note": "Stores 0 for dynamic, so the switch reads on when the value is 0."
        },
        "price": {
          "label": "Price",
          "type": "number",
          "position": 50,
          "disabledWhen": {"field": "price_type", "equals": "0"},
          "validation": {"min": 0},
          "note": "Disabled while Dynamic Price is on, and a disabled control is not posted."
        },
        "attribute_set_id": {
          "label": "Attribute Set",
          "type": "select",
          "optionsSource": "vendor_module.attribute_set",
          "position": 60,
          "note": "The provider narrows this list to the sets of the current entity through _options."
        }
      }
    }
  }
}

The last field's list is scoped by the provider, which returns it under the reserved _options key alongside the record:

Model/DataProvider/AttributeFormDataProvider.php
public function getData(array $config, array $params = []): array
{
    $entityId = $params['entityId'] ?? null;
    if ($entityId === null) {
        return [];
    }

    $attribute = $this->repository->getById((int) $entityId);

    return $attribute->getData() + [
        '_options' => [
            'attribute_set_id' => $this->setsForEntity($attribute->getEntityTypeId()),
        ],
        '_notices' => [
            ['type' => 'warning', 'message' => 'This attribute is used by 12 products.'],
        ],
    ];
}

Keys worth noticing

  • disabledWhen takes exactly field and equals, both required, and compares as strings. It works for every field type.
  • A disabled control is not submitted, which is usually the point. A price that must not post while dynamic pricing is on is the canonical case.
  • disableOnEdit and readonlyOnEdit differ only in whether the value reaches the controller. Choose by asking what the save controller does with it.
  • _options rows use the same shape an option source yields: value and label, plus an optional depth or group for an indented ladder. Keep an optionsSource on the field as well, because on a create page the provider has no record to scope to.
  • _notices and _can_delete are the other two reserved provider keys. Every key the provider returns beginning with an underscore is an engine instruction rather than a field.
  • There is no dependencies or depends key. Conditional enabling on a flat form is disabledWhen and nothing else.

Source: Nebula_Theme/view/adminhtml/form/product_attribute_edit.json, Nebula_Theme/view/adminhtml/form/inventory_source_form.json, Nebula_Theme/view/adminhtml/form/catalog_product_edit.json.

10. A record pager and the context around it

A record reached from a listing, from a product page and from a customer page all at once wants two things: prev and next navigation along the listing's order, and enough context to get back where it came from. settings.pager names a registered pager alias, and settings.contextParams names the request params that ride along.

Nebula_Theme/view/adminhtml/form/review_product_edit.json
{
  "id": "review_product_edit",
  "acl": "Magento_Review::reviews_all",
  "route": "review_product_edit",
  "dataSource": {
    "provider": "review.form",
    "config": {"identifierParam": "id"}
  },
  "settings": {
    "title": "Review",
    "titleField": "title",
    "identifierField": "id",
    "backUrl": "review/product/index",
    "saveUrl": "review/product/save",
    "deleteUrl": "review/product/delete",
    "pager": {
      "provider": "review",
      "editUrl": "review/product/edit",
      "idParam": "id"
    },
    "contextParams": ["ret", "productId", "customerId"]
  },
  "fieldsets": {
    "details": {
      "label": "Review Details",
      "position": 10,
      "open": true,
      "fields": {
        "status_id": {
          "label": "Status",
          "type": "select",
          "required": true,
          "optionsSource": "magento.review.status",
          "position": 10
        },
        "stores": {
          "label": "Visibility",
          "type": "multiselect",
          "required": true,
          "optionsSource": "nebula.store.view",
          "position": 20
        },
        "nickname": {"label": "Nickname", "type": "text", "required": true, "position": 30},
        "title": {"label": "Summary of Review", "type": "text", "required": true, "position": 40},
        "detail": {"label": "Review", "type": "textarea", "rows": 6, "required": true, "position": 50}
      }
    },
    "ratings": {
      "label": "Detailed Rating",
      "position": 20,
      "open": true,
      "renderer": "snippet.review_ratings"
    }
  }
}

Keys worth noticing

  • pager.provider is an alias registered on RecordPagerRegistry, the same binding pattern as a data provider. An unknown alias yields no pager rather than an error.
  • idParam defaults to dataSource.config.identifierParam, so it can usually be left out.
  • The pager is skipped entirely on a new record, and again when the provider finds neither a previous nor a next id.
  • contextParams are rendered as hidden inputs and appended to the prev and next links, which is what keeps "return to the product I came from" true after a save.
  • settings.readOnly removes the pager's Save and Previous and Save and Next variants and leaves plain navigation.

sidebarRenderer is not part of this

settings.sidebarRenderer is read by the EAV shell templates only. On a flat form the key lints and renders nothing. See example 11 for where it applies.

Source: Nebula_Theme/view/adminhtml/form/review_product_edit.json.

11. An EAV form

An EAV definition does not list its fields. It says which attribute groups and codes go where, and the engine renders each attribute with the control its frontend_input asks for. This one places six attributes and two snippets in a two-column row, adds a conditional section, moves the rest into a tab strip, drops two groups, and patches five attributes on the way through.

Nebula_Theme/view/adminhtml/form/catalog_category_edit.json
{
  "id": "catalog_category_edit",
  "type": "eav",
  "entity": "catalog_category",
  "acl": "Magento_Catalog::categories",
  "dataSource": {
    "provider": "category.form",
    "config": {"identifierParam": "id"}
  },
  "settings": {
    "title": "Category",
    "identifierField": "entity_id",
    "backUrl": "catalog/category/index",
    "saveUrl": "catalog/category/save",
    "deleteUrl": "catalog/category/delete",
    "hiddenParams": ["store"],
    "template": "Nebula_Theme::eav/form-with-sidebar.phtml",
    "sidebarRenderer": "snippet.category_navigation_tree"
  },
  "layout": [
    {
      "id": "main_row",
      "type": "row",
      "children": [
        {
          "id": "left_col",
          "type": "column",
          "width": "1/2",
          "children": [
            {
              "id": "general_section",
              "type": "section",
              "label": "General",
              "fields": ["name", "is_active", "include_in_menu"]
            },
            {
              "id": "content_section",
              "type": "section",
              "label": "Content",
              "fields": ["description", "image", "landing_page"]
            }
          ]
        },
        {
          "id": "right_col",
          "type": "column",
          "width": "1/2",
          "children": [
            {
              "id": "display_section",
              "type": "section",
              "label": "Display Settings",
              "children": [
                {"type": "fields", "fields": ["display_mode", "is_anchor"]},
                {"type": "snippet", "renderer": "snippet.category_sortby_fields"}
              ]
            },
            {
              "id": "anchor_section",
              "type": "section",
              "label": "Layered Navigation",
              "condition": {"is_anchor": "1"},
              "fields": ["available_sort_by", "default_sort_by"]
            }
          ]
        }
      ]
    },
    {
      "id": "category_tabs",
      "type": "tabs",
      "children": [
        {
          "id": "products_tab",
          "label": "Products in Category",
          "renderer": "snippet.category_products"
        },
        {
          "id": "design_tab",
          "label": "Design",
          "groups": ["custom-design"]
        },
        {
          "id": "attributes_tab",
          "label": "Attributes",
          "remainingGroups": true
        }
      ]
    }
  ],
  "excludeGroups": ["general-information", "display-settings"],
  "overrides": {
    "fields": {
      "image": {
        "type": "image",
        "label": "Category Image",
        "uploadUrl": "catalog/category_image/upload"
      },
      "description": {"type": "wysiwyg"},
      "is_active": {"type": "toggle"},
      "include_in_menu": {"type": "toggle"},
      "is_anchor": {"type": "toggle"},
      "custom_apply_to_products": {"$remove": true}
    }
  }
}

Keys worth noticing

  • type: "eav" selects both the runtime and the schema. bin/magento nebula:lint picks eav-form.schema.json for any form/*.json whose type is eav.
  • An EAV form is mounted from layout XML and must never carry route. Route claiming injects the flat-form block, which knows nothing about attributes.
  • fields on an EAV section is a list of attribute codes; groups is a list of attribute group codes and renders every attribute in each.
  • condition is evaluated here, unlike on a flat form. It is a map of record field to expected value, compared as strings, and a list of values means any of them.
  • excludeGroups drops groups from the tree entirely. What is neither excluded nor claimed by a node is the remaining set, and remainingGroups: true on one tab is where it lands. Say nothing and it renders below the tree instead.
  • overrides.fields.<code> reads a short list: $remove, type, renderer, label, default, uploadUrl, disabled, disableOnEdit, trueValue, falseValue and disabledWhen. Anything else is ignored.
  • settings.template selects the shell, and settings.sidebarRenderer fills the sidebar that shell draws. Both are EAV-only.

Source: Nebula_Theme/view/adminhtml/form/catalog_category_edit.json. The conditional section, the remaining-groups tab and hiddenParams are additions, taken from Nebula_Theme/view/adminhtml/form/catalog_product_edit.json.

12. Two overlays

An overlay is a file with the same id as a shipped definition and no id key of its own. The merge layer joins the two, so the overlay only names what it changes. Both of these ship, and they are the only two form overlays in the free package.

The passkey module adds a panel to the admin user form, rendered end to end by a snippet:

Nebula_Passkey/view/adminhtml/form/admin_user_edit.json
{
  "fieldsets": {
    "nebula_passkey_security": {
      "label": "Sign-in security",
      "position": 25,
      "open": true,
      "renderer": "snippet.nebula_passkey_admin_status"
    }
  }
}

The same module reaches into a layout tree by node id. main_row and account_col exist in the shipped customer form, so they are merged rather than duplicated, and the new section is appended to that column:

Nebula_Passkey/view/adminhtml/form/customer_edit.json
{
  "layout": [
    {
      "id": "main_row",
      "children": [
        {
          "id": "account_col",
          "children": [
            {
              "id": "nebula_passkey_security_section",
              "type": "section",
              "label": "Sign-in security",
              "renderer": "snippet.nebula_passkey_customer_status"
            }
          ]
        }
      ]
    }
  ]
}

Keys worth noticing

  • An overlay must omit id. The schema has two branches, and the one that allows a missing id requires at least one of fieldsets, layout, settings, dataSource, acl or hiddenFields.
  • position on a new fieldset places it among the shipped ones. 25 here puts the panel between the fieldsets at 20 and 30.
  • A fieldset with a renderer and no fields renders as a panel whose body is the snippet's template.
  • Give every layout node you add an id. A node whose id matches a shipped one is merged into it; a new id is appended to that parent's children.
  • Sequence your module after the one you overlay, in module.xml, or the merge order is undefined.
  • "fields": {} deletes the fields. Never write an empty object to mean "no change".

An EAV overlay cannot carry overrides

Lint validates each file on its own and picks the EAV schema only for a file that declares type: "eav". The EAV schema forbids type on an overlay, so an EAV overlay is always checked against the flat form schema, which has no overrides and no excludeGroups. The customer overlay above works because layout is a key both schemas share. To change how an attribute renders from another module, contribute a frontendInputMap entry in di.xml instead.

Source: Nebula_Passkey/view/adminhtml/form/admin_user_edit.json, Nebula_Passkey/view/adminhtml/form/customer_edit.json.

A save handler

Everything above posts to a controller. The alternative is the generic JSON endpoint at nebula/form/save, which validates the payload against the field rules, whitelists it against the declared fields, and hands it to SavePipeline. The pipeline dispatches nebula_form_save_before, persists, and dispatches nebula_form_save_after. Persistence is either a save handler you bind, or a repository named on the data source.

Model/Form/NoteSaveHandler.php
namespace Vendor\Module\Model\Form;

use Nebula\Component\Api\V1\FormSaveHandlerInterface;
use Vendor\Module\Api\NoteRepositoryInterface;
use Vendor\Module\Model\NoteFactory;

class NoteSaveHandler implements FormSaveHandlerInterface
{
    public function __construct(
        private readonly NoteRepositoryInterface $repository,
        private readonly NoteFactory $factory,
    ) {
    }

    /**
     * @param array<string, mixed> $data    submitted payload, already unwrapped from fieldPrefix
     * @param array<string, mixed> $context entityId, storeId, handlerAlias
     * @return array{entityId: int|string|null, messages?: list<string>, redirectUrl?: string|null}
     */
    public function save(array $data, array $context = []): array
    {
        $entityId = $context['entityId'] ?? null;

        $note = $entityId ? $this->repository->getById((int) $entityId) : $this->factory->create();
        $note->addData($data);
        $saved = $this->repository->save($note);

        return [
            'entityId' => $saved->getId(),
            'messages' => [(string) __('The Note was saved.')],
        ];
    }
}
etc/di.xml
<type name="Nebula\Component\Model\Registry\SaveHandlerRegistry">
    <arguments>
        <argument name="bindings" xsi:type="array">
            <item name="vendor.note" xsi:type="string">Vendor\Module\Model\Form\NoteSaveHandler</item>
        </argument>
    </arguments>
</type>

The registry rejects an unknown alias rather than instantiating it, and refuses a class that does not implement the interface, so a typo in a definition is an error rather than an arbitrary construction.

This applies to the generic save route only

A handler runs when the form posts to nebula/form/save. Every form Nebula ships posts to an entity-specific controller instead, so neither the handler nor the two save events is reached on those screens. Server-side validation rules have the same boundary. If you need the events for a shipped screen, observe the core *_save_after event for that entity.

The alias itself is declared as "saveHandler": "vendor.note" at the top level of the definition. The form schema has no such property and every schema object is strict, so a definition carrying it fails bin/magento nebula:lint until the schema is updated. The same is true of dataSource.repository and dataSource.model, which are the keys the repository path reads. Treat the generic route as an extension point you opt into with your eyes open, not as the default.

Validate before you ship

bin/magento nebula:lint

It validates every form/*.json against form.schema.json, switching to eav-form.schema.json for a file that declares type: "eav", and it checks that a base definition's id matches its file name. Every schema object is strict, so an unknown key is an error rather than a warning. Then clear the definition cache:

bin/magento cache:clean nebula_definitions layout block_html

In production mode run bin/magento nebula:compile instead, which also runs after setup:di:compile.

Every JSON document on this page was validated against the schema it belongs to before the page was written: the twelve flat definitions against form.schema.json, the category definition against eav-form.schema.json, and the four fragments with no id against the overlay branch of form.schema.json. No example declares a key the schema rejects.

What these examples leave out

  • step on a number field, and renderers on a field or a fieldset. All three are read by the templates and absent from the schema, so a definition using them does not lint. The plural renderers is only needed to stack several snippets in one panel; a single renderer is in the schema and does everything else.
  • condition on a flat form's layout node. Accepted by the schema, evaluated only on EAV forms. Every node on a flat form is drawn.
  • $remove on a fieldset. The merge layer honours it, the schema's fieldset object does not list it. $remove on a field is in the schema and does lint.
  • saveHandler, dataSource.repository, dataSource.model and context. All read by the save pipeline and the branch resolver, none in the schema. See the warning above.
  • defaults on an EAV form. Read by the block for a new record, not in the EAV schema. Use overrides.fields.<code>.default, which is read on new records and does lint.

See also