league/object-mapper
A lightweight object-to-object mapper for PHP that converts one class into another using reflection and PHP attributes. Define property mappings, handle nested objects, arrays, and collections, and reduce boilerplate when transforming DTOs, entities, and API payloads.
Install and orient yourself in 10 minutes.
composer require league/object-mapper
The package does exactly one thing: take a plain, already-parsed structure (array — from json_decode(..., true), a DB row, $request->validated(), a message payload) and hydrate it into a strict, typed object. It is not a serializer, not a validator, and not an ORM. Keep that mental model and the API makes sense immediately.
Where to look first, in order:
README.md — the first code block is the whole contract; copy the exact namespace/class name from it (this is a small package, the surface is deliberately tiny).src/ — the mapper entry point plus any Exception classes. You want to know the exception type thrown on a mapping failure, because you'll catch it at your input boundary.tests/ — the fixtures are the real documentation for nested objects, collections, and nullable handling.Define DTOs with promoted, typed constructors — that's the shape the mapper expects:
namespace App\Data;
final class Address
{
public function __construct(
public readonly string $street,
public readonly string $city,
public readonly string $postalCode,
) {}
}
final class User
{
public function __construct(
public readonly int $id,
public readonly string $name,
public readonly Address $address,
public readonly ?string $nickname = null,
) {}
}
First use case — kill the manual hydration boilerplate:
use League\ObjectMapper\ObjectMapper; // confirm namespace from README
$mapper = new ObjectMapper();
$user = $mapper->map(User::class, [
'id' => 7,
'name' => 'Ada',
'address' => [
'street' => '1 Analytical Way',
'city' => 'London',
'postalCode' => 'N1',
],
'nickname' => null,
]);
// $user is a User; $user->address is an Address, not an array.
That's the whole first win: no new User($data['id'], $data['name'], new Address(...)) hand-wiring, and no mixed leaking into your domain layer.
1. Map at the boundary, never deeper. Resolve the mapper once (container singleton / static factory) and call it only where untrusted structures enter: controllers, console commands, queue consumers, webhook handlers. Everything downstream takes typed objects.
public function store(StoreUserRequest $request): JsonResponse
{
$user = $this->mapper->map(CreateUser::class, $request->validated());
$this->commandBus->dispatch(new RegisterUser($user));
return response()->json(['id' => $user->id], 201);
}
2. Validation first, mapping second. This package enforces types, not business rules. Run Laravel's FormRequest (or Symfony's validator) first so you fail with 422s; let the mapper throw a 500 only on genuine programmer error.
3. Nested objects and collections. Nest a DTO-typed property and the mapper recurses. For lists, declare the element type and map a array<Item>-style property — expect the package to hydrate each element into the target class:
final class Order
{
/** @param list<LineItem> $items */
public function __construct(
public readonly string $reference,
public readonly Address $shipping,
public readonly array $items,
) {}
}
Verify in tests/ whether collections need an attribute/generic notation or whether the mapper infers from constructor docblocks — this is the one area worth confirming before you build on it.
4. Enums and scalars. PHP 8.1 backed enums hydrate cleanly from their backing value; use them instead of string constants so invalid values fail at the edge:
enum Status: string { case Draft = 'draft'; case Paid = 'paid'; }
final class Invoice
{
public function __construct(
public readonly Status $status,
public readonly int $amountCents,
) {}
}
5. Keep a toArray() on your DTOs. The package is one-directional. For responses, events, and logs you still need the reverse trip — implement a small explicit toArray() per DTO rather than reaching for a full serializer. Explicit is cheaper to reason about than a second library.
6. Wrap it behind your own interface. Given the maturity level, do not let League\ObjectMapper\* leak into 40 files:
interface Hydrator
{
/** @template T of object @param class-string<T> $class @return T */
public function hydrate(string $class, array $data): object;
}
Bind the League implementation in a service provider. Swapping to cuyz/valinor or Symfony Serializer later becomes a one-file change.
7. Queue/consumer pipeline. A single, consistent decode step dramatically simplifies jobs:
public function handle(): void
{
$event = $this->hydrator->hydrate(OrderPlaced::class, json_decode($this->payload, true));
// typed from here on; static analysis works
}
"123". If the mapper is strict, a string into an int parameter throws. Cast at the boundary ($request->validated() after Laravel numeric validation usually gives you the cast you expect) rather than loosening your DTO types.?string $nickname = null tolerates an absent key; a required string does not. Prefer explicit defaults for genuinely optional fields so partial payloads from third parties don't blow up.array property is not hydrated. Declaring public readonly array $items gives you the raw arrays — it does not guess LineItem. Element typing is your job. This is the #1 source of "why is my nested object still an array".#[...] configuration if the package exposes it.parent: ?Node) will blow the stack if the payload is cyclic. Guard depth yourself; there is no built-in cycle detector you should assume.src/Exception/ and catch that at your boundary, not \TypeError or \Throwable. Log the offending payload (redact secrets) — the exception message often won't carry the full path through nested objects, so isolate by mapping the sub-object directly when debugging.Address hydrates fine but User doesn't, the mismatch is at the parent level (key name or nullability), not in the child."league/object-mapper": "^x.y" with a committed composer.lock, keep the wrapper interface from Pattern 6, and don't extend it via monkey-patching. If the package lacks a feature you need (custom datetime casting, key renaming), implement it around the mapper — pre-normalize the array — rather than forking.src/: any Mapper interface you can decorate (to add logging/metrics), any attribute classes for key renaming or custom casting, and whether custom type handlers can be registered. If those hooks exist, use them; if not, don't invent them.readonly + ?T vs T|null should behave identically, but confirm in tests before relying on one spelling in 200 DTOs.How can I help you explore Laravel packages today?