Skip to content

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.

Vendor/ModuleName/Data/Form/ProductInput.php
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().

Vendor/ModuleName/DataTransformer/Form/ProductToProductInputDataTransformer.php
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:

Vendor/ModuleName/Configuration/Di.php
$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.

Vendor/ModuleName/Form/ProductForm.php
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'],
);
  1. DISPLAY_AS_TABLE renders a tabular layout; DISPLAY_AS_TABLE_LIST and DISPLAY_AS_LIST are 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);

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.

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

Vendor/ModuleName/DataTransformer/Form/ProductInputToProductDataTransformer.php
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:

$formBuilder->addMeta([Form::META_DISABLE_CSRF => true]);

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.

public function handle(EventInterface $event): void
{
    $dto = $event->getData('dto');
    if ($dto instanceof ProductInput) {
        $dto->sku = strtoupper($dto->sku);
    }
}