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:
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:
<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¶
{
"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" }
}
}
}
}
routelists the full action names the form mounts on. Thenewaction forwards toeditin the controller below, so listing both is harmless and lets you drop the forward later.dataSource.config.identifierParamnames the URL parameter that carries the record id. The engine reads it and passes it to the provider asentityId.settings.identifierFieldis the field whose value is posted as the record id.saveUrldefaults to*/*/save, so it could be omitted here.requiredrenders the asterisk and the client rule.validationholds the rules by name; see Saving and validation.defaultis 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¶
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;
}
}
public function execute(): ResultInterface
{
return $this->resultForwardFactory->create()->forward('edit');
}
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¶
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.saveAndContinueis on. - Dirty tracking: leaving with unsaved changes asks first.
- Client validation from
requiredandvalidation, 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¶
- Two columns, tabs and sections: the
layoutkey in the Form reference. - A select with options from a source model:
optionsSourcein the Form reference. - A form for a product, category or customer: EAV forms.
- Persist through a save handler instead of a controller: Saving and validation.