Skip to content

Saving and validation

What the browser posts

A Nebula form is one native <form method="post"> whose action is settings.saveUrl, by default */*/save, which is the save action of the current controller. Fields do not write hidden inputs. Each field's Alpine component registers a model in the store Alpine.store('nebulaModels'), and on the form's submit event the store serialises every model into the request as ordinary form fields. The record id from settings.identifierField, the form key, a nebula_form_id input carrying the definition id, and the entries in hiddenFields and settings.hiddenInputs are added as hidden inputs. A controller that whitelists the POST should expect form_key and nebula_form_id beside its own fields. settings.hiddenParams is different: those params are copied onto the save URL, not into the body.

settings.fieldPrefix wraps every field name, so a form with "fieldPrefix": "block" posts block[title], which is what many core save controllers expect.

Serialisation runs on submit, not on the Save button's click. Enter in a text field and a programmatic submit carry the same data. What breaks it is a second <form> inside the page: the browser closes the outer form at the nested one and every field after it is not submitted. Widgets you add must not contain a form element.

Where it goes

Every form Nebula ships posts to an entity-specific controller: core's own save controller for products, categories, customers, CMS pages and blocks, and the rest; Nebula's own controllers for the screens it took over or added, among them tax rates, tax rules, ratings, checkout agreements, custom variables and landing pages. Those controllers validate the way core does, and that is where a shipped form's server-side validation comes from.

A form you write posts to your controller, as in Your first form. Its job is to whitelist the POST against the fields it expects, load or create the entity, addData(), and save.

The generic save route

Nebula_Form also ships a generic JSON endpoint at nebula/form/save. It expects a JSON body:

{ "id": "vendor_module_note_edit", "entityId": 12, "storeId": 1, "data": { "name": "Hello", "is_active": "1" } }

and does five things in order:

  1. Resolves the form definition by id and checks its acl.
  2. Runs the server-side Validator over data using each field's validation rules. Errors come back as { "success": false, "errors": { "name": "Name is required" } }.
  3. Whitelists data against the fields the definition declares, in either the fieldsets or the layout dialect.
  4. Hands the payload to SavePipeline, which dispatches nebula_form_save_before, persists, and dispatches nebula_form_save_after. entityId and storeId are the two body keys that reach a save handler and both events as getContext(), alongside the handlerAlias the pipeline adds.
  5. Returns { "success": true, "entityId": 12 }, plus messages and redirectUrl when the save produced them.

Persistence in the pipeline is one of two paths. A saveHandler alias on the definition, bound on SaveHandlerRegistry to a class implementing FormSaveHandlerInterface, owns the save. Without one, the pipeline loads the entity through dataSource.repository, creates it from dataSource.model when there is no id, and saves it back. All three keys are in the form schema since 2026-09-13, so a definition using them lints.

{ "id": "vendor_module_note_edit", "saveHandler": "vendor.note", "dataSource": { "provider": "vendor_module_note.form" } }
<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 generic route is an extension point, not the default

No form Nebula ships posts to it. The two save events are not limited to this route. Any save controller that owns its persistence can hand the pipeline a callback through SavePipeline::around($formId, $data, $context, $persist) and get both events around it. The callback is given the payload after the nebula_form_save_before observers have had it, and returns at least entityId.

Which shipped screens dispatch the save events

Six of Nebula's own save controllers route through SavePipeline::around(), so they keep their entity-specific save logic and still fire both events:

Screen Save controller Form id the events carry
Landing page nebula_landing/page/save nebula_landing_page_edit
Custom variable nebulasystem/variable/save variable_edit
Rating nebula/rating/save review_rating_edit
Checkout agreement nebula/agreement/save checkout_agreement_edit
Tax rate nebula/taxrate/save tax_rate_edit
Tax rule nebula/taxrule/save tax_rule_edit

The id in the third column is the definition's own id in every case. Two controllers used to pass a different one — rating_edit for review_rating_edit, custom_variable_edit for variable_edit — so an observer keyed on the definition never fired for those saves; both were corrected on 2026-09-13 and a repository test now fails on any controller naming a form that does not exist.

Nothing else dispatches them. Core's own save controllers do not, and neither do Nebula's email template, grid quick edit and grid view controllers. To observe a product, category, customer, CMS page or CMS block save, use Magento's own catalog_product_save_after and its siblings.

nebula_form_save_before is a write hook, not a notification

The pipeline reads the payload back off the event after dispatching it, so whatever the before observers leave is what gets persisted. On the generic route and on all six screens above, any module with an observer can change what is written after the controller's own checks have run. Treat an observer on it as code that can rewrite the record, not only read it. ACL resources and events has the full contract.

The load endpoint

Nebula_Form ships a third generic endpoint, GET nebula/form/load?id=<formId>&entityId=<id>. It resolves the form definition by id, runs that definition's dataSource.provider for the given entityId, and returns { "success": true, "data": { ... } }. It returns the provider's data only, never the definition.

A definition with no dataSource.provider comes back as { "success": false, "message": "No data provider configured" }. Any other failure comes back as { "success": false, "message": "Could not load form data." }, with the real error in the admin log.

Access is the same default-deny check on the definition's acl that gates rendering the form, so a definition without an acl is refused. Like the generic save route, nothing Nebula ships calls it. It is the endpoint to reach for when you need a form's data outside a page render, such as a drawer, a preview or a custom UI.

Client-side validation

Rules are read from a field's required flag and its validation object, and rendered as a data-validate attribute the core bundle evaluates on blur and on submit:

Rule Meaning
required: true not empty
number: true numeric
min, max numeric bounds
minLength, maxLength string length
email: true an address
pattern a regular expression the value must match

Messages are interpolated with the field label. A module can add or override a rule from any script that runs after the core bundle:

window.Nebula.rule('sku', (value) => /^[A-Z0-9-]+$/.test(String(value)), '{label} must be an SKU');

and use it as "validation": { "sku": true }. Snippet templates that need whole-form checks register one with Nebula.registerValidator(fn); it receives the form element and returns the fields in error.

Server-side validation

Nebula\Form\Model\Validator reads the same validation object and enforces required, minLength, maxLength, pattern (as a PCRE pattern with delimiters), min, max, email, and custom, where custom names a class implementing FieldValidatorInterface:

"sku": { "type": "text", "validation": { "required": true, "custom": "Vendor\\Module\\Model\\Validator\\Sku" } }

It runs only on the generic save route and on nebula/form/validate. A form that posts to its own controller is validated by that controller. Write the rule in both places when it matters; the client rule gives the message under the field, and the controller gives the guarantee.

The Delete button

settings.deleteUrl renders a Delete button that POSTs to that URL with the record id and a confirmation dialog. A provider can hide it for one record by returning _can_delete: false. That is chrome, not a permission; the delete controller still decides.

Provider-supplied notices and options

Key returned by the provider Effect
_can_delete: false No Delete button for this record
_notices: [{"type": "warning", "message": "This note is locked"}] Alerts above the form. type is warning, error or success, and anything else becomes warning
_options: { "group_id": [ {"value": 1, "label": "General"} ] } Options for one field, when the list depends on the record; keep an optionsSource on the field as the fallback for a new record