NebulaNebula
Architecture

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

Data flow: Field → Model → Store → Form Submit
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

1

Alpine initializes the component

When Alpine encounters x-data="nebulaField_text({...})", it creates the component and calls init().

nebula-component.js
init() {
    if (this.fieldName) {
        this._modelKey = 'field:' + this.fieldName;
        Alpine.store('nebulaModels').register(this._modelKey, this);
    }
}
2

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

Store state after registration
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()... }
}
3

User clicks Save

The nebulaForm.saveWithBack() method runs the full pipeline:

nebula-form.js
// 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();
4

Component removed from DOM

Alpine calls destroy() which unregisters the model, preventing stale data from being serialized.

Built-in Field Models

TypeComponentSerialize Behavior
textnebulaField_text[{name, value}]
textareanebulaField_textarea[{name, value}]
selectnebulaField_select[{name, value}]
multiselectnebulaField_multiselectOne {name[], value} per selection
togglenebulaField_toggletrue → "1", false → "0"
checkboxnebulaField_checkboxtrue → "1", false → "0"
numbernebulaField_number[{name, value}]
hiddennebulaField_hidden[{name, value}]
datenebulaField_date[{name, value}]
colornebulaField_color[{name, value}]
passwordnebulaField_password[{name, value}]
Disabled fields return an empty array from serialize(). This means disabled fields are automatically excluded from form submission.

Built-in Section Models

Custom Options

section:customOptions

Serializes product custom options with nested values, prices, and SKUs into product[options][N][...] format.

Module: Qoliber_NebulaTheme

Bundle Options

section:bundleOptions

Serializes bundle options with selections into bundle_options[bundle_options][N][...] format.

Module: Qoliber_NebulaTheme

Creating a Custom Field Model

Example: a color swatch picker field. Use the IIFE pattern to prevent duplicate registration.

nebula-swatch-field.js
(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:

swatch_field.phtml
<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.

nebula-download-links.js
(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:

Browser Console
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"},
    ...
  ]
}
Tip: Run Object.keys(Alpine.store('nebulaModels')._models) to see all registered model keys without serializing.

Config Properties Reference

PropertyTypeDescription
fieldNamestringForm field name (e.g., "product[name]"). Used as registry key and hidden input name.
fieldTypestringField type identifier. Informational only.
valuemixedCurrent field value. Bound via x-model in templates.
defaultmixedFallback value if value is not provided.
disabledbooleanIf true, serialize() returns [] (excluded from submission).
requiredbooleanPassed to validation rules.
validationobjectRules: {required, min, max, minLength, maxLength, email, pattern}.
optionsarrayFor select/multiselect: [{value, label}] pairs.