Form Models
The Alpine.js data layer that separates form data ownership from templates. Fields and sections auto-register models that serialize, validate, and clean up automatically.
Why the Model Layer Exists
Traditional Magento forms scatter hidden <input type="hidden"> elements throughout phtml templates. Complex sections like bundle options or custom options end up with dozens of hidden inputs managed by JavaScript. Templates become tightly coupled to the save controller's POST data contract.
Nebula inverts this: templates are purely visual. Each field and section component owns its data through an Alpine.js model that implements a serialize() contract. On form submit, the central store collects all data at once and injects hidden inputs automatically.
Architecture Flow
Template (phtml) Alpine.js Model Layer
┌──────────────────┐ ┌──────────────────────────────────┐
│ <div x-data= │ init() │ Alpine.store('nebulaModels') │
│ "nebulaField_ │──────────────>│ ._models = { │
│ text({ │ registers │ 'field:product[name]': m1, │
│ fieldName: │ itself │ 'field:product[sku]': m2, │
│ 'product[name]'│ │ 'section:customOptions':m3 │
│ })"> │ │ } │
│ <input x-model= │ └───────────────┬──────────────────┘
│ "value"> │ │
│ </div> │ on form submit
│ │ │
│ (zero hidden │ ┌───────────────v──────────────────┐
│ inputs) │ │ 1. validateAll() │
└──────────────────┘ │ 2. serializeAll(form) │
│ → m1.serialize() → [{n,v}] │
│ → m2.serialize() → [{n,v}] │
│ → m3.serialize() → [{n,v}…] │
│ 3. inject <input type=hidden> │
│ 4. form.submit() │
└──────────────────────────────────┘Before vs After
Before: Hidden Inputs in Templates
<!-- product form template -->
<input type="hidden"
name="product[name]"
value="<?= $name ?>">
<input type="hidden"
name="product[sku]"
value="<?= $sku ?>">
<input type="hidden"
name="product[status]"
value="<?= $status ?>">
<!-- JS manually manages these
on every user interaction -->
<script>
updateHiddenInput('product[name]',
document.getElementById('name').value
);
</script>Templates own data. Tight coupling. Hidden inputs scattered everywhere.
After: Model Serialize
<!-- product form template -->
<div x-data="nebulaField_text({
fieldName: 'product[name]',
value: '<?= $name ?>'
})">
<input type="text" x-model="value">
</div>
<!-- No hidden inputs anywhere.
On submit, serializeAll()
handles everything. -->Templates are visual only. Models own data. Zero hidden inputs in source.
How Auto-Registration Works
Alpine initializes the component
When Alpine encounters x-data="nebulaField_text({...})", it creates the component and calls init().
init() {
if (this.fieldName) {
this._modelKey = 'field:' + this.fieldName;
Alpine.store('nebulaModels').register(this._modelKey, this);
}
}Model registers with the central store
The store maps the model key to the component instance. Field models use the field: prefix, section models use section:.
Alpine.store('nebulaModels')._models = {
'field:product[name]': { value: 'Widget', serialize()... },
'field:product[sku]': { value: 'WDG-001', serialize()... },
'field:product[status]': { value: '1', serialize()... },
'section:customOptions': { options: [...], serialize()... }
}User clicks Save
The nebulaForm.saveWithBack() method runs the full pipeline:
// 1. Validate all models
if (!Alpine.store('nebulaModels').validateAll()) return;
// 2. Validate DOM fields (data-validate attributes)
if (!Nebula.validateForm(form)) return;
// 3. Show loader
Nebula.showSaveLoader();
// 4. Serialize all models into hidden inputs
Alpine.store('nebulaModels').serializeAll(form);
// 5. Submit the form
form.submit();Component removed from DOM
Alpine calls destroy() which unregisters the model, preventing stale data from being serialized.
Built-in Field Models
| Type | Component | Serialize Behavior |
|---|---|---|
| text | nebulaField_text | [{name, value}] |
| textarea | nebulaField_textarea | [{name, value}] |
| select | nebulaField_select | [{name, value}] |
| multiselect | nebulaField_multiselect | One {name[], value} per selection |
| toggle | nebulaField_toggle | true → "1", false → "0" |
| checkbox | nebulaField_checkbox | true → "1", false → "0" |
| number | nebulaField_number | [{name, value}] |
| hidden | nebulaField_hidden | [{name, value}] |
| date | nebulaField_date | [{name, value}] |
| color | nebulaField_color | [{name, value}] |
| password | nebulaField_password | [{name, value}] |
serialize(). This means disabled fields are automatically excluded from form submission.Built-in Section Models
Custom Options
section:customOptionsSerializes product custom options with nested values, prices, and SKUs into product[options][N][...] format.
Module: Qoliber_NebulaThemeBundle Options
section:bundleOptionsSerializes bundle options with selections into bundle_options[bundle_options][N][...] format.
Module: Qoliber_NebulaThemeCreating a Custom Field Model
Example: a color swatch picker field. Use the IIFE pattern to prevent duplicate registration.
(function() {
if (window._nebulaSwatchFieldRegistered) return;
window._nebulaSwatchFieldRegistered = true;
var register = function() {
Alpine.data('nebulaField_swatch', function(config) {
return {
value: config.value ?? '',
fieldName: config.fieldName ?? '',
disabled: config.disabled ?? false,
swatches: config.swatches ?? [],
_modelKey: null,
init() {
if (this.fieldName) {
this._modelKey = 'field:' + this.fieldName;
Alpine.store('nebulaModels')
.register(this._modelKey, this);
}
},
destroy() {
if (this._modelKey) {
Alpine.store('nebulaModels')
.unregister(this._modelKey);
}
},
serialize() {
if (!this.fieldName || this.disabled) return [];
return [{
name: this.fieldName,
value: this.value
}];
},
validate() {
return this.value !== '';
},
selectSwatch(color) {
this.value = color;
}
};
});
};
if (window.Alpine) { register(); }
else { document.addEventListener('alpine:init', register); }
})();Use it in a template:
<div x-data="nebulaField_swatch({
fieldName: 'product[color]',
value: '<?= $escaper->escapeHtmlAttr($value) ?>',
swatches: ['#ff0000', '#00ff00', '#0000ff']
})">
<template x-for="color in swatches">
<button :style="'background:' + color"
:class="value === color
? 'ring-2 ring-nebula-500' : ''"
@click="selectSwatch(color)">
</button>
</template>
<!-- No hidden inputs needed -->
</div>Creating a Custom Section Model
Section models handle complex nested data. They register with a section: prefix and serialize arrays of structured data.
(function() {
if (window._nebulaDownloadLinksRegistered) return;
window._nebulaDownloadLinksRegistered = true;
var register = function() {
Alpine.data('nebulaDownloadLinks', function(config) {
return {
links: config.existingLinks || [],
init() {
Alpine.store('nebulaModels')
.register('section:downloadLinks', this);
},
destroy() {
Alpine.store('nebulaModels')
.unregister('section:downloadLinks');
},
serialize() {
var pairs = [];
this.links.forEach(function(link, i) {
var p = 'downloadable[link][' + i + ']';
pairs.push({
name: p + '[title]',
value: link.title
});
pairs.push({
name: p + '[price]',
value: link.price
});
pairs.push({
name: p + '[url]',
value: link.url
});
});
return pairs;
},
addLink() {
this.links.push({
title: '', price: '0', url: ''
});
},
removeLink(index) {
this.links.splice(index, 1);
}
};
});
};
if (window.Alpine) { register(); }
else { document.addEventListener('alpine:init', register); }
})();Debugging
Open the browser console on any Nebula form page and run:
Alpine.store('nebulaModels').debug()
// Returns:
{
"field:product[name]": [
{name: "product[name]", value: "Widget"}
],
"field:product[status]": [
{name: "product[status]", value: "1"}
],
"section:customOptions": [
{name: "product[options][0][title]", value: "Size"},
{name: "product[options][0][type]", value: "drop_down"},
...
]
}Object.keys(Alpine.store('nebulaModels')._models) to see all registered model keys without serializing.Config Properties Reference
| Property | Type | Description |
|---|---|---|
fieldName | string | Form field name (e.g., "product[name]"). Used as registry key and hidden input name. |
fieldType | string | Field type identifier. Informational only. |
value | mixed | Current field value. Bound via x-model in templates. |
default | mixed | Fallback value if value is not provided. |
disabled | boolean | If true, serialize() returns [] (excluded from submission). |
required | boolean | Passed to validation rules. |
validation | object | Rules: {required, min, max, minLength, maxLength, email, pattern}. |
options | array | For select/multiselect: [{value, label}] pairs. |