laminas/laminas-form
Laminas Form provides a robust object-oriented API for building, validating, filtering, and rendering PHP forms. It includes elements, collections, annotations, input filters, and view helpers for creating secure, reusable forms in Laminas MVC applications.
composer require laminas/laminas-form laminas/laminas-inputfilter laminas/laminas-hydrator
laminas-form is a standalone, framework-agnostic form object library. It gives you three things Laravel does not ship as one unit: a Form/Fieldset object graph, an InputFilter (filter + validator chain) attached to that graph, and a Hydrator that casts the validated array into a business object (and back). There is no service provider, no config file, and no Blade integration — you wire it yourself.
Look at these four classes first, in this order:
Laminas\Form\Form / Laminas\Form\Fieldset — the element container.Laminas\InputFilter\InputFilterProviderInterface — where validation lives.Laminas\Form\Element\* — element types and their attributes.Laminas\Hydrator\ObjectPropertyHydrator / ReflectionHydrator — the object casting.First use case — a controller-validated registration form bound to an entity:
use Laminas\Form\Form;
use Laminas\Form\Element;
use Laminas\InputFilter\InputFilterProviderInterface;
use Laminas\Validator;
class RegisterForm extends Form implements InputFilterProviderInterface
{
public function __construct()
{
parent::__construct('register');
$this->add(['name' => 'email', 'type' => Element\Email::class]);
$this->add(['name' => 'password', 'type' => Element\Password::class]);
$this->add(['name' => 'submit', 'type' => Element\Submit::class,
'attributes' => ['value' => 'Register']]);
}
public function getInputFilterSpecification(): array
{
return [
'email' => [
'required' => true,
'filters' => [['name' => \Laminas\Filter\StringTrim::class]],
'validators' => [
['name' => Validator\EmailAddress::class],
['name' => Validator\StringLength::class, 'options' => ['max' => 254]],
],
],
'password' => [
'required' => true,
'validators' => [
['name' => Validator\StringLength::class, 'options' => ['min' => 8]],
],
],
];
}
}
// Controller — order matters: bind, then setData, then isValid.
$form = new RegisterForm();
$form->bind(new User()); // attach the target object
$form->setData($request->all()); // raw input
if ($form->isValid()) {
/** @var User $user */
$user = $form->getData(); // hydrated object, not an array
$user->save();
} else {
return back()->withErrors($form->getMessages())->withInput();
}
That is the whole loop. Everything else is composition.
Form classes, not inline forms. Always subclass Form or Fieldset and implement InputFilterProviderInterface. Keeping the filter spec next to the elements is the main reason to use this package over Validator::make() — the validation rules travel with the shape.
Fieldsets for reuse. Addresses, money blocks, and line items belong in their own Fieldset with their own getInputFilterSpecification(). Nesting produces dotted/bracketed input names (contact[email]), and the parent form automatically aggregates child validation. This is where the package earns its place in a Laravel app: deeply nested, repeated structures are painful with FormRequest.
Collections for dynamic rows. Use Laminas\Form\Element\Collection with target_element and count (or allow_add) for repeatable rows. Supply target_element as a class name or a factory closure so each row gets a fresh element instance.
Hydrators do the DTO casting. The default is ObjectPropertyHydrator (public props + setters). For readonly/value objects use ReflectionHydrator, or write a custom hydrator implementing Laminas\Hydrator\HydratorInterface when you need constructor injection:
$form->setHydrator(new \Laminas\Hydrator\ReflectionHydrator());
$form->setObject(new MoneyBox($currency));
Validation groups for PATCH. InputFilter::setValidationGroup(['email']) (or a fieldset-scoped array) lets you validate only the submitted subset. Combine with 'allow_empty' => true and 'required' => false on inputs to support partial updates without duplicating form classes.
Two-pass rendering. For repopulation, run a display pass (setData(old())) after a failed validation pass. Do not rely on setData inside isValid() to preserve old input — isValid() mutates internal state.
Blade rendering is DIY. There are no Blade helpers. Two viable options:
$form->getElements() and emit <input>/error markup; read $element->getValue(), $element->getMessages(), $element->getAttributes().<x-lf-input :element="$form->get('email')" /> component centralises attribute rendering, old(), and the error bag. This is the pattern I'd recommend; it keeps views framework-native.Bridge errors to Laravel's bag. $form->getMessages() returns ['email' => ['msg1', 'msg2'], 'contact' => ['email' => [...]]]. Flatten nested keys with dotted notation (Arr::dot) into MessageBag so @error('contact.email') works.
Custom validators. Wrap Laravel rules by implementing Laminas\Validator\ValidatorInterface and delegating to Validator::make(['value' => $value], ['value' => $rule]). This lets you keep Laravel-specific rules (e.g. unique:users) in the InputFilter spec.
Container wiring. Register form classes as singletons or bind them in a FormServiceProvider and inject via the Laravel container. Do not pull in laminas-servicemanager just for this — the Laravel container is sufficient and avoids a second DI layer.
InputFilter as single source of truth. For APIs, skip the HTML form entirely: instantiate the form, setData($request->json()->all()), isValid(), getData() → object → JsonResource. Same validation rules serve web and API. That is the strongest argument for this package in a Laravel codebase.
View helpers are not usable. formRow(), formElement(), formElementErrors() are Laminas-View helpers that require laminas-view and an MVC context. Do not install laminas-view to get them. Write Blade components instead — it's less code than the glue you'd otherwise write.
CSRF element conflicts with Laravel. Laminas\Form\Element\Csrf validates against laminas-session, which you will not have configured. Disable it ($form->remove('csrf')) or never add it, and rely on Laravel's @csrf middleware. Otherwise every submission fails with a session-token error that is easy to misread as a form bug.
Order of operations. bind() before setData() before isValid(). Calling setData() after isValid() overwrites the hydrated object with the raw array. Calling bind() after setData() silently discards the object target.
isValid() returns an object, not an array, when bound. After bind() + successful validation, getData() is your DTO. Use getRawValues() if you need the filtered array for logging or events.
Filters run before validators, always. StringTrim will strip whitespace before EmailAddress sees it — usually desired, but it means required checks the post-filter value. A field of only spaces passes through as empty and fails required.
required => false does not mean "skip validation". If the input is present and non-empty, validators still run. Use allow_empty => true when an empty string should bypass the chain entirely.
Nested names must match the structure exactly. contact[email] requires an input named email inside a fieldset named contact. Mismatches produce "input not found" behaviour where the child is treated as valid and unvalidated — a real security foot-gun. Verify with $form->getInputFilter()->getInvalidInput() while debugging.
Hydrator silently skips unmapped fields. ObjectPropertyHydrator only writes to public properties or matching setters. If a property stays null after successful validation, check for a missing setter or a name mismatch — there is no exception.
Collections and file uploads. Laminas\Form\Element\File expects the $_FILES-shaped array, while Laravel gives you UploadedFile. Preprocess with $request->file() → a mapped array, or validate files with Laravel and only use the form for scalar data.
Messages are not translated by default. Validation strings are English via laminas-i18n. Either inject a TranslatorInterface adapter backed by Laravel's translator, or override the message templates per-input in getInputFilterSpecification().
Debugging checklist: $form->getMessages() (tree), $inputFilter->getMessages(), $inputFilter->getInvalidInput() (which inputs actually failed), $form->getRawValues(). Nine out of ten "it always fails" reports are a naming mismatch or the CSRF element, not a validator.
Ecosystem pinning. Keep laminas-form, laminas-inputfilter, laminas-filter, laminas-validator, and laminas-hydrator on the same major (3.x). Mixing with abandoned zendframework/* packages causes Composer conflicts and duplicate classes.
Extension points worth using: Form::prepare() for elements that depend on runtime data; FormInterface decorators for logging; a custom HydratorInterface for anything constructor-heavy; a FormElementManager-free factory closure in Collection for per-row element config.
Maintenance reality. The package is mature and recently released, licensed BSD-3-Clause (safe for closed-source), but the community is the Laminas ecosystem, not Laravel. With ~82 stars, expect to own every line of integration glue. Budget for it, weigh it against FormRequest + a hand-rolled fieldset helper, and pick laminas-form only when the nesting/hydration model genuinely wins.
How can I help you explore Laravel packages today?