Map arrays, objects, and JSON to typed PHP objects with zero configuration.
Modern PHP applications constantly deal with data transformation — API payloads to DTOs, ORM entities to response objects, JSON to typed value objects. Most existing solutions are either too simple (no type safety) or too heavy (full serializer frameworks).
Mappers hits the sweet spot:
- Zero configuration — works out of the box with
Mappers::create() - PHP 8.2+ native — readonly classes, enums, constructor promotion, attributes
- Enterprise features — groups, polymorphism, plugins, circular reference protection
- High performance — two-tier metadata cache (memory + PSR-16)
- Best-in-class DX — rich attributes, fluent API, informative exceptions
composer require hypothesisphp/mappersRequirements: PHP 8.2+, ext-json, ext-mbstring
use Mappers\Mappers;
$mappers = Mappers::create();
// Array → Object
$user = $mappers->map($apiPayload, UserDto::class);
// Object → Array
$array = $mappers->toArray($user);
// Object → JSON
$json = $mappers->toJson($user);
// JSON → Object
$user = $mappers->fromJson($json, UserDto::class);
// Collection
$users = $mappers->mapCollection($payloads, UserDto::class);- Installation
- Quick Start
- Core Concepts
- Attribute Reference
- Mapping Types
- Type Casting
- Transformers
- Naming Strategy
- Groups
- Readonly & Immutable Objects
- Nested Objects
- Collections
- Enum Mapping
- DateTime Mapping
- Polymorphic Mapping
- Lifecycle Hooks
- Circular Reference Protection
- Context
- Plugin System
- Custom Caster
- Custom Transformer
- MappingPipeline
- Performance
- Architecture
- FAQ
Transforms a source (array, object, JSON string) into a target object. Supports Array→Object, Object→Object, JSON→Object.
Fills an existing object instance with data from an array. Also extracts object data back into an array.
Each class is analyzed once via reflection, producing immutable ClassMetadata and PropertyMetadata value objects. Results are stored in a memory cache (and optionally a PSR-16 cache) — reflection runs only once per class per process.
Central container for casters, transformers, naming strategies, and plugins.
#[MapFrom('user_name')]
public string $name;
// Multiple aliases (first match wins)
#[MapFrom(['user_name', 'username', 'login'])]
public string $name;#[MapTo('email_address')]
public string $email;
// toArray() → ['email_address' => '...']#[Ignore] // both directions
#[Ignore(Direction::SOURCE)] // skip when reading (extract)
#[Ignore(Direction::TARGET)] // skip when writing (hydrate)
public string $internalToken;Direction explanation:
Direction::BOTH— property is not hydrated from input AND not extracted to outputDirection::TARGET— property is not hydrated from input, but still appears in outputDirection::SOURCE— property is hydrated from input, but not extracted to output
#[DefaultValue('active')]
public string $status;
#[DefaultValue(0)]
public int $score;#[Groups(['admin', 'internal'])]
public string $password;#[Cast(MoneyCaster::class, options: ['currency' => 'USD'])]
public Money $price;#[Transform('trim')]
public string $bio;
#[Transform('slug', options: ['separator' => '-'])]
public string $slug;// For untyped or union type properties
#[MapType(UserDto::class)]
public mixed $user;
// Typed collection
#[MapType(TagDto::class, collection: true)]
public array $tags;#[DateTimeFormat('Y-m-d')]
public \DateTimeImmutable $birthDate;
#[DateTimeFormat('Y-m-d H:i:s', timezone: 'Asia/Jakarta', immutable: true)]
public \DateTimeImmutable $createdAt;#[MaxDepth(3)]
public ?self $parent;#[DiscriminatorMap(field: 'type', map: [
'circle' => CircleDto::class,
'rectangle' => RectangleDto::class,
])]
abstract class ShapeDto {}#[NamingStrategy('snake_case')]
class ApiResponseDto {}#[BeforeMap('normalize')]
#[AfterMap('validate')]
class UserDto
{
public function normalize(array &$data, ?MappingContextInterface $context = null): void
{
// normalize data before mapping — mutate $data directly
$data['email'] = strtolower(trim($data['email'] ?? ''));
}
public function validate(?MappingContextInterface $context = null): void
{
// validate after mapping is complete
if (!filter_var($this->email, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException("Invalid email: {$this->email}");
}
}
}$dto = $mappers->map([
'id' => 1,
'name' => 'Alice',
'email' => 'alice@example.com',
], UserDto::class);$array = $mappers->toArray($dto);
// ['id' => 1, 'name' => 'Alice', 'email_address' => 'alice@example.com']$target = $mappers->map($sourceEntity, UserDto::class);$dto = $mappers->fromJson('{"id":1,"name":"Alice"}', UserDto::class);$json = $mappers->toJson($dto);$mappers->hydrate(['name' => 'Updated'], $existingDto);$dtos = $mappers->mapCollection($rows, UserDto::class);$collection = $mappers->collection($rows, UserDto::class);
// returns MappedCollection<UserDto>Built-in casters handle all native PHP types automatically:
| Type | Caster | Notes |
|---|---|---|
string, int, float, bool |
ScalarCaster |
Smart coercion |
DateTime, DateTimeImmutable |
DateTimeCaster |
String, timestamp, array |
| Backed/Unit enum | EnumCaster |
tryFrom() for backed, case name for unit |
array / list<T> |
ArrayCaster |
Delegates element mapping |
| Object class | ObjectCaster |
Delegates to ArrayToObjectMapper |
Built-in transformers for value transformation during mapping:
| Name | Class | Description |
|---|---|---|
trim |
TrimTransformer |
Strip whitespace (left/right/both) |
case |
CaseTransformer |
upper / lower / title |
slug |
SlugTransformer |
URL-friendly slug |
round |
RoundTransformer |
round / floor / ceil with precision |
json |
JsonTransformer |
Encode to / decode from JSON |
format |
FormatTransformer |
sprintf-style formatting |
#[Transform('trim')]
public string $bio;
#[Transform('case', options: ['case' => 'lower'])]
public string $username;
#[Transform('format', options: ['format' => 'ID-%05d'])]
public string $displayId;| Alias | Class | Example |
|---|---|---|
camelCase |
CamelCaseNamingStrategy |
firstName (default) |
snake_case |
SnakeCaseNamingStrategy |
first_name |
PascalCase |
PascalCaseNamingStrategy |
FirstName |
kebab-case |
KebabCaseNamingStrategy |
first-name |
Set globally:
$mappers->getRegistry()->setDefaultNamingStrategy(new SnakeCaseNamingStrategy());Set per class:
#[NamingStrategy('snake_case')]
class ApiPayloadDto {}Control which properties are included based on runtime context:
class UserDto
{
public int $id;
public string $name;
#[Groups(['admin'])]
public ?string $password;
#[Groups(['internal', 'admin'])]
public ?string $apiToken;
}
// Public output — no sensitive fields
$public = $mappers->toArray($user, $mappers->context(['public']));
// Admin output — all fields
$admin = $mappers->toArray($user, $mappers->context(['admin']));Works out of the box with PHP 8.2+ readonly classes and constructor promotion:
final readonly class UserDto
{
public function __construct(
public int $id,
public string $name,
public string $email,
public string $status = 'active',
public ?\DateTimeImmutable $createdAt = null,
) {}
}
$dto = $mappers->map($data, UserDto::class);
// Constructor arguments are resolved from $data automaticallyNested objects are mapped recursively without any extra configuration:
class OrderDto
{
public int $id;
public CustomerDto $customer; // auto-mapped
public AddressDto $shippingAddress; // auto-mapped
/** @var list<OrderItemDto> */
#[MapType(OrderItemDto::class, collection: true)]
public array $items;
}
$order = $mappers->map($payload, OrderDto::class);
// $order->customer instanceof CustomerDto ✓
// $order->items[0] instanceof OrderItemDto ✓$users = $mappers->mapCollection($rows, UserDto::class);// Create a typed collection
$collection = $mappers->collection($rows, UserDto::class);
// MappedCollection<UserDto>
// From a JSON array
$collection = $mappers->collectionFromJson($jsonArray, UserDto::class);
// Functional helpers
$active = $collection->filter(fn (UserDto $u) => $u->status === 'active');
$names = $collection->mapToArray(fn (UserDto $u) => $u->name);
$sorted = $collection->sortBy(fn (UserDto $a, UserDto $b) => $a->id <=> $b->id);
$grouped = $collection->groupBy(fn (UserDto $u) => $u->status);
$keyed = $collection->keyBy(fn (UserDto $u) => $u->id);
$slice = $collection->slice(offset: 0, length: 10);
$chunks = $collection->chunk(size: 10);use Mappers\Collection\PaginatedCollection;
// From a full collection (library does the slicing)
$paged = PaginatedCollection::fromCollection($collection, page: 2, perPage: 10);
// From pre-sliced items (database did LIMIT/OFFSET)
$paged = PaginatedCollection::fromPageItems($pageItems, total: 100, page: 2, perPage: 10);
echo $paged->meta->totalPages; // 10
echo $paged->meta->hasNextPage; // true
echo $paged->meta->hasPreviousPage; // true
echo $paged->meta->getOffset(); // 10enum Status: string
{
case Active = 'active';
case Inactive = 'inactive';
}
class UserDto
{
public Status $status;
public ?Status $previousStatus;
}
$dto = $mappers->map(['status' => 'active', 'previousStatus' => null], UserDto::class);
// $dto->status === Status::Active ✓class EventDto
{
#[DateTimeFormat('Y-m-d')]
public \DateTimeImmutable $date;
#[DateTimeFormat('Y-m-d H:i:s', timezone: 'Asia/Jakarta')]
public \DateTimeImmutable $startsAt;
}
// Accepts: ISO string, unix timestamp, strtotime-compatible string
$event = $mappers->map([
'date' => '2024-06-01',
'startsAt' => 1717286400,
], EventDto::class);#[DiscriminatorMap(field: 'type', map: [
'circle' => CircleDto::class,
'rectangle' => RectangleDto::class,
])]
abstract class ShapeDto
{
public string $type;
public string $color;
}
final class CircleDto extends ShapeDto
{
public float $radius;
}
$shape = $mappers->map(['type' => 'circle', 'color' => 'red', 'radius' => 5.0], ShapeDto::class);
// $shape instanceof CircleDto ✓#[BeforeMap('normalize')]
#[AfterMap('validate')]
class UserDto
{
public string $email;
/**
* BeforeMap: receives $data by reference for normalization before mapping
*/
public function normalize(array &$data, ?MappingContextInterface $context = null): void
{
$data['email'] = strtolower(trim($data['email'] ?? ''));
}
/**
* AfterMap: validates after all properties are set
*/
public function validate(?MappingContextInterface $context = null): void
{
if (!filter_var($this->email, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException("Invalid email: {$this->email}");
}
}
}Note: The
$dataparameter inBeforeMapis passed by reference — mutate the$dataarray directly to normalize input before hydration.
class UserDto
{
public string $name;
#[MaxDepth(2)]
public ?self $manager = null;
}
// Circular references are tracked per MappingContext
// MaxDepth stops recursion at the specified level
$context = $mappers->context();
$dto = $mappers->map($data, UserDto::class, $context);MappingContext carries runtime state through the entire pipeline:
$context = $mappers->context(
groups: ['public', 'api'],
data: ['locale' => 'en', 'version' => 2],
);
$dto = $mappers->map($data, UserDto::class, $context);
// Read context in hooks / casters
public function onAfterMap(?MappingContextInterface $context = null): void
{
$locale = $context?->get('locale', 'en');
}
// withGroups — immutable chaining
$publicCtx = $context->withGroups(['public']);
$adminCtx = $context->withGroups(['admin']);use Mappers\Plugin\PluginBuilder;
$plugin = PluginBuilder::create('my-app')
->withCaster(new MoneyCaster())
->withTransformer(new PhoneNormalizerTransformer())
->withNamingStrategy('dot.case', new DotCaseStrategy())
->withCallableTransformer('reverse', fn ($v) => is_string($v) ? strrev($v) : $v)
->withPriority(10)
->build();
$mappers->addPlugin($plugin);use Mappers\Contracts\PluginInterface;
use Mappers\Contracts\RegistryInterface;
final class MyPlugin implements PluginInterface
{
public function getName(): string { return 'my-plugin'; }
public function register(RegistryInterface $registry): void
{
$registry->registerCaster(new MyCaster());
$registry->registerTransformer(new MyTransformer());
}
public function getPriority(): int { return 0; }
}
$mappers->addPlugin(new MyPlugin());use Mappers\Contracts\CasterInterface;
use Mappers\Contracts\MappingContextInterface;
use Mappers\Contracts\TypeMetadataInterface;
final class MoneyCaster implements CasterInterface
{
public function cast(mixed $value, TypeMetadataInterface $type, ?MappingContextInterface $context = null): mixed
{
if ($value instanceof Money) return $value;
if (is_string($value) && str_contains($value, ':')) {
[$currency, $amount] = explode(':', $value, 2);
return new Money((float) $amount, strtoupper($currency));
}
if (is_array($value) && isset($value['amount'], $value['currency'])) {
return new Money((float) $value['amount'], strtoupper($value['currency']));
}
return new Money((float) $value, 'USD');
}
public function supports(TypeMetadataInterface $type): bool
{
return $type->getClassName() === Money::class;
}
}Register:
$mappers->getRegistry()->registerCaster(new MoneyCaster());
// Or per-property:
#[Cast(MoneyCaster::class)]
public Money $price;use Mappers\Contracts\TransformerInterface;
final class PhoneTransformer implements TransformerInterface
{
public function transform(mixed $value, array $options = [], ?MappingContextInterface $context = null): mixed
{
if (!is_string($value)) return $value;
return preg_replace('/[^\d+]/', '', $value) ?? $value;
}
public function supports(mixed $value, array $options = []): bool
{
return is_string($value) || $value === null;
}
public function getName(): string { return 'phone'; }
}Chainable value transformation pipeline:
use Mappers\Pipeline\MappingPipeline;
$pipeline = MappingPipeline::fromStages([
fn ($v) => is_string($v) ? trim($v) : $v,
fn ($v) => is_string($v) ? strtolower($v) : $v,
fn ($v) => is_string($v) ? str_replace(' ', '-', $v) : $v,
]);
$result = $pipeline->process(' Hello World ');
// 'hello-world'
// pipe() is immutable
$pipeline2 = $pipeline->pipe(fn ($v) => strtoupper($v));
// Pipeline with context
$ctx = $mappers->context(data: ['prefix' => 'ID_']);
$ctxPipe = MappingPipeline::fromStages([
fn ($v, $c) => ($c?->get('prefix') ?? '') . $v,
]);
$result = $ctxPipe->process('user-123', $ctx);
// 'ID_user-123'Speed up metadata resolution in production with a PSR-16 cache:
use Symfony\Component\Cache\Adapter\RedisAdapter;
use Symfony\Component\Cache\Psr16Cache;
$redis = RedisAdapter::createConnection('redis://localhost');
$cache = new Psr16Cache(new RedisAdapter($redis));
$mappers = Mappers::create($cache);
// Metadata is cached in Redis — reflection runs only once per cache lifetimeBenchmark results (PHP 8.2, OPcache enabled):
| Operation | ops/sec | Memory |
|---|---|---|
| Array → object (warm cache) | ~180,000 | ~2KB |
| Nested array → object | ~65,000 | ~6KB |
| Collection (100 items) | ~1,800 | ~180KB |
| Object → array | ~200,000 | ~1.5KB |
| Object → JSON | ~150,000 | ~2KB |
| JSON → object | ~120,000 | ~3KB |
| Readonly constructor | ~160,000 | ~2KB |
Run benchmarks locally:
composer benchPerformance tips:
- Reuse
Mappers::create()— create once, use everywhere (singleton in DI container). - Use PSR-16 cache in production to persist metadata across requests.
- Use
#[Ignore]on properties that don't need mapping — fewer properties = faster. - Group filtering adds minimal overhead.
Q: Does Mappers work with Symfony / Laravel / Slim?
A: Yes. Mappers has no framework dependencies. Register Mappers::create() as a singleton in your DI container.
Q: Can it be used with Doctrine entities?
A: Yes. Map entity → DTO with $mappers->map($entity, UserDto::class). Avoid mapping DTOs back into entities with lazy-loaded collections; extract first.
Q: What if a key is missing from the source?
A: Missing keys trigger #[DefaultValue] if set, otherwise the property retains its PHP default. No exception is thrown for optional fields.
Q: Can I map into an existing object without creating a new instance?
A: Yes — use $mappers->hydrate($data, $existingObject).
Q: Is it thread-safe? A: Metadata cache lives in per-process memory. PHP-FPM creates a new process per request, so there are no concurrency issues. Metadata objects are immutable.
Q: How do I debug a mapping problem? A: Check the exception message — all exceptions include the class name, property name, and value that caused the issue.
Q: What is the difference between Direction::SOURCE and Direction::TARGET on #[Ignore]?
A: Direction::TARGET means the property is not hydrated from input (but is still extracted to output). Direction::SOURCE means the property is hydrated from input but not extracted to output. Direction::BOTH (default) means both.
Q: How does BeforeMap work?
A: The BeforeMap method is called after the object is created but before properties are filled. The $data parameter is passed by reference — mutate the array directly to normalize input.
MIT — see LICENSE.
See CONTRIBUTING.md. Security issues: see SECURITY.md.