Skip to content

Your first form

This page adds an edit form to the notes module from Your first grid. The form needs three things the grid did not: a data provider that loads one record, a save controller, and the definition itself.

app/code/Vendor/Module/
├── etc/di.xml                                      ← the provider alias (added)
├── Model/DataProvider/NoteFormDataProvider.php
├── view/adminhtml/form/vendor_module_note_edit.json
└── Controller/Adminhtml/Note/{Edit,NewAction,Save,Delete}.php

1. The data provider

A form provider returns a flat map of field name to value for one record, or an empty array for a new one:

Model/DataProvider/NoteFormDataProvider.php
namespace Vendor\Module\Model\DataProvider;

use Magento\Framework\Exception\NoSuchEntityException;
use Nebula\Component\Api\V1\FormDataProviderInterface;
use Vendor\Module\Api\NoteRepositoryInterface;

class NoteFormDataProvider implements FormDataProviderInterface
{
    public function __construct(private readonly NoteRepositoryInterface $repository)
    {
    }

    /**
     * @param array<string, mixed> $config  dataSource.config from the definition
     * @param array<string, mixed> $params  includes entityId, read from the URL
     * @return array<string, mixed>
     */
    public function getData(array $config, array $params = []): array
    {
        $entityId = $params['entityId'] ?? null;
        if ($entityId === null) {
            return [];
        }

        try {
            return $this->repository->getById((int) $entityId)->getData();
        } catch (NoSuchEntityException) {
            return [];
        }
    }
}

Bind it to an alias:

etc/di.xml
<type name="Nebula\Component\Model\Registry\DataProviderRegistry">
    <arguments>
        <argument name="bindings" xsi:type="array">
            <item name="vendor_module_note.form" xsi:type="string">Vendor\Module\Model\DataProvider\NoteFormDataProvider</item>
        </argument>
    </arguments>
</type>

A provider may also return keys with a leading underscore. _can_delete: false hides the Delete button for this record, _notices shows alerts above the form, and _options supplies options for a field that depend on the record. They are engine instructions, not fields.

2. The definition

view/adminhtml/form/vendor_module_note_edit.json
{
    "id": "vendor_module_note_edit",
    "acl": "Vendor_Module::note",
    "route": ["vendor_module_note_edit", "vendor_module_note_new"],
    "dataSource": {
        "provider": "vendor_module_note.form",
        "config": { "identifierParam": "note_id" }
    },
    "settings": {
        "title": "Note",
        "identifierField": "note_id",
        "backUrl": "vendor_module/note/index",
        "saveUrl": "vendor_module/note/save",
        "deleteUrl": "vendor_module/note/delete"
    },
    "fieldsets": {
        "general": {
            "label": "General",
            "position": 10,
            "open": true,
            "fields": {
                "note_id":   { "label": "Note ID", "type": "hidden", "position": 10 },
                "name":      { "label": "Name", "type": "text", "position": 20, "required": true,
                               "validation": { "required": true, "maxLength": 255 } },
                "is_active": { "label": "Active", "type": "toggle", "position": 30, "default": "1" }
            }
        }
    }
}
  • route lists the full action names the form mounts on. The new action forwards to edit in the controller below, so listing both is harmless and lets you drop the forward later.
  • dataSource.config.identifierParam names the URL parameter that carries the record id. The engine reads it and passes it to the provider as entityId.
  • settings.identifierField is the field whose value is posted as the record id. saveUrl defaults to */*/save, so it could be omitted here.
  • required renders the asterisk and the client rule. validation holds the rules by name; see Saving and validation.
  • default is used on a new record when the provider returns nothing for the field.

Because the definition has a route, no layout XML is needed. The route map finds it, injects the form block into content, and removes the core page actions. nebula:crud:generate writes a vendor_module_note_edit.xml as well, declaring the same block class under the same name, nebula.vendor_module_note_edit, so the page still renders one form. If you would rather mount from layout XML, leave route out and keep that file: a Nebula\Form\Block\Form block carrying a form_id argument, the same shape as the grid's layout.

3. The controllers

Controller/Adminhtml/Note/Edit.php
class Edit extends \Magento\Backend\App\Action implements HttpGetActionInterface
{
    public const ADMIN_RESOURCE = 'Vendor_Module::note';

    public function __construct(Context $context, private readonly PageFactory $pageFactory)
    {
        parent::__construct($context);
    }

    public function execute(): Page
    {
        $page = $this->pageFactory->create();
        $page->setActiveMenu('Vendor_Module::note');
        $page->getConfig()->getTitle()->prepend(__('Notes'));
        return $page;
    }
}
Controller/Adminhtml/Note/NewAction.php
public function execute(): ResultInterface
{
    return $this->resultForwardFactory->create()->forward('edit');
}
Controller/Adminhtml/Note/Save.php
class Save extends \Magento\Backend\App\Action implements HttpPostActionInterface
{
    public const ADMIN_RESOURCE = 'Vendor_Module::note';

    /** Fields the form is allowed to persist. The primary key comes from the URL, never the body. */
    private const ALLOWED_FIELDS = ['name', 'is_active'];

    public function __construct(
        Context $context,
        private readonly NoteRepositoryInterface $repository,
        private readonly NoteFactory $factory,
    ) {
        parent::__construct($context);
    }

    public function execute(): ResultInterface
    {
        $redirect = $this->resultRedirectFactory->create();
        $data = $this->getRequest()->getPostValue();
        if (!$data) {
            return $redirect->setPath('*/*/');
        }

        $data = array_intersect_key($data, array_flip(self::ALLOWED_FIELDS));
        $id = (int) ($this->getRequest()->getParam('note_id') ?: 0);

        try {
            $note = $id ? $this->repository->getById($id) : $this->factory->create();
            $note->addData($data);
            $this->repository->save($note);
            $this->messageManager->addSuccessMessage(__('The Note was saved.'));
        } catch (\Exception $e) {
            $this->messageManager->addErrorMessage($e->getMessage());
            return $redirect->setPath('*/*/edit', ['note_id' => $id]);
        }

        return $redirect->setPath('*/*/');
    }
}

Two habits in that controller matter. The POST is intersected with a whitelist, so a crafted request cannot set columns the form does not expose. And the model gets addData(), not setData(), because a form posts every field it renders and nothing else.

4. Open it

bin/magento cache:clean layout block_html nebula_definitions

Open the grid and click Edit on a row, or the Add New Note button. Save with an empty name and the form refuses with a message under the field. Fill it in and Save returns to the grid with a success message.

What you got

  • A sticky action bar: Back, Delete with confirmation, Save, and Save and Continue when settings.saveAndContinue is on.
  • Dirty tracking: leaving with unsaved changes asks first.
  • Client validation from required and validation, with messages under the field.
  • Keyboard submit: Enter posts the form through the same serialisation as the Save button.
  • The record id as a hidden input and a form key, so the save controller is a plain Magento POST action.

Next