JSON Forms
Declare admin forms as JSON, then layer module-specific overlays on top. No XML, no UI components, no PHP for the common case — but every layer is open if you need to extend it.
The shape in 30 seconds
A form file lives at view/adminhtml/form/<id>.json in any module. Nebula scans every enabled module on boot, deep-merges files with the same id, and renders the result.
{
"id": "store_group_edit",
"acl": "Magento_Backend::store",
"dataSource": {
"provider": "store_group.form",
"config": { "identifierParam": "group_id" }
},
"settings": {
"title": "Store",
"identifierField": "group[group_id]",
"saveUrl": "adminhtml/system_store/save",
"backUrl": "adminhtml/system_store/index"
},
"fieldsets": {
"general": {
"label": "General Information",
"position": 10,
"open": true,
"fields": {
"group[name]": { "type": "text", "label": "Name", "required": true, "position": 10 }
}
}
}
}That JSON renders the entire admin form: title bar, save buttons, fieldset card, field with validation, Alpine model registered for serialization at submit. Everything else is the framework default.
Anatomy of a form
| Top-level key | Required | Purpose |
|---|---|---|
| id | base only | Snake_case identifier; matches the filename. Overlays omit it. |
| acl | no | Magento ACL resource — gates the page |
| dataSource | yes | Where the entity loads from (provider alias + config) |
| settings | yes | Title, save/back/delete URLs, identifierField, fieldPrefix, layout |
| fieldsets | one of | Map of fieldset_id → fieldset definition (Simple form path) |
| layout | one of | Tree of layout nodes (EAV / multi-tab forms) |
| hiddenFields | no | Static key/value pairs always serialized to the form |
Fields — the building blocks
A field is a key/value pair under fieldsets.<id>.fields. The key is the form input name (supports bracket paths like product[name]). The value is the field definition.
"product[meta_title]": {
"type": "text",
"label": "Meta Title",
"required": false,
"validation": { "maxLength": 70 },
"default": "",
"placeholder": "Show in search engine results",
"note": "Keep it under 60 characters for best SEO.",
"colspan": 2,
"position": 30
}Built-in types shipped by NebulaForm:
"renderer": "snippet.<id>" instead of type. See the Snippets guide.Rows, columns & layout
Two layout modes — pick the one that fits the page.
Single-column flow (default)
Set nothing — fields stack vertically inside a fieldset card. Use position to order them.
"fieldsets": {
"general": {
"label": "General",
"position": 10,
"open": true,
"fields": {
"name": { "type": "text", "label": "Name", "position": 10 },
"sku": { "type": "text", "label": "SKU", "position": 20 },
"status": { "type": "select", "label": "Status", "options": [...], "position": 30 }
}
}
}Multi-column grid (columns + colspan)
Set columns on the fieldset to split into N columns. Each field declares its width viacolspan (defaults to 1). Auto-wraps to a new row when the row fills.
"fieldsets": {
"pricing": {
"label": "Pricing",
"position": 20,
"columns": 4,
"fields": {
"price": { "type": "number", "label": "Price", "colspan": 2, "position": 10 },
"special_price": { "type": "number", "label": "Special Price", "colspan": 2, "position": 20 },
"tax_class_id": { "type": "select", "label": "Tax Class", "colspan": 4, "position": 30 }
}
}
}Two 2-wide fields share a row, then the tax-class spans the full 4 columns underneath.
Two-column page layout (sidebar + main)
Set "layout": "two-column" on the form's settings, then route fieldsets into the left or right column via the column property.
"settings": {
"title": "Edit Page",
"layout": "two-column"
},
"fieldsets": {
"content": { "label": "Content", "column": "main", "position": 10, "fields": {...} },
"seo": { "label": "SEO", "column": "sidebar", "position": 10, "fields": {...} },
"publishing":{ "label": "Publishing","column": "sidebar", "position": 20, "fields": {...} }
}EAV layout tree (tabs / sections)
EAV forms (catalog product, category) use a layout tree instead of fieldsets. Nodes are typed (tabs, section, column) and reference attribute codes by name — Nebula resolves the EAV metadata at render time.
{
"id": "catalog_category_edit_refresh",
"type": "eav",
"entity": "catalog_category",
"settings": { "title": "Category", "saveUrl": "catalog/category/save", "fieldPrefix": "category" },
"layout": [
{
"id": "category_tabs",
"type": "tabs",
"children": [
{ "id": "general", "label": "General", "type": "section",
"fields": ["name", "is_active", "include_in_menu"] },
{ "id": "content", "label": "Content", "type": "section",
"fields": ["description", "image"] },
{ "id": "products", "label": "Products", "type": "section",
"renderer": "snippet.category_products" }
]
}
]
}Replacing & extending fields
Any module can drop an overlay next to a base form. Same filename, no id — the loader deep-merges in module load order. Overlays can rename labels, change types, hide fields, or insert new ones, without touching the original module.
Base form
{
"id": "catalog_rule_edit",
"fieldsets": {
"general": {
"fields": {
"name": { "type": "text", "label": "Rule Name", "position": 10 },
"discount_amount": { "type": "number", "label": "Discount", "position": 20 }
}
}
}
}Your overlay
{
"fieldsets": {
"general": {
"fields": {
"discount_amount": { "label": "Discount %", "position": 25 },
"max_uses": { "type": "number", "label": "Max Uses", "position": 30 }
}
}
}
}Three overlay verbs:
Add
Declare a new field key. It appears at position.
"max_uses": {
"type": "number",
"label": "Max",
"position": 30
}Patch
Same key, partial definition. Values deep-merge.
"discount_amount": {
"label": "Discount %",
"position": 25
}Remove
Set the special $remove sentinel.
"old_field": {
"$remove": true
}Including a snippet
When a single field type isn't enough — e.g. a category tree, rule conditions UI, media gallery — drop in a snippet. Snippets are reusable bundles of (JSON config + phtml template + optional ViewModel).
"fieldsets": {
"conditions": {
"label": "Conditions",
"position": 15,
"renderer": "snippet.rule_conditions",
"rule_type": "catalog"
}
}"fields": {
"media_gallery": {
"label": "Media",
"renderer": "snippet.media_gallery",
"position": 50
}
}The snippet's phtml gets the entity, form block, and section config injected as block data, plus its ViewModel (if registered). Read the Snippets guide for authoring details.
Render flow
view/adminhtml/form/cms_page_edit.json
│ (any module)
▼
Loader.collect('form', 'cms_page_edit')
│ scans every enabled module, returns ordered file list
▼
Merger.merge(files)
│ base + overlays, deep-merge, $remove, position sort
▼
NebulaForm\Block\Form (or EavForm)
│ getDefinition() → resolved JSON
│ getEntityData() → DataProviderResolver.fetch(provider, config)
│ getFieldValue('cms[title]') → walks bracket path in entity data
▼
view/adminhtml/templates/form.phtml
│ iterates fieldsets, includes field/<type>.phtml per field
▼
field/text.phtml
│ <div x-data="nebulaField_text({ fieldName, value, ... })">
│ <input x-model="value">
│ </div>
▼
Alpine.start()
│ factory init() registers model with Alpine.store('nebulaModels')
│
│ on Save click:
│ store.validateAll() → store.serializeAll(form) → form.submit()Authoring a new form — checklist
Pick the form id
Snake_case, descriptive: blog_post_edit, customer_address_edit. The id matches the filename and becomes the merge key for overlays.
Drop the JSON
Path: YourModule/view/adminhtml/form/blog_post_edit.json. Start with the top-level skeleton.
{
"id": "blog_post_edit",
"acl": "Acme_Blog::posts",
"dataSource": {
"provider": "blog_post.form",
"config": { "identifierParam": "post_id" }
},
"settings": {
"title": "Post",
"identifierField": "post[id]",
"saveUrl": "blog/post/save",
"backUrl": "blog/post/index",
"deleteUrl": "blog/post/delete"
},
"fieldsets": {}
}Register the data provider
The provider name in JSON resolves through DataProviderRegistry. Bind your class via di.xml.
<type name="Qoliber\NebulaComponent\Model\DataProviderRegistry">
<arguments>
<argument name="providers" xsi:type="array">
<item name="blog_post.form" xsi:type="object">Acme\Blog\Provider\PostFormProvider</item>
</argument>
</arguments>
</type>Add the route + controller
One controller — Acme\Blog\Controller\Adminhtml\Post\Edit — that creates a layout block ofQoliber\NebulaForm\Block\Form with the form id passed in.
public function execute() {
$page = $this->resultPageFactory->create();
$page->getLayout()->createBlock(
\Qoliber\NebulaForm\Block\Form::class,
'blog.post.form',
['data' => ['form_id' => 'blog_post_edit']]
);
return $page;
}Fill in fieldsets
Add fieldsets with fields. Use position to order, colspan for grid layout, and snippets when a single field isn't enough. Overlay it later from another module if you need to extend.
Reference paths
- code/Qoliber/NebulaComponent/schemas/form.schema.json
- code/Qoliber/NebulaComponent/schemas/eav-form.schema.json
- code/Qoliber/NebulaComponent/Model/Definition/Loader.php
- code/Qoliber/NebulaComponent/Model/Definition/Merger.php
- code/Qoliber/NebulaForm/Block/Form.php · EavForm.php
- code/Qoliber/NebulaForm/view/adminhtml/templates/form.phtml