NebulaNebula
Developer guide

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.

view/adminhtml/form/store_group_edit.json
{
  "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 keyRequiredPurpose
idbase onlySnake_case identifier; matches the filename. Overlays omit it.
aclnoMagento ACL resource — gates the page
dataSourceyesWhere the entity loads from (provider alias + config)
settingsyesTitle, save/back/delete URLs, identifierField, fieldPrefix, layout
fieldsetsone ofMap of fieldset_id → fieldset definition (Simple form path)
layoutone ofTree of layout nodes (EAV / multi-tab forms)
hiddenFieldsnoStatic 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.

A complete field
"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:

text
textarea
select
multiselect
toggle
checkbox
number
date
color
image
file
password
hidden
wysiwyg
pagebuilder
Anything more complex than a single input — media gallery, category tree, rule editor — is a snippet, referenced as "renderer": "snippet.<id>" instead of type. See the Snippets guide.

Rows, columns & layout

Two layout modes — pick the one that fits the page.

1

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 }
    }
  }
}
2

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.

3

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": {...} }
}
4

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

Magento_Catalog::view/adminhtml/form/catalog_rule_edit.json
{
  "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

Acme_RulesPlus::view/adminhtml/form/catalog_rule_edit.json
{
  "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
}
The same merge rules work at any depth — replace a fieldset, swap a field type, drop a tab in the EAV layout tree. The Loader is deterministic: module load order = overlay precedence.

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).

As a fieldset (full row)
"fieldsets": {
  "conditions": {
    "label": "Conditions",
    "position": 15,
    "renderer": "snippet.rule_conditions",
    "rule_type": "catalog"
  }
}
As a single field (replaces input)
"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

JSON → block → template → Alpine
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()
The form layer guarantees one thing: at submit time, every model has serialized into the form. That's the entire contract between the form layer and Magento's save controller. Templates own zero POST plumbing.

Authoring a new form — checklist

1

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.

2

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": {}
}
3

Register the data provider

The provider name in JSON resolves through DataProviderRegistry. Bind your class via di.xml.

YourModule/etc/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>
4

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;
}
5

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