Forms
The form system provides server-rendered HTML forms backed by typed DTOs and Symfony Validator. A form is defined as a PHP class that uses a builder API to declare elements and their constraints. On submission the request data is deserialized into a DTO, validated, and returned to the controller. CSRF protection is applied automatically.
Input DTO
Every form is backed by an input DTO — a plain class implementing JsonSerializableInterface that
carries the submitted values. Properties are nullable (the form may be rendered without data) and are
annotated with #[Json] so the serializer can map request keys to them.
use Amarant\Framework\Attribute\Serialization\Json;
use Amarant\Framework\Contract\Data\JsonSerializableInterface;
use Amarant\Framework\Serialization\JsonSerializeTrait;
class ProductInput implements JsonSerializableInterface
{
use JsonSerializeTrait;
#[Json]
public ?string $name = null;
#[Json]
public ?string $sku = null;
#[Json('price')]
public ?string $price = null;
/**
* @var ProductAttributeInput[]
*/
#[Json(type: ProductAttributeInput::class)]
public array $attributes = [];
}
Keep input properties loosely typed (?string, mixed) — the form submits everything as strings from
the browser, and conversion to stronger types belongs in the saving logic, not the DTO.
Data transformer (model → DTO)
Before rendering a form for an existing entity, populate the DTO from the data model using a data
transformer. Implement DataTransformerInterface, declare which input it handles in supports(), and
convert the model fields to DTO properties in transform().
use Amarant\Framework\Contract\DataTransformer\DataTransformerInterface;
use Vendor\ModuleName\Data\Form\ProductInput;
use Vendor\ModuleName\DataModel\Product;
final class ProductToProductInputDataTransformer implements DataTransformerInterface
{
public function supports(mixed $subject, string $to, array $context = []): bool
{
return $subject instanceof Product && $to === ProductInput::class;
}
public function transform(mixed $subject, string $to, array $context = []): mixed
{
$dto = new ProductInput();
$dto->name = $subject->getName();
$dto->sku = $subject->getSku();
$dto->price = (string) $subject->getPrice();
return $dto;
}
}
Register the transformer tagged with DiEnum::DATA_TRANSFORMER:
$container->tag(
[ProductToProductInputDataTransformer::class],
[DiEnum::DATA_TRANSFORMER]
);
Defining a form
Implement FormInterface. The build() method receives the FormBuilder, the pre-populated DTO, and an
optional context array.
use Amarant\Framework\Contract\Form\FormInterface;
use Amarant\Framework\Form\Element\TextElement;
use Amarant\Framework\Form\Element\NumberElement;
use Amarant\Framework\Form\FormBuilder;
use Symfony\Component\Validator\Constraints as Assert;
class ProductForm implements FormInterface
{
public const string FORM_NAME = 'product_form';
public function build(FormBuilder $formBuilder, mixed $data, array $context = []): void
{
$formBuilder
->name(self::FORM_NAME)
->dtoClass(ProductInput::class)
->fieldset(id: 'default', order: 10);
$formBuilder
->add(
id: 'name',
elementClass: TextElement::class,
elementParams: [
'label' => 'Product name',
'attributes' => ['required' => true],
'constraints' => [
new Assert\NotBlank(),
new Assert\Length(max: 255),
],
'order' => 10,
]
)
->add(
id: 'sku',
elementClass: TextElement::class,
elementParams: [
'label' => 'SKU',
'constraints' => [new Assert\NotBlank()],
'order' => 20,
]
)
->add(
id: 'price',
elementClass: NumberElement::class,
elementParams: [
'label' => 'Price',
'constraints' => [
new Assert\NotBlank(),
new Assert\Type('numeric'),
],
'order' => 30,
]
);
}
}
FormBuilder API
| Method | Description |
|---|---|
name(string $name) |
Set the form name. Used as the HTML form name attribute and the top-level key when reading submitted data. |
dtoClass(string $class) |
Set the DTO class the form deserializes into. |
fieldset(id, order, label) |
Start a new fieldset. Subsequent add() calls go into this fieldset. |
add(id, elementClass, value, elementParams) |
Add an element to the current fieldset. elementParams are passed to the element's constructor. |
addCollection(id, FormCollectionElement, label, order) |
Add a collection of sub-forms. |
addAttributes(array $attributes) |
Add HTML attributes to the <form> element. |
addMeta(array $meta) |
Add metadata accessible to the template. |
factory() |
Create a fresh FormBuilder instance (used when building sub-forms inside collections). |
build() |
Produce the Form instance. |
Common elementParams keys
| Key | Type | Description |
|---|---|---|
label |
string |
Label text displayed next to the element. |
order |
int |
Sort order within the fieldset. |
attributes |
array |
HTML attributes merged onto the element (e.g. ['required' => true, 'placeholder' => '...']). Adding required automatically adds a NotBlank visual indicator. |
constraints |
Constraint[] |
Symfony constraints validated on submission. |
meta |
array |
Arbitrary metadata for template use. |
templatePath |
string |
Override the default template used to render the element. |
Available element types
| Class | Rendered as | Notes |
|---|---|---|
TextElement |
<input type="text"> |
General text input. |
EmailElement |
<input type="email"> |
Email input. |
PasswordElement |
<input type="password"> |
Password input. |
NumberElement |
<input type="number"> |
Numeric input. |
RangeElement |
<input type="range"> |
Range slider. |
DateElement |
<input type="date"> |
Date picker. |
DateTimeElement |
<input type="datetime-local"> |
Date and time picker. |
TextareaElement |
<textarea> |
Multi-line text. |
CheckboxElement |
<input type="checkbox"> |
Single checkbox. |
SelectElement |
<select> |
Dropdown. Pass options (array of SelectOptionElement) in elementParams. |
DataModelSelectElement |
<select> |
Dropdown populated from a data model repository. Pass dataModelClass, valueField, labelField, and optionally a filter closure. |
FileElement |
<input type="file"> |
File upload. |
ImageElement |
Image preview + upload | Used for image fields. |
HiddenElement |
<input type="hidden"> |
Hidden field. |
HiddenWithLabelValueElement |
Hidden field with a visible label showing the value | Display-only. |
LinkElement |
<a> |
Renders a link within the form. |
CodeEditorElement |
Code editor widget | Multi-line code/JSON editing. |
FormCollectionElement |
Repeatable sub-form group | See below. |
Collections
FormCollectionElement renders a repeatable group of sub-forms — for example a list of line items each
with their own fields.
use Amarant\Framework\Form\Element\FormCollectionElement;
$itemCollection = new FormCollectionElement(
parentName: $formName,
name: 'items',
displayAs: FormCollectionElement::DISPLAY_AS_TABLE, // (1)
allowAdd: true,
allowRemove: true,
headers: ['Source', 'Target', 'Rate'],
);
DISPLAY_AS_TABLErenders a tabular layout;DISPLAY_AS_TABLE_LISTandDISPLAY_AS_LISTare the alternatives.
Template matcher
Provide a closure that builds and returns the sub-form for a single item. The same closure is used both to generate the blank template (for adding new rows) and to populate existing rows.
$templateMatcher = function () use ($formBuilder, $formName): Form {
$itemFormBuilder = $formBuilder->factory();
$itemFormBuilder->dtoClass(ProductAttributeInput::class);
$itemFormBuilder->name('item');
$itemFormBuilder->add(
id: 'value',
elementClass: TextElement::class,
elementParams: ['constraints' => [new Assert\NotBlank()], 'order' => 10]
);
return $itemFormBuilder->build();
};
$itemCollection->setTemplateMatcher($templateMatcher);
$template = $templateMatcher();
$itemCollection->prepareTemplate($template);
$itemCollection->setMeta('template', $template);
$formBuilder->addCollection('items', $itemCollection, null, 110);
Processing a form (recommended)
Inject FormProcessorInterface and use its builder to handle the full request cycle — DTO population,
form building, data binding, CSRF validation, and Symfony validation — in one place.
use Amarant\Framework\Contract\Form\FormProcessorInterface;
use Amarant\Framework\Contract\Form\FormProcessorRequestBuilderInterface;
use Symfony\Component\HttpFoundation\Request;
public function editAction(RequestContextInterface $requestContext): Response|Result
{
$product = $this->productRepository->getById($id);
$dto = $this->dataTransformer->transform($product, ProductInput::class);
$builder = $this->formProcessor
->createBuilder()
->dto($dto)
->form(ProductForm::class)
->onFailure(fn (FormProcessorResult $r) => $this->logger->error($r->getException()?->getMessage()));
if ($requestContext->getRequest()->getMethod() === Request::METHOD_GET) {
return $this->pageRenderer->render([
'product_form' => $builder->noValidate(true)->build()->getForm(),
]);
}
$result = $this->formProcessor->process($builder->build(), $requestContext);
if ($errorResponse = $result->getErrorResponse()) {
return $errorResponse; // validation errors as JSON
}
/** @var ProductInput $dto */
$dto = $result->getDto();
// save $dto → data model
}
Builder API
| Method | Description |
|---|---|
dto(JsonSerializableInterface $dto) |
Provide a pre-populated DTO directly. |
transform(mixed $subject, string $to, array $context = []) |
Alternatively, let the builder run a data transformer to produce the DTO. |
form(string $class, array $context = []) |
The FormInterface class to use. |
noValidate(bool $noValidate) |
Skip validation when building for a GET render (data is pre-populated, not submitted). |
onSuccess(Closure $fn) |
Callback invoked when the form is valid. Receives FormProcessorResult. |
onFailure(Closure $fn) |
Callback invoked on CSRF failure, binding failure, or validation failure. Receives FormProcessorResult. |
beforeBuild(Closure $fn) |
Callback invoked with the FormBuilder just before build() is called — useful for dynamic element injection. |
bind(bool $bind) |
Whether to bind the DTO to the form after building (default true). |
FormProcessorResult
| Method | Description |
|---|---|
isValid(): bool |
Whether all constraints passed. |
isSuccessful(): bool |
Whether processing completed without exception. |
getDto(): ?JsonSerializableInterface |
The populated and validated DTO. |
getErrorResponse(): ?Response |
A pre-built JSON error response when validation or CSRF fails; return it directly. |
getViolations(): array |
Raw violation list. |
getException(): ?Throwable |
Any exception that aborted processing. |
Saving data with a data transformer
When the save logic is non-trivial (loading the existing entity, mapping fields, persisting), abstract it
into a dedicated data transformer rather than doing it inline in the controller. Pass an existing entity as
OUTPUT_INSTANCE in the context so the transformer can update it in place rather than creating a new one.
use Amarant\Framework\Contract\DataTransformer\DataTransformerInterface;
use Vendor\ModuleName\Data\Form\ProductInput;
use Vendor\ModuleName\DataModel\Product;
final class ProductInputToProductDataTransformer implements DataTransformerInterface
{
public function supports(mixed $subject, string $to, array $context = []): bool
{
return $subject instanceof ProductInput && $to === Product::class;
}
public function transform(mixed $subject, string $to, array $context = []): mixed
{
/** @var Product $product */
$product = $context[DataTransformerInterface::OUTPUT_INSTANCE] ?? new Product();
$product->setName($subject->name);
$product->setSku($subject->sku);
$product->setPrice((float) $subject->price);
return $product;
}
}
In the controller:
$product = $this->dataTransformer->transform(
$dto,
Product::class,
[DataTransformerInterface::OUTPUT_INSTANCE => $existingProduct]
);
$this->productRepository->save($product);
This keeps the controller thin and makes the mapping logic independently testable.
CSRF protection
CSRF tokens are generated and validated automatically by FormHandler. The token name is derived from the
form name. To disable CSRF for a specific form, add the meta flag when building:
CSRF is also skipped globally for the frontend area when SecurityConfiguration::isFrontendCsrfDisabled()
returns true (controlled via configuration).
Form events
All form events are dispatched twice: once as a generic event and once as a form-name-suffixed variant.
The suffixed name is produced by EventEnum::getSuffixedEventName(EventEnum::FORM_EVENT_*, $formName).
Subscribe to the generic event to react to every form, or to the suffixed event to target a specific form.
EventEnum case |
Fired when | Key event data |
|---|---|---|
FORM_EVENT_FORM_BUILDER_BUILD_BEFORE |
Just before FormBuilder::build() constructs the Form. |
form_builder, dto_class, fieldsets, attributes, meta — all mutable. |
FORM_EVENT_FORM_BUILDER_BUILD_AFTER |
After the Form is constructed. |
form — the built Form instance. |
FORM_EVENT_FORM_BIND_DATA_BEFORE |
Before request data is bound to the form. | form, data, no_validate. |
FORM_EVENT_FORM_BIND_DATA_AFTER |
After data is bound and the DTO is populated (before validation runs). | form, dto, bound_data, flat_data, flat_data_constraints — all mutable. |
FORM_EVENT_FORM_VALIDATION_FAILED_AFTER |
After validation fails, before the violation list is finalised. | All of the above plus validation_errors (mutable — violations can be added or removed). |
Modifying a form from a subscriber
The BUILD_BEFORE event is the right place to add or remove elements from any form — for example, to inject
a field contributed by another module without modifying the original form class.
use Amarant\Framework\Enum\EventEnum;
use Amarant\Framework\Form\Element\TextElement;
use Amarant\Framework\Form\FormBuilder;
// subscribe to the suffixed event to target only "product_form"
EventEnum::getSuffixedEventName(EventEnum::FORM_EVENT_FORM_BUILDER_BUILD_BEFORE, 'product_form')
public function handle(EventInterface $event): void
{
/** @var FormBuilder $formBuilder */
$formBuilder = $event->getData('form_builder');
$formBuilder->add(
id: 'custom_field',
elementClass: TextElement::class,
elementParams: ['label' => 'Custom', 'order' => 999]
);
}
Modifying the DTO after binding
Subscribe to FORM_EVENT_FORM_BIND_DATA_AFTER (suffixed if targeting one form) to adjust the populated
DTO before validation runs. Because the DTO is an object, mutating its properties is enough — you only need
to call setData('dto', ...) if you want to replace the DTO with a completely different object.