Keeping Laminas Controllers Thin: Pairing laminas-form with laminas-hydrator
A practical pattern for Laminas MVC: use laminas-form InputFilters as the single source of validation truth and laminas-hydrator to map filtered data into DTOs, keeping controllers focused on orchestration.
08 Nov 2025, 21:14 UTC

The junk-drawer controller
When a Laminas MVC action starts checking array keys by hand, casting strings to integers inline, and assigning properties one by one, the boundary between HTTP and your domain logic disappears. The useful takeaway is that you do not need a new framework to fix this. Treat the InputFilter from laminas-form as the single place where request data is validated and normalized, and treat laminas-hydrator as the single place where filtered data becomes an object. The controller then only orchestrates: bind, validate, hydrate, delegate.
What the InputFilter owns
An InputFilter is a collection of named inputs, each with optional filters and validators. Filters normalize data—trimming whitespace, casting to integers—while validators enforce rules. Because the InputFilter is independent of HTML rendering, the same object can back a server-rendered form and a JSON API endpoint. That makes it the single source of truth: if a rule changes, you change it once.
In a typical Laminas project you may define a Form element collection for browser clients and extract or reuse its InputFilter for API clients. The controller never re-implements validation; it asks the filter whether the data is valid and, if not, returns the messages.
Hydration without persistence leaks
laminas-hydrator maps an associative array onto an object. Instead of writing $dto->setEmail($data['email']) repeatedly, you configure a hydrator once and reuse it. Hydrator strategies let you declare transformations—such as converting a string to a DateTime or mapping a nested array into a collection—rather than scattering that logic across controllers.
A common mistake is hydrating directly into active-record style entities that also know how to save themselves. That couples your HTTP layer to persistence. The safer pattern is to hydrate into a plain data-transfer object (DTO), a simple object that carries data without behavior, and pass that DTO to a service layer. The service layer owns the domain rules and the persistence calls.
A worked example
Assume a Laminas MVC application using laminas-hydrator 4.x. In the project root, install the components:
composer require laminas/laminas-form laminas/laminas-hydrator
Run this where your composer.json lives. It requires write access to composer.json and the vendor/ directory. Expected check: composer show laminas/laminas-hydrator lists the installed version. Risk: Composer may adjust other laminas packages if version constraints allow, so review the upgrade plan before running it in production.
Define a DTO and an InputFilter:
final class CreateOrderRequest
{
private string $customerEmail;
private int $quantity;
public function getCustomerEmail(): string
{
return $this->customerEmail;
}
public function setCustomerEmail(string $email): void
{
$this->customerEmail = $email;
}
public function getQuantity(): int
{
return $this->quantity;
}
public function setQuantity(int $qty): void
{
$this->quantity = $qty;
}
}
class CreateOrderInputFilter extends InputFilter
{
public function init()
{
$this->add([
'name' => 'customerEmail',
'required' => true,
'filters' => [
['name' => Laminas\Filter\StringTrim::class],
],
'validators' => [
['name' => Laminas\Validator\EmailAddress::class],
],
]);
$this->add([
'name' => 'quantity',
'required' => true,
'filters' => [
['name' => Laminas\Filter\ToInt::class],
],
'validators' => [
[
'name' => Laminas\Validator\Between::class,
'options' => ['min' => 1, 'max' => 100],
],
],
]);
}
}
Configure a hydrator in your service manager or module config:
// module/Application/config/module.config.php or similar
'service_manager' => [
'factories' => [
Laminas\Hydrator\ClassMethodsHydrator::class => Laminas\ServiceManager\Factory\InvokableFactory::class,
],
],
Inject both into the controller. The action becomes orchestration only:
public function createAction()
{
$request = $this->getRequest();
$payload = json_decode($request->getContent(), true) ?: [];
$this->inputFilter->setData($payload);
if (! $this->inputFilter->isValid()) {
return $this->createErrorResponse($this->inputFilter->getMessages());
}
$dto = $this->hydrator->hydrate($this->inputFilter->getValues(), new CreateOrderRequest());
$this->orderService->createOrder($dto);
return $this->createSuccessResponse();
}
Note the explicit JSON decode. If you instead call $request->getPost()->toArray(), you are assuming form-encoded data; for JSON APIs that assumption silently drops the body.
Checking the result
A unit test can verify the boundary without a web server. The expected checks are that the filter rejects bad input, normalizes good input, and that the hydrator produces a DTO with the expected state:
public function testFilterAndHydrator()
{
$filter = new CreateOrderInputFilter();
$filter->setData(['customerEmail' => ' [contact removed] ', 'quantity' => '5']);
$this->assertTrue($filter->isValid());
$values = $filter->getValues();
$this->assertSame('[contact removed]', $values['customerEmail']);
$this->assertSame(5, $values['quantity']);
$hydrator = new Laminas\Hydrator\ClassMethodsHydrator();
$dto = $hydrator->hydrate($values, new CreateOrderRequest());
$this->assertSame('[contact removed]', $dto->getCustomerEmail());
$this->assertSame(5, $dto->getQuantity());
}
Trade-offs and limitations
This pattern is not free. Complex nested forms increase InputFilter configuration overhead, and deeply nested arrays may require custom validators or hydrator strategies to map correctly. Version compatibility also matters: laminas-hydrator 4.x targets PHP 7.4+ and changed some strategy interfaces compared with earlier majors. Verify your installed version before relying on a specific strategy API.
Finally, if your DTOs are fully immutable, a hydrator that calls setters will not fit. You may need a custom hydrator or a dedicated strategy that constructs the object through a named constructor. That is a deliberate trade-off: you gain safety at the cost of extra mapping code.
Where to start
Pick one endpoint that currently mixes validation and object construction. Extract its rules into an InputFilter, inject a hydrator, and hydrate into a DTO before calling the service layer. Write the unit test above against that endpoint. If the test passes and the controller action reads as four lines of orchestration, the boundary is working.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.