create-controller-form-actions
Read @.ai/Component/Controller/CONTEXT.md for controller conventions (base class, DI, security attributes, error mapping).
Read @.ai/Component/Forms/CONTEXT.md for the settings-vs-CRUD decision tree. Then, depending on which branch this controller serves, load only the relevant pattern detail:
- CRUD action →
@.ai/Component/Forms/CRUD.md(FormBuilder + FormHandler pair, IdentifiableObject base classes) - Settings action →
@.ai/Component/Forms/SETTINGS.md(baseHandler, hooks, allowed exception)
Two patterns: CRUD entity forms inject the
FormBuilder+ IdentifiableObjectFormHandlerpair (sections 1–3 below). Settings forms inject the baseHandleronce (section 4 below). For the full settings-form stack (DataConfiguration + FormDataProvider + FormType + 4 YAML entries), the umbrellacreate-settings-formskill covers everything.
1. Create action
FormBuilderInterface and FormHandlerInterface are injected as action arguments (preferred DI mode — see Controller/CONTEXT.md), each with an explicit #[Autowire(service: '…')] so the action picks up the domain-specific form services:
#[AdminSecurity("is_granted('create', request.get('_legacy_controller'))")]
public function createAction(
Request $request,
#[Autowire(service: 'prestashop.core.form.identifiable_object.builder.{domain}_form_builder')]
FormBuilderInterface ${domain}FormBuilder,
#[Autowire(service: 'prestashop.core.form.identifiable_object.handler.{domain}_form_handler')]
FormHandlerInterface ${domain}FormHandler,
): Response
- GET: build form via
FormBuilderInterface::getForm(), render form template - POST: handle request via
FormHandlerInterface::handle()— it internally callsFormDataHandler::create()which dispatches the Add command - Never instantiate commands directly in the controller
- On success: flash message + redirect to index or edit page (return the new entity ID via
getIdentifiableObjectId()) - On failure: re-render form with errors
Reference: TaxController::createAction(), ManufacturerController::createAction()
2. Edit action
Same injection mode — FormBuilderInterface and FormHandlerInterface as action arguments:
#[AdminSecurity("is_granted('update', request.get('_legacy_controller'))")]
public function editAction(
Request $request,
int ${domain}Id,
#[Autowire(service: 'prestashop.core.form.identifiable_object.builder.{domain}_form_builder')]
FormBuilderInterface ${domain}FormBuilder,
#[Autowire(service: 'prestashop.core.form.identifiable_object.handler.{domain}_form_handler')]
FormHandlerInterface ${domain}FormHandler,
): Response
- GET:
${domain}FormBuilder->getFormFor(${domain}Id)—FormDataProvider::getData($id)is called internally to pre-fill the form - POST:
${domain}FormHandler->handle($form)— callsFormDataHandler::update()which dispatches Edit command - Catch
{Domain}NotFoundException→ error flash, redirect to index (entity may have been deleted concurrently)
Reference: TaxController::editAction(), ManufacturerController::editAction()
3. Entity-specific actions
Some entities require additional actions beyond create/edit:
- Image deletion:
deleteCoverImageAction(int $id) - Export:
exportAction(Request $request)— generates CSV/PDF - View page:
viewAction(int $id)— read-only detail page (e.g. Customer, Order) - Position update:
updatePositionAction(Request $request)— handled byPositionDefinition
For these single-purpose actions, skip FormBuilder/FormHandler — they exist to bridge a Symfony form lifecycle to a CQRS command, which is overkill for an action that just dispatches one command or query. Inject the bus (or specific handler) and dispatch directly:
#[AdminSecurity("is_granted('delete', request.get('_legacy_controller'))")]
public function deleteCoverImageAction(int ${domain}Id): RedirectResponse
{
try {
$this->dispatchCommand(new DeleteCoverImageCommand(${domain}Id));
$this->addFlash('success', /* … */);
} catch ({Domain}NotFoundException $e) {
$this->addFlash('error', $this->getErrorMessageForException($e, $this->getErrorMessages()));
}
return $this->redirectToRoute('admin_{domain}s_edit', ['{domain}Id' => ${domain}Id]);
}
The FormBuilder/FormHandler pair is required for the create/edit form lifecycle — not for every controller action.
4. Settings form action
For a settings form (options block writing into ps_configuration), the controller injects the base Handler once and calls getForm() / save() directly. There is no FormBuilder; there is no FormDataHandler. Both interfaces share the short name FormHandlerInterface, so use an as alias to disambiguate:
use PrestaShop\PrestaShop\Core\Form\FormHandlerInterface as ConfigurationFormHandlerInterface;
use PrestaShop\PrestaShop\Core\Form\IdentifiableObject\Handler\FormHandlerInterface; // unaliased — CRUD on this page
Index action — render the form alongside the rest of the page:
#[AdminSecurity("is_granted('read', request.get('_legacy_controller'))")]
public function indexAction(
Request $request,
#[Autowire(service: 'prestashop.admin.{domain}.{name}.form_handler')]
ConfigurationFormHandlerInterface $optionsFormHandler,
): Response {
return $this->render('@PrestaShop/Admin/.../index.html.twig', [
'optionsForm' => $optionsFormHandler->getForm()->createView(),
// … grid, toolbar buttons, etc.
]);
}
Save action — POST handler:
#[DemoRestricted(redirectRoute: 'admin_{page}_index')]
#[AdminSecurity("is_granted('update', request.get('_legacy_controller'))", redirectRoute: 'admin_{page}_index')]
public function saveOptionsAction(
Request $request,
#[Autowire(service: 'prestashop.admin.{domain}.{name}.form_handler')]
ConfigurationFormHandlerInterface $optionsFormHandler,
): Response {
$form = $optionsFormHandler->getForm();
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$errors = $optionsFormHandler->save($form->getData());
if (0 === count($errors)) {
$this->addFlash('success', $this->trans('Update successful', [], 'Admin.Notifications.Success'));
return $this->redirectToRoute('admin_{page}_index');
}
$this->addFlashErrors($errors);
}
return $this->redirectToRoute('admin_{page}_index');
}
Key differences from CRUD form actions:
- Single handler injection (not a FormBuilder + FormHandler pair).
$formHandler->save($form->getData())returns an array of error messages (empty = success) — noResultobject, nogetIdentifiableObjectId().- Index action passes the form view alongside other page data (grid, etc.); save action only redirects.
- Reference:
CountryController::indexAction()andCountryController::saveOptionsAction()(PR #41406).
Rules
- See
@.ai/Component/Controller/CONTEXT.mdfor all controller conventions (DI order, security attributes, error mapping) - Create/edit go through FormBuilder/FormHandler — never build the create/edit commands manually
- Single-purpose actions (toggle, delete sub-resource, export, view) dispatch commands/queries directly
- FormDataProvider transforms query results into form-ready data, not the controller
- Catch NotFoundException on edit — the entity may have been deleted between page load and save