dalpras / form-zero
Yet another kind of forms: without the need of a view, low dependencies, rendering by decorators
Requires
- php: >=8.3
- dalpras/smart-template: ^4.0
- symfony/http-foundation: ^7.3
- symfony/mime: ^7.4
- symfony/psr-http-message-bridge: ^7.4
- symfony/translation: ^7.4
- symfony/validator: ^7.4
Requires (Dev)
- phpunit/phpunit: ^12.3.6
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-15 14:25:08 UTC
README
FormZero is a PHP 8.3+ form library built around three ideas:
- Forms are PHP objects. Fields, validation, filtering, nested data, and rendering are configured in PHP.
- Rendering is modular. FormZero renders through decorators and Smart Template instead of coupling forms to Twig, Blade, or another full view framework.
- Application infrastructure is injected. The library receives a template engine, Symfony validator, optional translator, and an upload provider. It does not read the current HTTP request directly.
FormZero is used as a form library, not as an HTTP framework. Your controller/application is responsible for obtaining POST data, deciding when to validate, persisting values, and handling successful/failed requests.
Requirements
The package currently requires:
- PHP
>= 8.3 dalpras/smart-template ^5.0- Symfony Validator
^7.4 - Symfony Translation
^7.4 - Symfony HttpFoundation
^7.3 - Symfony Mime
^7.4 - Symfony PSR HTTP Message Bridge
^7.4
The package intentionally uses Symfony validation and UploadedFile objects, but FormZero itself does not depend on Symfony's Request object.
Installation
When the package is available through Composer:
composer require dalpras/form-zero
In this repository it is normally installed as a local/path package together with dalpras/smart-template.
Core concepts
A typical FormZero request uses these objects:
Application/controller
|
| POST data
v
ZeroForm
|
+-- Elements / nested ZeroForms
+-- FormDataMapper
+-- FormValidator
+-- Decorators
|
v
FormFactory
|
+-- TemplateEngine
+-- ValidatorInterface
+-- UploadedFileProviderInterface
`-- optional Translator
The public entry point is normally FormFactory.
1. Configure FormFactory
FormFactory requires three services and accepts an optional translator:
use DalPraS\FormZero\Factory\FormFactory; use DalPraS\FormZero\Upload\UploadedFileProviderInterface; use DalPraS\SmartTemplate\TemplateEngine; use Symfony\Component\Translation\Translator; use Symfony\Component\Validator\Validator\ValidatorInterface; $formFactory = new FormFactory( template: $template, uploadedFileProvider: $uploadedFileProvider, validator: $validator, translator: $translator, // optional; may be null );
The constructor does not create any of these services for you.
This is deliberate: validator configuration, translation resources, upload access, and template customization belong to the application.
1.1 Configure Smart Template
The simplest standalone setup is:
use DalPraS\FormZero\Preset\FormPreset; use DalPraS\SmartTemplate\TemplateEngine; $template = new TemplateEngine(); FormPreset::register($template); // Registers HtmlPreset and its |join modifier.
FormZero passes HTML attribute arrays as '{attributes}|join' so rendering
and escaping happen together in one pass. No custom {attributes} callback
or TemplateEngine::attributes() method is required.
If your application has a custom escaper or needs to configure the escaped attribute names, override the default after registering the preset:
use DalPraS\SmartTemplate\HtmlAttributes; $escaper = $helpers->escaper(); (new HtmlAttributes( escape: static fn(string $value): string => $escaper->escapeHtml($value), escapedNames: ['id', 'title', 'name', 'alt', 'aria-label'], omitEmptyNames: ['id', 'name'], ))->register($template);
{attributes} remains the token in HTML template strings; the |join
modifier belongs on the argument array key, not inside markup. For
example: ['{attributes}|join' => ['class' => 'form-control']].
FormPreset::register() registers the FormZero templates from:
resources/templates/form.php
under the form namespace.
If your application already has a shared UI preset/namespace, you may merge the FormZero templates into that namespace instead. The Vimar application does this through its TemplateFactory.
1.2 Configure Symfony Validator
FormZero expects an already-created ValidatorInterface.
Minimal example:
use Symfony\Component\Validator\Validation; $validator = Validation::createValidatorBuilder() ->getValidator();
With translations:
use Symfony\Component\Translation\Translator; use Symfony\Component\Validator\Validation; /** @var Translator $translator */ $validator = Validation::createValidatorBuilder() ->setTranslator($translator) ->setTranslationDomain('validators') ->getValidator();
Create the validator once in your DI container and reuse it. Do not create a new Symfony validator for every form or every field.
1.3 Configure uploaded files
FormZero does not read Request::$files directly. It asks an UploadedFileProviderInterface for the upload associated with a compiled FieldPath.
use DalPraS\FormZero\FieldPath; use DalPraS\FormZero\Upload\UploadedFileProviderInterface; use Symfony\Component\HttpFoundation\File\UploadedFile; interface UploadedFileProviderInterface { /** @return UploadedFile|UploadedFile[]|null */ public function get(FieldPath $fieldPath): array|UploadedFile|null; }
A Symfony HTTP application can use a small adapter:
use DalPraS\FormZero\FieldPath; use DalPraS\FormZero\Upload\UploadedFileProviderInterface; use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\Request; final readonly class SymfonyUploadedFileProvider implements UploadedFileProviderInterface { public function __construct( private Request $request, ) { } public function get(FieldPath $fieldPath): array|UploadedFile|null { $file = $fieldPath->find($this->request->files->all()); return $file instanceof UploadedFile || is_array($file) ? $file : null; } }
For an application that never handles uploads, provide an implementation that simply returns null:
final class NullUploadedFileProvider implements UploadedFileProviderInterface { public function get(FieldPath $fieldPath): array|UploadedFile|null { return null; } }
2. Create a form
Create a class extending ZeroForm and define its structure in init().
<?php declare(strict_types=1); namespace App\Form; use DalPraS\FormZero\Element\EmailElement; use DalPraS\FormZero\Element\SubmitElement; use DalPraS\FormZero\Element\TextElement; use DalPraS\FormZero\FormLayout; use DalPraS\FormZero\ZeroForm; use Symfony\Component\Validator\Constraints as Assert; final class ContactForm extends ZeroForm { public function init(): void { $this->setAttribs([ 'method' => 'post', 'action' => '', ]); $this->add(new TextElement(), 'name', [ 'label' => 'Name', 'required' => true, 'attribs' => [ 'autocomplete' => 'name', 'maxlength' => 100, ], 'filters' => [ \DalPraS\FormZero\Filter\StringTrim::class, \DalPraS\FormZero\Filter\StripTags::class, ], ]); $this->add(new EmailElement(), 'email', [ 'label' => 'Email', 'required' => true, 'constraints' => [ new Assert\Email(), ], ]); $this->add(new SubmitElement(), 'submit', [ 'text' => 'Send', 'ignore' => true, 'layout' => FormLayout::Submit, ]); } }
Create it through the factory:
$form = $formFactory->createForm(ContactForm::class);
createForm() performs these steps automatically:
- creates the form and injects the factory;
- calls
init(); - installs default form decorators if the form did not define its own decorators.
Important: element class names, not aliases
This is valid:
$form->add(new TextElement(), 'name');
This is also valid:
$form->add(TextElement::class, 'name');
This is not valid:
$form->add('text', 'name');
FormZero does not currently provide string aliases such as text, email, or select.
3. Process a form in a controller
A normal controller flow is:
$form = $formFactory->createForm(ContactForm::class); if ($request->isMethod('POST')) { $data = $request->request->all(); if ($form->isValid($data)) { $values = $form->getValues(); // Persist/use $values. } } $html = $form->render();
Important points:
- pass normal submitted fields to
isValid(); - uploaded files are obtained independently through the configured upload provider;
- call
getValues()after successful validation when you want normalized/filtered values; - render the same form instance to show validation feedback.
4. Adding elements
ZeroForm::add() creates an element through FormFactory, adds it to the form, and returns the same stored instance.
$element = $this->add(new TextElement(), 'title', [ 'label' => 'Title', 'required' => true, ]); $element->setDescription('Displayed below the field.');
Descriptions belong to individual elements, not to the form. Likewise, use the order argument of addElement() or addSubForm() to control the order of children; the form does not have its own setOrder() property.
The most commonly used option keys are:
| Option | Meaning |
|---|---|
label |
Element label |
description |
Help/description text |
required |
Marks the element required |
requiredMessage |
Custom required message |
allowEmpty |
Controls whether an optional empty value is accepted |
ignore |
Excludes the field from getValues() / getValidValues() |
isArray |
Treats the element value as an array |
filters |
Value filters applied before validation/value retrieval |
constraints |
Symfony Validator constraints |
attribs |
HTML attributes |
decorators |
Custom decorator stack |
layout |
A FormLayout preset |
disableTranslator |
Disables translation for that element |
Prefer explicit HTML attributes inside attribs:
$this->add(new TextElement(), 'name', [ 'label' => 'Name', 'attribs' => [ 'class' => 'form-control', 'autocomplete' => 'name', 'maxlength' => 100, ], ]);
initOptions() maps known option names to setters. Unknown keys fall back to HTML attributes, so using attribs explicitly makes configuration errors easier to see and review.
5. Available element types
The package currently provides:
TextElement
TextareaElement
EmailElement
PasswordElement
SearchElement
HiddenElement
SubmitElement
CheckboxElement
CheckboxMultiElement
RadioElement
RadioImageElement
RadioPopupElement
SelectElement
SelectMultiElement
DatePickerElement
HashElement
SymfileElement
SymfileMultiElement
Choose the concrete element type that matches the rendered control and validation semantics you need.
6. Choice elements
Choice elements such as SelectElement, RadioElement, and their multi-value variants accept multiChoices as a label => value array.
$this->add(new SelectElement(), 'country', [ 'label' => 'Country', 'multiChoices' => [ 'Italy' => 'IT', 'France' => 'FR', 'Germany' => 'DE', ], 'required' => true, ]);
The submitted value is validated against the configured choices.
RadioElement escapes choice labels as text by default. If the labels
contain trusted HTML created by your application (for example, icons), enable
htmlChoiceLabels on that specific radio group:
$this->add(new RadioElement(), 'icon', [ 'label' => 'Icon', 'htmlChoiceLabels' => true, 'multiChoices' => [ '<i class="fa-solid fa-book" aria-hidden="true"></i><span class="visually-hidden">Book</span>' => 'book', '<i class="fa-solid fa-globe" aria-hidden="true"></i><span class="visually-hidden">Globe</span>' => 'globe', ], ]);
Security: this option disables HTML escaping for all choice labels in
that radio group. Use it only for developer-controlled HTML; never include
unsanitized user or database text. Values, input attributes and normal radio
labels continue to be escaped. Do not rely on detecting < in a label to
decide whether it is HTML. HTML choice labels bypass automatic translation, while the surrounding field
label can still be translated. Translate/escape any dynamic text parts before
composing the markup.
A visible or screen-reader-only text name is needed when a choice consists of
an icon alone. The radio choice <label> is linked to its unique input ID,
so clicking the icon selects the option.
Multi-value elements use array semantics and validate each submitted value.
7. Filters and normalized values
Filters normalize values before validation and before values are returned from the form.
Example:
use DalPraS\FormZero\Filter\StringTrim; use DalPraS\FormZero\Filter\StripTags; $this->add(new TextElement(), 'title', [ 'filters' => [ StripTags::class, StringTrim::class, ], ]);
Input:
" <b>Hello</b> "
Normalized value:
"Hello"
FormZero caches the normalized value. Repeated calls to getValue() do not rerun an unchanged filter chain.
The cache is invalidated when:
- the raw value changes;
- array mode changes;
- the filter chain changes.
Available built-in filters include:
StringTrim
StripTags
StripNewlines
StringToLower
StringToUpper
ToBoolean
ToFloat
ToInt
ToNull
ToString
ParseEmailFilter
PhoneNumberFilter
PublicKeyClean
CamelCaseToDash
CamelCaseToSeparator
DashToUnderscore
SeparatorToCamelCase
SeparatorToSeparator
Filters may also be passed as configured arrays:
'filters' => [ [ToNull::class, ['type' => ToNull::TYPE_STRING]], ],
8. Validation
FormZero delegates actual constraint validation to Symfony Validator.
use Symfony\Component\Validator\Constraints as Assert; $this->add(new TextElement(), 'code', [ 'required' => true, 'constraints' => [ new Assert\Length(max: 20), new Assert\Regex('/^[A-Z0-9]+$/'), ], ]);
Required fields
When required => true, FormZero automatically prepends a Symfony NotBlank constraint unless you already supplied one.
You can customize the implicit required message:
$this->add(new TextElement(), 'name', [ 'required' => true, 'requiredMessage' => 'Name is required.', ]);
Required-field marker and legend
Required elements with a non-empty label receive the required CSS class on their rendered label. The application theme can use that class to display an asterisk, for example:
label.required::before { content: '* '; }
FormDecorator also renders the translated mandatory-fields legend automatically when the form, or one of its nested subforms, contains at least one required labeled element.
You can override that automatic behavior per form:
// Always hide the mandatory-fields legend. [FormDecorator::class, ['mandatory' => false]] // Always show the mandatory-fields legend. [FormDecorator::class, ['mandatory' => true]]
When the mandatory option is omitted, automatic detection is used.
Full validation
if ($form->isValid($data)) { // Every field, including absent required fields, has been checked. }
Partial validation
Use isValidPartial() when you intentionally want to validate only fields present in the input:
if ($form->isValidPartial($data)) { // Missing required fields are not checked. }
This is useful for PATCH-like workflows, builders, wizards, or draft persistence.
Validation messages
$messages = $form->getMessages();
For one element:
$messages = $form->getMessagesForElement('email');
You may also add form-level errors manually:
$form->addError('The operation could not be completed.');
9. Values and defaults
Defaults
$form->setDefaults([ 'name' => 'Mario Rossi', 'email' => 'mario@example.com', ]);
Or one value:
$form->setDefault('name', 'Mario Rossi');
Read one value
$name = $form->getValue('name');
Read all values
$values = $form->getValues();
getValues() returns normalized/filtered values and excludes elements marked with:
'ignore' => true
disabled and ignore are not equivalent. A disabled element is still part of the FormZero value model unless it is also explicitly ignored.
Read only valid values
$validValues = $form->getValidValues($data);
getValidValues() validates submitted fields and returns only values that pass their element validation. This is useful for partially valid/draft workflows.
10. Nested forms and array notation
A regular ZeroForm is reusable: it operates as a standalone HTML form and
becomes a nested group only while attached to a parent through addSubForm().
The same field definitions, filters and validation rules are used in both modes.
For example, define the reusable form once:
use DalPraS\FormZero\ZeroForm; use DalPraS\FormZero\Element\TextElement; final class MetadataForm extends ZeroForm { public function init(): void { $this->setName('MetadataForm'); $this->add(new TextElement(), 'metaTitle', ['label' => 'Meta title']); } }
Use it independently to edit metadata:
$metadata = $formFactory->createForm(MetadataForm::class); // $metadata->render() produces its own <form> element. // $metadata->isValid($post) and $metadata->getValues() use flat keys.
Or create a new instance of exactly the same class for a larger form, as in
Article's basedata -> metadata tree:
$article = $formFactory->createForm(ZeroForm::class); $basedata = $formFactory->createForm(ZeroForm::class); $metadata = $formFactory->createForm(MetadataForm::class); $basedata->addSubForm($metadata, 'metadata'); $article->addSubForm($basedata, 'basedata'); // The metadata field is now basedata[metadata][metaTitle]. // The nested metadata form renders as <fieldset>, NOT another <form>. // The parent validates and collects the complete data tree.
$metadata->getValues(true) returns its local values in nested mode;
$article->getValues() returns ['basedata' => ['metadata' => [...]]].
Calling removeSubForm('metadata') restores the child's original name,
standalone mapping and form rendering without modifying its decorators.
An attached instance cannot simultaneously belong to a second parent; use a
fresh factory-created instance wherever the same form appears twice.
All forms attached through addSubForm() are automatically namespaced under
that child key: no ZeroForm::setIsArray() is necessary or available. The
isArray() getter remains for internal namespace handling and reflects the
current nesting context. For a standalone form that intentionally uses a
namespaced payload, call setElementsBelongTo('address') explicitly. This does
not affect Element::setIsArray(), which is still required for individual
multiple-value fields. Custom decorators, such as Article's accordion/card
renderers, are left untouched. A normal FormDecorator is contextually
rendered as a fieldset when nested.
All nested groups are ordinary ZeroForm instances constructed through the
form factory; no separate nested-form class or creator method is needed. The
Article accordion/card decorators retain their existing data-row-for IDs
using explicit id attributes on the few groups that need stable UI selectors.
The same approach applies to Contact accordion groups.
PHP-style array keys containing hyphens, such as the Article image locale
localized[x-default][title], are preserved in names, mapping, validation
and HTML field paths. A nested form's key must still be a single field-name
segment (bracket characters are not allowed in an addSubForm() key).
Hydrator::hydrateForm($metadata, $callback) continues to accept local
metadata fields when called directly on a child already nested under
basedata[metadata], as in ArticleForm::hydrateDefaults(). For ordinary
setDefaults()/isValid() calls, use the child's named input branch when
it is nested, or call these methods on the parent with the full data tree.
Building nested groups with ZeroForm
Create a normal form through the factory and attach it under the desired key:
$metadata = $this->factory->createForm(ZeroForm::class); $metadata->add(new TextElement(), 'metaTitle', [ 'label' => 'Meta title', ]); $this->addSubForm($metadata, 'metadata');
For deeper structures, nest additional ordinary forms:
$baseData = $this->factory->createForm(ZeroForm::class); $metadata = $this->factory->createForm(ZeroForm::class); $metadata->add(new TextElement(), 'metaTitle'); $baseData->addSubForm($metadata, 'metadata'); $this->addSubForm($baseData, 'basedata');
The nested field is named basedata[metadata][metaTitle] with HTML ID
basedata-metadata-metaTitle. Data extraction, validation, defaults and uploads
use the same hierarchical path. The standalone form keeps its normal field names
and HTML <form> rendering until attached.
Set an explicit id on a nested form when an accordion or external JavaScript
selector must stay fixed while field names use the hierarchical structure:
$metadata->setAttrib('id', 'metadata');
Subform legends
A subform has one legend rule and one renderer for it. The legend value belongs to the form:
$metadata = $this->factory->createForm(ZeroForm::class); $metadata->setLegend('Metadata'); $this->addSubForm($metadata, 'metadata');
A standalone ZeroForm defaults to ElementsDecorator -> FormDecorator.
When attached to a parent, FormDecorator renders a FieldsetDecorator
instead of creating a nested HTML <form> tag. Its original decorators remain
unchanged so the form can be detached and reused standalone.
FieldsetDecorator renders both the semantic <fieldset> wrapper and, when getLegend() is not empty, its <legend>. There is no separate legend decorator and no independent legend value on the fieldset decorator: setLegend() on the form/subform is the single source of truth.
For the uncommon case where a fieldset is required but its legend must not be rendered, suppress only the rendering:
use DalPraS\FormZero\Decorator\ElementsDecorator; use DalPraS\FormZero\Decorator\FieldsetDecorator; $metadata->setDecorators([ ElementsDecorator::class, [FieldsetDecorator::class, ['renderLegend' => false]], ]);
Do not pass a second legend option to FieldsetDecorator; change the legend through $metadata->setLegend(...).
11. File uploads
Use SymfileElement for one file and SymfileMultiElement for multiple files.
use DalPraS\FormZero\Element\SymfileElement; use Symfony\Component\Validator\Constraints as Assert; $this->add(new SymfileElement(), 'attachment', [ 'label' => 'CSV file', 'required' => true, 'constraints' => [ new Assert\File([ 'maxSize' => '10M', 'extensions' => ['csv'], ]), ], ]);
FormZero automatically detects file elements when rendering the form and adds multipart encoding to the rendered <form> element. You do not need rendering code to mutate the form attributes manually.
Correct upload processing order
Treat form->isValid() as the authoritative validation step.
use DalPraS\FormZero\Element\SymfileElement; use Symfony\Component\HttpFoundation\File\UploadedFile; $data = $request->request->all(); if ($form->isValid($data)) { /** @var SymfileElement $attachment */ $attachment = $form->getElement('attachment'); $uploadedFile = $attachment->getUploadedFiles(); if (!$uploadedFile instanceof UploadedFile) { throw new \LogicException( 'Expected a valid upload after successful form validation.' ); } // Store/process $uploadedFile. }
Do not repeat application-level upload validation after the form has succeeded:
// Avoid this in controller/business code after successful form validation. $uploadedFile->isValid();
A storage service may still perform defensive transport checks before physically moving the file.
Multiple uploads
use DalPraS\FormZero\Element\SymfileMultiElement; $this->add(new SymfileMultiElement(), 'attachments', [ 'constraints' => [ new Assert\File(maxSize: '10M'), ], ]);
After successful validation:
/** @var SymfileMultiElement $attachments */ $attachments = $form->getElement('attachments'); $uploadedFiles = $attachments->getUploadedFiles(); if (!is_array($uploadedFiles)) { throw new \LogicException('Expected multiple uploaded files.'); }
Nested upload fields
The provider receives a compiled FieldPath, so nested names work correctly:
profile[documents][attachment]
The Symfony adapter traverses the nested FileBag structure instead of trying to pass bracket notation directly to FileBag::get().
Do not read raw request files in business code
Prefer:
/** @var SymfileElement $attachment */ $attachment = $form->getElement('attachment'); $file = $attachment->getUploadedFiles();
instead of:
$file = $request->files->get('attachment');
This keeps nested paths, provider behavior, and form validation consistent.
12. Layouts
FormLayout selects one of the built-in decorator stacks:
use DalPraS\FormZero\FormLayout; $this->add(new TextElement(), 'title', [ 'layout' => FormLayout::HorizontalWide, ]);
Available layouts:
| Layout | Intended use |
|---|---|
FormLayout::Horizontal |
Standard label/content horizontal field |
FormLayout::HorizontalWide |
Wider content column |
FormLayout::HorizontalNarrow |
Narrower content column |
FormLayout::FullWidth |
Full-width content |
FormLayout::Submit |
Submit/button row |
FormLayout::Raw |
Element content only |
You can also change layout after creation:
$element->layout(FormLayout::Raw);
13. Decorators
Decorators define how an element or form is rendered.
A normal form receives these decorators by default:
[
[ElementsDecorator::class],
[FormDecorator::class],
]
An element receives the decorator stack associated with its FormLayout unless you provide decorators explicitly.
Custom example:
use DalPraS\FormZero\Decorator\ElementContentDecorator; use DalPraS\FormZero\Decorator\ElementLabelDecorator; use DalPraS\FormZero\Decorator\ElementWrapperDecorator; $this->add(new TextElement(), 'title', [ 'decorators' => [ [ElementContentDecorator::class], [ElementLabelDecorator::class, ['class' => 'col-form-label']], [ElementWrapperDecorator::class, ['class' => 'row mb-3']], ], ]);
A decorator entry may be:
- a decorator instance;
- a decorator class name;
[DecoratorClass::class, $options];null(removed by the factory).
CallbackDecorator
CallbackDecorator is useful when a field needs application-specific rendering around otherwise standard element output.
use DalPraS\FormZero\Decorator\CallbackDecorator; use DalPraS\FormZero\ElementInterface; use DalPraS\FormZero\ZeroForm; use DalPraS\SmartTemplate\Collection\RenderCollection; [ CallbackDecorator::class, [ 'callback' => static function ( string $content, RenderCollection $render, ElementInterface|ZeroForm $element, string $namespace, ): string { return '<div class="custom-wrapper">' . $content . '</div>'; }, ], ]
Rendering exceptions are not converted to HTML. If a callback/template throws, the exception propagates to the application error handler.
14. Rendering behavior
Render with:
echo $form->render();
or:
echo (string) $form;
Rendering is designed to be observational:
$before = $form->getValues(); $html = $form->render(); $after = $form->getValues(); // $before and $after represent the same form data.
Rendering does not:
- rewrite nested
belongsTovalues; - change normal element data;
- write
enctypeback into the form object merely to render multipart HTML; - swallow rendering exceptions.
HashElement is a special case: its render value is the current CSRF token, while its stored form/data value remains independent.
15. Template overrides
You can override FormZero templates when registering FormPreset:
FormPreset::register($template, overrides: [ 'input' => '<input class="my-input" {attributes}>', ]);
Or register overrides later in the same namespace:
$template->register(FormPreset::NAMESPACE, [ 'input' => '<input class="my-input" {attributes}>', ]);
Later registration replaces/extends keys in that namespace according to Smart Template behavior.
The default FormZero template file is:
resources/templates/form.php
16. CSRF with HashElement
FormZero provides HashElement for session-backed CSRF protection.
Example with the Symfony session adapter:
use DalPraS\FormZero\Element\HashElement; use DalPraS\FormZero\FormLayout; use DalPraS\FormZero\Session\SymfonySessionAdapter; $hash = new HashElement( new SymfonySessionAdapter($session), ); $this->add($hash, 'hash')->layout(FormLayout::Raw);
Other provided session adapters are:
ArraySessionAdapter
NativeSessionAdapter
SymfonySessionAdapter
HashElement:
- generates a cryptographically random token;
- stores token and expiry in the session;
- checks the submitted token with
hash_equals(); - rotates the token after validation according to its configured strategy;
- renders the current token without mutating the stored data value.
Ignoring CSRF
The factory exposes:
$formFactory->setIgnoreCsrfToken();
This is an application policy switch. Use it only when another trusted mechanism protects the request, for example an authenticated API flow that deliberately does not use browser-session CSRF protection.
17. Translation
FormFactory accepts an optional Symfony Translator:
$formFactory = new FormFactory( template: $template, uploadedFileProvider: $uploads, validator: $validator, translator: $translator, );
Element translation can be disabled with:
'disableTranslator' => true
or:
$element->setDisableTranslator(true);
The validator's translator is configured separately when creating the Symfony validator.
18. Ordering elements and subforms
You may provide an explicit order when adding an element:
$this->add(new TextElement(), 'second', [], order: 20); $this->add(new TextElement(), 'first', [], order: 10);
The same ordering model is used for subforms.
FormZero uses IteratorAggregate, so independent/nested iteration is safe:
foreach ($form as $outer) { foreach ($form as $inner) { // The inner iteration does not corrupt the outer iterator state. } }
Duplicate explicit order positions are rejected.
19. Custom filters
A custom filter implements FilterInterface.
Use it exactly like a built-in filter:
$this->add(new TextElement(), 'value', [ 'filters' => [ MyFilter::class, ], ]);
You can also mutate the filter chain directly:
$element->getFilterChain()->attach($filter);
FormZero tracks filter-chain revisions so the normalized-value cache is invalidated when filters change.
20. Custom elements
Custom elements are an advanced extension point.
The default ElementBaseDecorator deliberately dispatches only the built-in element classes. Therefore, creating a new subclass of Element is not enough by itself: you must also provide rendering support for that class, either by extending the FormZero renderer/template mapping or by supplying a custom decorator stack that renders the element without ElementBaseDecorator.
For small visual variations, prefer one of these approaches before creating a new element type:
- configure
attribs; - choose a different
FormLayout; - provide custom decorators;
- use
CallbackDecoratoraround a supported element.
Create a new element class when it also represents genuinely different form semantics or validation behavior, and add matching rendering/tests at the same time.
21. Common mistakes
Do not use element aliases
Wrong:
$form->add('text', 'name');
Correct:
$form->add(new TextElement(), 'name');
or:
$form->add(TextElement::class, 'name');
Do not construct the validator repeatedly
Create one shared ValidatorInterface and inject it into the factory.
Do not read request files in form business logic
Wrong:
$file = $request->files->get('attachment');
Preferred:
/** @var SymfileElement $attachment */ $attachment = $form->getElement('attachment'); $file = $attachment->getUploadedFiles();
Do not validate an uploaded file twice in controllers
After successful form validation, retrieve and use the upload. Leave defensive file-transport checks to the storage boundary.
Do not confuse disabled and ignore
ignore controls whether FormZero returns the value. A disabled HTML control is a rendering/browser concern.
Do not rely on rendering to initialize data
Set defaults/values explicitly. Rendering is pure with respect to form data.
Do not swallow decorator/template exceptions
FormZero intentionally propagates rendering errors. Let the application error handler log/display them according to environment.
22. Recommended controller pattern
A complete non-upload example:
$form = $formFactory->createForm(ProfileForm::class); $form->setDefaults($existingValues); if ($request->isMethod('POST')) { $data = $request->request->all(); if ($form->isValid($data)) { $values = $form->getValues(); $service->save($values); // Redirect or render a success response. } } echo $form->render();
A complete upload example:
$form = $formFactory->createForm(DocumentUploadForm::class); if ($request->isMethod('POST')) { $data = $request->request->all(); if ($form->isValid($data)) { /** @var SymfileElement $attachment */ $attachment = $form->getElement('attachment'); $file = $attachment->getUploadedFiles(); if (!$file instanceof UploadedFile) { throw new \LogicException( 'Expected a valid uploaded file after successful validation.' ); } $documentService->store($file); } } echo $form->render();
23. Internal architecture
The current internal responsibilities are intentionally separated:
ZeroForm
|
+-- FormDataMapper
| setDefaults()
| getValues()
| getValidValues()
|
+-- FormValidator
| isValid()
| isValidPartial()
| message aggregation
|
+-- FieldPath
| compiled nested names
| nested read/write/remove/find operations
|
+-- ElementsOrdered
| stable element/subform ordering
|
`-- Decorators
rendering only
ZeroForm remains the public facade; the helper classes above are implementation details unless explicitly documented otherwise.
FieldPath is also used by the upload-provider contract because the provider needs the exact compiled nested file path.
24. Testing
The package uses PHPUnit in development:
composer install ./vendor/bin/phpunit
Useful focused suites include the tests for:
FieldPathand compiled field names;- data mapping;
- validation/message traversal;
- render purity;
- render exception propagation;
- ordering /
IteratorAggregate; - upload provider behavior.
When changing FormZero internals, preserve these contracts:
- rendering must not change form data;
- repeated value reads must remain stable;
- nested names/IDs must remain stable;
- upload lookup must follow compiled nested paths;
isValid()remains the authoritative form-validation step;- public
ZeroFormAPIs should remain compatible unless a breaking change is intentional and documented.
25. Migration notes from older FormZero versions
If you are updating older integration code, the most important changes are:
Factory no longer receives Request
Old pattern:
new FormFactory( template: $template, request: $request, );
Current pattern:
new FormFactory( template: $template, uploadedFileProvider: $uploadedFileProvider, validator: $validator, translator: $translator, );
FormFactory no longer creates Validator
Inject a shared ValidatorInterface.
FormFactory does not create TemplateEngine
Configure TemplateEngine and register FormZero templates before constructing the factory.
Upload access moved behind UploadedFileProviderInterface
Business/controller code should retrieve uploaded files from the corresponding form element rather than directly from Request::$files.
Rendering is pure
Do not depend on render() to mutate belongsTo, values, CSRF data state, or form attributes.
Render exceptions propagate
Do not expect rendering errors to be returned as HTML strings. They now reach the normal application error handler.
Element aliases are not supported
Use concrete element instances or element class names.
26. Quick reference
Create a form:
$form = $formFactory->createForm(MyForm::class);
Validate:
$valid = $form->isValid($data);
Validate only submitted fields:
$valid = $form->isValidPartial($data);
Get normalized values:
$values = $form->getValues();
Get only valid submitted values:
$values = $form->getValidValues($data);
Set defaults:
$form->setDefaults($defaults);
Get messages:
$messages = $form->getMessages();
Render:
$html = $form->render();
Get an element:
$element = $form->getElement('name');
Get an upload after successful validation:
/** @var SymfileElement $attachment */ $attachment = $form->getElement('attachment'); $file = $attachment->getUploadedFiles();
Check whether multipart rendering is required:
$multipart = $form->requiresMultipartEncoding();
27. Validation and nested-form hardening
- CSRF identity follows the rendered field path. A
HashElementcreated in a standalone form automatically switches to the nested session scope when its form is attached. Always obtain the token to submit from the final rendered form, rather than cachinggetHash()before nesting. Invalid, missing, or expired tokens returnfalseand appear ingetMessages(); they do not rotate the valid token. Successful validation rotates it as configured. - Submitted field paths must match their declared namespace. For instance,
profile[account][preferences][nickname]cannot be supplied asprofile[nickname]. Standalone forms can still usesetElementsBelongTo()to namespace the entire form, and their established unwrapped-default behavior remains supported. - A required multi-value field rejects
[]. Optional multi-value elements still accept an empty selection whenallowEmptyis enabled. - Generated validation constraints are temporary. Requiredness and choice
constraints are assembled per validation, so repeated validation or changes
to
setMultiChoices()do not leave stale constraints ingetConstraints(). - Element insertion is atomic for order collisions. If the requested order
is already taken,
addElement()raisesLogicExceptionwithout inserting the new element.replaceElement()creates the replacement before deleting the old one, retaining the old element if construction fails. getMultiElement()returnsnullwhen the name is missing or belongs to an element that does not implementMultiChoicesInterface.- Checkbox hidden fallback inputs have no ID; only the visible control owns the corresponding ID. Radio choice labels are HTML-escaped by default; use a dedicated trusted-content renderer instead of embedding markup into labels.
License
See composer.json for the package license and metadata.
Optional AJAX fragments (PHP payloads + native ES module)
FormZero's core has no HTTP or JavaScript dependency. For independently saved nested forms, the package provides two optional, framework-neutral pieces:
DalPraS\FormZero\Ajax\FragmentResponsebuilds response arrays; your controller chooses HTTP status, authorization, data persistence andJsonResponse(or equivalent).resources/js/FormZeroAjax.mjsprogressively enhances explicitly marked forms and buttons. It usesfetch,FormData,CustomEventand one delegated listener per enhanced form; it requires no npm package or frontend framework.
The script is a source module, not an automatically included browser asset. Import it in your application's JS bundle or serve it as an ES module:
import FormZeroAjax from '<path-to-form-zero>/resources/js/FormZeroAjax.mjs'; new FormZeroAjax().init(document.querySelector('#my-editor'));
1. Declarative create and save
A parent HTML <form> carries a dedicated fragment CSRF token:
<form id="my-editor" data-fz-token="SERVER_GENERATED_FRAGMENT_TOKEN"> <!-- Child forms from FormZero, normal whole-page submit still works --> <a href="/fallback/add" data-fz-action="create" data-fz-url="/api/items/create" data-fz-target="#items" data-fz-fragment-prefix="item-" data-fz-success="Created">Add item</a> <div id="items"></div> </form>
The server creates an item and returns a JSON object such as
{ "success":true, "id":17, "html":"<section id=\"item-17\">...</section>", "csrfToken":"NEXT_TOKEN" }. The module checks that the expected
item-17 exists before appending. It never reloads the page or replaces
existing unsaved inputs.
To save a subform, render an action button inside its own row:
<section id="item-17"> <input name="items[17][title]" value="Example"> <input name="items[17][children][6][title]" value="Unsaved child"> <button type="button" data-fz-action="save" data-fz-url="/api/items/save?id=17" data-fz-row="#item-17" data-fz-scope="items[17]" data-fz-fields="title,enabled,options" data-fz-success="Saved">Save item</button> </section>
Only direct items[17][title], items[17][enabled] and
items[17][options][] controls are serialized. Nested children are never
included even when the child fields have the same final name. Disabled and
unchecked controls are omitted; multiselects contribute one value per
selected option. Normalize omitted checkbox/multiselect fields on the PHP
side (e.g. $submitted['options'] ??= []). Submitted file fields are
included when present and selected. The standalone subform should be
validated with isValid(), not with a permissive isValidPartial().
Supported trigger attributes:
| Attribute | Purpose |
|---|---|
data-fz-action |
create, save, delete, or mutate |
data-fz-url |
POST URL, HTML-escaped once in its attribute rendering context |
data-fz-target |
Create: selector of the existing append container |
data-fz-fragment-prefix |
Expected row ID prefix; result id must be a positive integer |
data-fz-fragment-match |
Optional selector that the returned row must match (e.g. [data-dragger=item]) |
data-fz-context-key / data-fz-context-value |
Optional create guard: response property (sectionId etc.) must match expected parent ID |
data-fz-row |
Save/delete/mutate: selector of the existing subform row enclosing the button |
data-fz-confirm |
Delete/mutate: optional browser confirmation message |
data-fz-scope |
Save: bracket notation field name prefix, e.g. sections[7] |
data-fz-fields |
Save: comma-separated explicit field whitelist |
data-fz-label-target |
Save: descendant selector whose textContent receives returned label (never HTML) |
data-fz-replace="if-html" |
Save/mutate: replace that row only when server sends html (for mutate, dirty rows are rejected before sending) |
data-fz-success |
Optional text or application translation key for the status message; save actions fall back to configurable savedMessage (Saved by default) |
When generating data-fz-url from an application URL helper, pass the raw URL
to the attribute serializer. For example, if url() defaults to an HTML-encoded
query separator (&), request & explicitly and let
{attributes}|join escape data-fz-url once. Pre-escaped query strings
will be double-encoded and multi-parameter URLs will fail.
On successful saves, FormZeroAjax replaces stale unsaved or error status with
a success notice. To localize the fallback, configure savedMessage in
new FormZeroAjax({savedMessage: 'translation.key', translate}). If the user
changes a field while a save is in flight, the unsaved warning takes
precedence; those newer edits still need saving.
The create link's href is a non-JavaScript fallback. Save buttons use
type="button" and do not trigger the global form submit. The data-fz-*
selectors are resolved from the parent form, not by assuming the Add
button is inside its target accordion. This also handles decorators that
prepend buttons as siblings of the accordion.
2. PHP controller response contract
use DalPraS\FormZero\Ajax\FragmentResponse; // Validate authorization and the separate fragment CSRF token FIRST. // Persist only the intended entity, then render only its child form. return new JsonResponse( FragmentResponse::created($newId, $childForm->render(), $nextToken, [ 'sectionId' => $sectionId, ]), 200, ); // Successful scoped update. Preserve the current browser subform by default. return new JsonResponse(FragmentResponse::saved($nextToken, [ 'id' => $id, 'label' => $newCaption, ])); // Validation errors use HTTP 422 and carry a replacement token, if rotated. return new JsonResponse( FragmentResponse::invalid($form->getMessages(true), $nextToken), 422, );
Responses must be JSON (application/json) with success: true|false.
csrfToken is adopted on both successful and HTTP 422 responses. A 403 with
an expired/invalid token should prompt a reload or fresh token flow; do not
make CSRF optional. Never interpret the requested entity ID or field
whitelist as authorization: verify entity ownership, ACL and allowed fields
on the server, then validate a standalone form and persist only those fields.
The helper returns plain arrays, keeping FormZero independent of
Laminas/Symfony controllers. The application decides its status codes. It
does not change ZeroForm, add a JS dependency to Composer or register AJAX
behavior during ordinary form rendering.
3. Events and editor integrations
The library dispatches document-level CustomEvents with a detail payload:
formzero:before-submit:{form, trigger, root}; synchronize rich editors beforeFormDataserialization.formzero:fragment-added:{form, trigger, root, result}; initialize newly inserted widgets and optionally focus the first field.formzero:fragment-replaced: same payload when an explicit row replacement is authorized.formzero:committed:{form, trigger, result, action}immediately upon a successful server response, before DOM rendering. Use this to mark a whole-page snapshot stale even if returned HTML cannot be inserted.formzero:fragment-saved:{form, trigger, root, result, action}after success. UI status is reported throughrole="status".formzero:fragment-removed:{form, trigger, root, result}after a successful delete and removal of that row.formzero:reordered:{form, type, ownerId, ids, result}after a successful scoped reorder.formzero:dirty:{row, trigger}when an independent subform changes.formzero:conflict:{form, trigger, error}when a response is HTTP 409.formzero:fragment-warning:{form, trigger, root, result}if persistence succeeded but the server reports a nonfatal follow-up warning (e.g. file cleanup).formzero:uploaded:{form, trigger, completed}after a file-selection batch.formzero:fragment-error:{form, trigger, error}; validation and network errors are displayed viarole="alert"andaria-live.
The status CSS classes are configurable with statusClasses; by default the
library uses fz-status, fz-status--success, fz-status--warning
and fz-status--error without requiring Bootstrap. A Bootstrap app may pass
{statusClasses:{base:'ms-2 small', success:'text-success', error:'text-danger'}}.
For example:
document.addEventListener('formzero:before-submit', ({detail}) => { window.MyEditor?.sync(detail.root); }); document.addEventListener('formzero:fragment-added', ({detail}) => { initializeEditorWidgets(detail.root); });
All requests sharing one HTML form are queued, so concurrent clicks don't submit the same rotating CSRF token. The same action button cannot be sent twice while pending. Requests in different browser tabs may still race; for non-idempotent creates, applications can add idempotency keys if needed.
Returned HTML is treated as trusted server-rendered markup. The controller must correctly escape user-provided values and must not return user-controlled raw scripts/HTML. The library intentionally does not sanitize application markup or contain any database rules.
This optional module is tested from the application's Node test runner; the
PHP FragmentResponseTest runs in the package's PHPUnit suite.
4. Optional multipart file uploads
A SymfileElement or SymfileMultiElement can opt in to AJAX uploading
without changing FormZero's core. Add the attributes below to the
rendered <input type="file">. On selection, FormZeroAjax sends one file
per POST, using FormData field file, then appends the validated server
fragment into data-fz-target. Without JavaScript the same input remains a
normal multipart field for a full-page POST.
<form data-fz-token="..." data-fz-revision="12" enctype="multipart/form-data"> <input type="file" multiple name="image[]" data-fz-action="upload" data-fz-url="/api/media/upload?type=image" data-fz-target="#images" data-fz-fragment-prefix="image-" data-fz-fragment-match="[data-dragger=item]" data-fz-context-key="type" data-fz-context-value="image" data-fz-success="Uploaded"> <div id="images"></div> </form>
Each successful JSON response must contain success, a positive id, the
matching type, server-rendered html, csrfToken, and (for revisioned
editors) editorRevision. Selected batches are capped at 20 files, queued
in order, and serialized with all other fragment actions on the same form.
A failed later upload does not undo earlier successful uploads: the status
shows how many succeeded and keeps their inserted rows. File inputs clear
after the attempt, allowing an identical filename to be selected again.
During the batch, navigation triggers the standard unsaved-changes warning.
File type/MIME, size, ownership, CSRF, per-entity revision and storage rules
MUST be checked by the application endpoint. The HTML data-fz-* attributes
are configuration, not authorization. In particular, never put arbitrary
filesystem paths or an HTML fragment supplied by the browser into the
server's response. Normal server-side validation is always required.
formzero:committed and formzero:fragment-added fire separately for every
successfully uploaded file; formzero:uploaded fires after the full batch
with {form, trigger, completed}. Physical file deletion is deliberately an
application-side operation, not a FormZero feature.
5. Revision-aware saves, deletion, reordering and unsaved changes
Optionally set data-fz-revision="1" on the parent form. The module then
includes editor_revision on each fragment POST and updates that attribute
only when a successful JSON response provides a numeric editorRevision.
HTTP 409 emits formzero:conflict; the token returned in csrfToken is
still adopted but the revision is never updated on a failed request.
Delete an existing row only after the server has committed the deletion:
<button type="button" data-fz-action="delete" data-fz-url="/api/items/delete?id=17" data-fz-row="#item-17" data-fz-confirm="Delete this item?">Delete</button>
For an action that updates a record but must not submit fields or remove
its row, use data-fz-action="mutate". The server may return a refreshed
html fragment; the row is replaced only when data-fz-replace="if-html"
is present. A mutate request is rejected before transmission if the target
row contains unsaved fields, so an updated fragment cannot silently discard
those edits. Use a real POST route with CSRF and revision checks; this is
useful for unlinking a physical file while preserving its CMS record:
<button type="button" data-fz-action="mutate" data-fz-row="#video-12" data-fz-url="/api/media/unlink-file?id=12" data-fz-confirm="Delete the uploaded file?" data-fz-replace="if-html" data-fz-fragment-prefix="video-">Remove file</button>
For reordering, call the JavaScript API from your drag handler:
await ajax.reorder(form, '/api/items/reorder', 'item', 7, [21, 19, 23]);
The application must authenticate and validate ownership of every ID, validate the complete list of siblings on the server, and use a transaction and revision check. On failure, the drag handler should restore the previous DOM order. FormZero deliberately knows nothing about rank, parent entities, or persistence.
init() tracks an independent fingerprint of each row with a matching
data-fz-action="save" button. Only fields declared in data-fz-fields
participate. It signals unsaved changes with data-fz-dirty, an accessible
message alongside the save button, formzero:dirty, and a browser
beforeunload warning. After a successful save, changes typed during the
request remain dirty; a failed save never discards user input.
FormZeroAjax options dirtyMessage and statusClasses let applications
translate/customize messages without coupling the package to Bootstrap.
This is deliberately an explicit-save API: no invisible autosave or
unconditional background traffic is enabled by the library.
Security rules still apply: the server must not trust a client-provided
editor_revision, target selector, field whitelist, form scope, or row ID to
prove access. Check authentication, CSRF, ownership, form validation and
revision server-side. For double-escaped URL avoidance, use the raw URL as
the value of data-fz-url (and any other HTML-escaped data-fz-* URL).
Notification and layout-stable dirty indicator
FormZeroAjax can optionally delegate operation-level notifications to the host
application instead of inserting text after a Save button. Its core stays
independent of any toaster or CSS framework:
const ajax = new FormZeroAjax({ translate: key => translator.trans(key), notify: ({type, message}) => toastNotifier.show({type, message}), inlineDirty: false, });
- The default behavior remains unchanged: inline status text and inline dirty
warnings, without requiring
ToastNotifier. - In this opt-in mode, successful saves, creations, deletes and completed upload batches produce one notification; errors are reported through the same callback. Intermediate multi-file progress counters are suppressed in toast mode rather than generating a flood of notifications.
- Editing a field never creates a toast.
data-fz-dirty="true"is placed on the row and its Save button, and the button's accessible label gains the translateddirtyMessage. Both are restored on successful save or reverting the edit. Applications can style the state with a small CSS pseudo-element without changing the toolbar layout. Thebeforeunloadunsaved-change protection still applies. - Saves with additional changes made while the request was pending retain the dirty marker and do not incorrectly announce a fully saved form.
- Save errors continue to be delivered as
formzero:fragment-errorevents and valid responses still rotate CSRF/revision tokens. The notification callback must display text, not treat server-provided messages as trusted HTML.
Optional per-component status UI
Applications may keep operation-level toasts while rendering independent saves
locally, without modifying ZeroForm or depending on any CSS framework:
const ajax = new FormZeroAjax({ notify: ({type, message}) => toaster.show({type, message}), inlineDirty: false, onRowStatus: ({row, trigger, state, message}) => { // Render inside the application's own component, NOT as a sibling of Save. // state: idle | dirty | saving | saved | error componentFeedback.update(row, trigger, state, message); }, });
onRowStatus is optional. The callback is invoked when the initial row is
snapshotted, when its permitted fields become dirty or clean, before a scoped
save request, and after a successful or failed save. A successful component
save no longer calls notify if this callback exists; failures reach the
component and (when configured) the general notification callback. Create,
delete, upload and other operation messages still use notify. For consumers
without onRowStatus, the original inline/toast behavior is unchanged.
Optional confirmation callback and localized server errors
For data-fz-action="delete" or "mutate" with data-fz-confirm, the
application can supply an asynchronous confirmation function:
const ajax = new FormZeroAjax({ translate: key => translator.trans(key), confirm: (message, trigger) => confirmationDialog.ask(message, trigger), });
The promise must resolve to true to perform the request; cancellation
never sends a POST. While confirmation is pending, repeated clicks on the
same trigger are ignored. Without an application callback, native browser
confirm() remains the default. The library never assumes Bootstrap or a
particular modal service. The server must still validate CSRF, ownership,
revision, and deletion constraints after the user confirms.
When a JSON failure includes a single error string, FormZero passes it
through the configured translator before displaying it. Applications can
therefore return stable message keys, including for domain-specific 422
failures, without embedding HTML or duplicating translations in controllers.
Structured field-validation errors remain untouched because they may
already contain translated per-field messages.
The callback must not throw, mutate form fields, or cause persistence calls. FormZero catches presentation errors so a saved record cannot be reported as an unsuccessful HTTP operation solely because a notification failed. Keep local feedback text-only and reserve space in the component so visual changes do not shift the Save toolbar.