laminas/laminas-hydrator
Laminas Hydrator converts arrays into objects and extracts objects back into arrays. It supports naming strategies, filters, hydration/extraction strategies, nested objects, and reusable data mapping for PHP applications.
Check you can take v5 first. It is a major release with real BC breaks (typed signatures, enum filter conditions, static→instance properties). Also check whether something else is pinning you back:
composer why laminas/laminas-hydrator
composer show laminas/laminas-hydrator
Note the PHP constraint in the v5 composer.json — v5 aligns with Laminas' current support policy, so it will not run on older PHP.
Installation
composer require laminas/laminas-hydrator:^5.0
There is no Laravel service provider in the package — you wire it up yourself (see Implementation Patterns).
Basic hydration (array → object)
use Laminas\Hydrator\ClassMethodsHydrator;
$hydrator = new ClassMethodsHydrator(); // underscoreSeparatedKeys = true by default
$user = new User(); // must have setName()/setAge()
$hydrator->hydrate(['name' => 'John', 'age' => 30], $user);
Basic extraction (object → array)
$hydrator->extract($user); // ['name' => 'John', 'age' => 30]
src/Filter/ — the enum that replaces the old integer filter-condition constants lives here. Confirm the exact case names in your installed version before copying examples from anywhere.src/Strategy/ and src/NamingStrategy/ — the extension points you will actually use day to day.composer.json of the package — the authorative source for the new PHP constraint and the bumped dependency floors (laminas-serializer ^4, laminas-servicemanager ^4, psr/container ^2).This is where a hydrator earns its place in a Laravel app: mapping validated snake_case request data onto a typed DTO or value object with explicit setters, and back out again for the JSON response.
public function store(StoreUserRequest $request)
{
$hydrator = app(ClassMethodsHydrator::class);
$dto = new CreateUserData(); // setName(), setEmail(), setBirthdate()...
$hydrator->hydrate($request->validated(), $dto);
$user = User::create($dto->toArray());
return response()->json($hydrator->extract($user));
}
Reality check for Laravel teams: Eloquent's fill()/create() already covers flat model attributes, and packages like spatie/laravel-data cover typed DTOs with validation attached. Reach for laminas-hydrator when you want a framework-agnostic mapper you can reuse outside Laravel (queues, CLI importers, a shared package), or when you need fine-grained control over naming, filters, and per-property strategies.
Hydration with ClassMethodsHydrator
setX() methods; keys are matched to methods, not properties.underscoreSeparatedKeys is on by default, so first_name finds setFirstName() — handy when your payload is snake_case and your DTO is camelCase.$hydrator = new ClassMethodsHydrator(underscoreSeparatedKeys: false);
Hydration with ReflectionHydrator
$hydrator = new ReflectionHydrator();
$hydrator->hydrate(['email' => '[email protected]'], $dataBag);
Extraction and naming strategy
ClassMethodsHydrator converts getFirstName() → first_name when underscore separation is enabled.use Laminas\Hydrator\NamingStrategy\UnderscoreNamingStrategy;
$hydrator->setNamingStrategy(new UnderscoreNamingStrategy());
MapNamingStrategy is often a better fit than guessing conventions — explicit maps for wire format ↔ PHP property names, and they are trivially unit-testable.Per-property strategies (dates, enums, money)
use Laminas\Hydrator\Strategy\StrategyInterface;
final class DateStrategy implements StrategyInterface
{
public function hydrate(mixed $value, ?array $data = null): mixed
{
return $value === null ? null : Carbon::parse($value);
}
public function extract(mixed $value, ?object $object = null): mixed
{
return $value?->format('Y-m-d');
}
}
$hydrator->addStrategy('birthdate', new DateStrategy());
The mixed parameter/return types above are mandatory in v5 (see Gotchas).
Filters (selective mapping / mass-assignment defence)
use Laminas\Hydrator\Filter\MethodMatchFilter;
$hydrator->addFilter('sensitive', new MethodMatchFilter('isSensitive'));
Use filters to keep fields like is_admin, password, or computed accessors out of hydration/extraction. This is a cheaper, more explicit alternative to guarding with $fillable.
Bind hydrators in the container — keeps configuration in one place and makes the hydrator injectable in jobs, commands and controllers:
// AppServiceProvider::register()
$this->app->singleton(ClassMethodsHydrator::class, function () {
$hydrator = new ClassMethodsHydrator(underscoreSeparatedKeys: true);
$hydrator->addStrategy('birthdate', new DateStrategy());
$hydrator->addFilter('sensitive', new MethodMatchFilter('isSensitive'));
return $hydrator;
});
Prefer bind() over singleton() if you ever mutate strategies/filters at runtime — a shared instance is a hidden global.
Form requests as the validation boundary, hydrator as the mapping boundary
public function update(UpdateUserRequest $request, User $user)
{
$dto = app(ClassMethodsHydrator::class)->hydrate($request->validated(), new UpdateUserData());
$user->update($dto->toArray());
return new UserResource($user);
}
Never let the hydrator be your validator — it does no validation and will happily accept junk that passes its filter.
Collections — HydratingIterator exists in Laminas\Hydrator\Iterator, but for API responses Laravel's own ResourceCollection is usually a better fit. If you do use the iterator, wrap it in iterator_to_array() and paginate separately:
$rows = DB::table('imports')->cursor();
$iterator = new \Laminas\Hydrator\Iterator\HydratingIterator($rows, $hydrator);
Nested structures — AggregateHydrator chains hydrators with priorities. Keep aggregates to a single level deep, and prefer mapping nested payloads to nested DTOs in your own application code rather than relying on the aggregate to resolve relationships. Aggregate ordering is priority-based, so a hydrator that matches loosely can steal keys from a more specific one.
StrategyInterface, FilterInterface, HydratorInterface or extending AbstractHydrator with untyped signatures will fatal with an incompatible-declaration error. Add mixed/bool/string types to hydrate(), extract(), filter() and friends.FilterComposite::CONDITION_AND / CONDITION_OR with an enum. Update any FilterComposite construction:
use Laminas\Hydrator\Filter\FilterComposite;
// v5: enum case instead of an int constant — verify the case name in src/Filter/
$filter = new FilterComposite([
new MethodMatchFilter('isPublic'),
new NumberOfParameterFilter(0),
], Condition::And);
Grep your codebase for CONDITION_ — it is a cheap, complete migration check.ClassMethodsHydrator) must now be passed per instance, via the constructor or setNamingStrategy(). This is a silent behaviour change if you were setting statics in a service provider.HydratorProviderInterface were removed (PR #137). Any config-driven getHydratorConfig() wiring is dead — register hydrators in AppServiceProvider instead. This also means there is no config file to publish; everything is explicit.psr/container ^2, laminas/laminas-servicemanager ^4, laminas/laminas-serializer ^4. In a Laravel app this is mostly noise, but if another package requires the older Laminas service manager, you will get a solver conflict rather than a runtime error.ClassMethods, Reflection, Aggregate) have been off the documentation path for two majors. Standardise on the *Hydrator names and treat the aliases as unsupported.ClassMethodsHydrator looks for setName(); Eloquent has setAttribute() and magic __set, so hydration silently no-ops and you get an unsaved-changes bug that looks like a database problem. Hydrate DTOs, then map to models with fill()/create().ReflectionHydrator on an Eloquent model bypasses mutators, casts and $fillable, and will create dynamic properties on PHP 8.2+ (deprecation notices). Avoid entirely for models.FilterComposite with an explicit condition and test the edge case.UnderscoreNamingStrategy and ClassMethodsHydrator is already doing underscore separation, names get mangled. Pick one layer: either the constructor flag or a naming strategy, never both.AggregateHydrator has no cycle detection — hydrating User → Post → User will recurse forever. Break the cycle in your own code (hydrate IDs only, hydrate the child side lazily) rather than trying to teach the hydrator graph semantics.$hydrator = new ClassMethodsHydrator(methodExistsCheck: true);
Turn this on in development at minimum; it converts a whole class of "why is my DTO empty" bugs into an immediate exception. Remember to pass it per instance in v5.Log inside a decorator, not inside the hydrator. Subclassing or monkey-patching extract() is fragile across majors. Wrap instead:
final class LoggingHydrator implements \Laminas\Hydrator\HydratorInterface
{
public function __construct(private \Laminas\Hydrator\HydratorInterface $inner) {}
public function hydrate(array $data, object $object): object
{
logger()->debug('hydrate', ['keys' => array_keys($data), 'target' => $object::class]);
return $this->inner->hydrate($data, $object);
}
public function extract(object $object): array
{
$out = $this->inner->extract($object);
logger()->debug('extract', ['keys' => array_keys($out)]);
return $out;
}
}
It also makes the mapper swappable in tests.
Diff keys, don't eyeball values. When a field "doesn't map", log array_diff(array_keys($input), array_keys($extracted)) — naming-strategy bugs show up as key mismatches, not value mismatches.
Reject bad values in a strategy. Because strategies run per property during hydration, a throwing strategy is the cheapest place to enforce a domain invariant before it reaches your model:
public function hydrate(mixed $value, ?array $data = null): mixed
{
if (! filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException('Invalid email');
}
return $value;
}
Still validate at the request boundary — the hydrator is a mapper, not a gatekeeper.
AbstractHydrator and keep the v5 typed signatures:
final class ApiHydrator extends \Laminas\Hydrator\AbstractHydrator
{
public function hydrate(array $data, object $object): object
{
foreach ($data as $key => $value) {
$method = 'set' . str_replace(' ', '', ucwords(str_replace('_', ' ', $key)));
if (method_exists($object, $method)) {
$object->{$method}($this->hydrateValue($key, $value, $data));
}
}
return $object;
}
public function extract(object $object): array
{
$data = [];
foreach (get_class_methods($object) as $method) {
if (str_starts_with($method, 'get') && $this->getFilter($method)->filter($method)) {
$key = $this->extractName($method);
$data[$key] = $this->extractValue($key, $object->{$method}(), $object);
}
}
return $data;
}
}
"laminas/laminas-hydrator": "^5.0" and let Dependabot open the v6 PR instead of discovering the break in production.How can I help you explore Laravel packages today?