hypothesisphp / mappers
Mapper & Hydrator library — the de facto standard for object mapping in PHP
Requires
- php: ^8.2
- ext-json: *
- ext-mbstring: *
- psr/container: ^2.0
- psr/log: ^3.0
- psr/simple-cache: ^3.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.64
- infection/infection: ^0.27 || ^0.28 || ^0.29
- mockery/mockery: ^1.6
- phpbench/phpbench: ^1.3
- phpstan/phpstan: ^1.12
- phpstan/phpstan-strict-rules: ^1.6
- phpunit/phpunit: ^10.5
- rector/rector: ^1.2 || ^2.0
- symfony/cache: ^7.1
- symfony/var-dumper: ^7.1
- vimeo/psalm: ^5.0 || ^6.0
This package is not auto-updated.
Last update: 2026-07-18 00:41:16 UTC
README
Map arrays, objects, and JSON to typed PHP objects with zero configuration.
Why Mappers?
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
Installation
composer require hypothesisphp/mappers
Requirements: PHP 8.2+, ext-json, ext-mbstring
Quick Start
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);
Table of Contents
- 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
Core Concepts
Mapper
Transforms a source (array, object, JSON string) into a target object. Supports Array→Object, Object→Object, JSON→Object.
Hydrator
Fills an existing object instance with data from an array. Also extracts object data back into an array.
Metadata
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.
Registry
Central container for casters, transformers, naming strategies, and plugins.
Attribute Reference
#[MapFrom] — Source key alias
#[MapFrom('user_name')] public string $name; // Multiple aliases (first match wins) #[MapFrom(['user_name', 'username', 'login'])] public string $name;
#[MapTo] — Custom output key
#[MapTo('email_address')] public string $email; // toArray() → ['email_address' => '...']
#[Ignore] — Exclude from mapping
#[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] — Fallback value
#[DefaultValue('active')] public string $status; #[DefaultValue(0)] public int $score;
#[Groups] — Group-based filtering
#[Groups(['admin', 'internal'])] public string $password;
#[Cast] — Custom caster per property
#[Cast(MoneyCaster::class, options: ['currency' => 'USD'])] public Money $price;
#[Transform] — Value transformer
#[Transform('trim')] public string $bio; #[Transform('slug', options: ['separator' => '-'])] public string $slug;
#[MapType] — Explicit type declaration
// For untyped or union type properties #[MapType(UserDto::class)] public mixed $user; // Typed collection #[MapType(TagDto::class, collection: true)] public array $tags;
#[DateTimeFormat] — DateTime parsing format
#[DateTimeFormat('Y-m-d')] public \DateTimeImmutable $birthDate; #[DateTimeFormat('Y-m-d H:i:s', timezone: 'Asia/Jakarta', immutable: true)] public \DateTimeImmutable $createdAt;
#[MaxDepth] — Recursion depth limit
#[MaxDepth(3)] public ?self $parent;
#[DiscriminatorMap] — Polymorphic mapping
#[DiscriminatorMap(field: 'type', map: [ 'circle' => CircleDto::class, 'rectangle' => RectangleDto::class, ])] abstract class ShapeDto {}
#[NamingStrategy] — Per-class naming strategy
#[NamingStrategy('snake_case')] class ApiResponseDto {}
#[BeforeMap] / #[AfterMap] — Lifecycle hooks
#[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}"); } } }
Mapping Types
Array → Object
$dto = $mappers->map([ 'id' => 1, 'name' => 'Alice', 'email' => 'alice@example.com', ], UserDto::class);
Object → Array
$array = $mappers->toArray($dto); // ['id' => 1, 'name' => 'Alice', 'email_address' => 'alice@example.com']
Object → Object
$target = $mappers->map($sourceEntity, UserDto::class);
JSON → Object
$dto = $mappers->fromJson('{"id":1,"name":"Alice"}', UserDto::class);
Object → JSON
$json = $mappers->toJson($dto);
Hydrate an existing instance
$mappers->hydrate(['name' => 'Updated'], $existingDto);
Map a collection
$dtos = $mappers->mapCollection($rows, UserDto::class);
Typed collection (MappedCollection)
$collection = $mappers->collection($rows, UserDto::class); // returns MappedCollection<UserDto>
Type Casting
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 |
Transformers
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;
Naming Strategy
| 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 {}
Groups
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']));
Readonly & Immutable Objects
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 automatically
Nested Objects
Nested 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 ✓
Collections
Plain collection
$users = $mappers->mapCollection($rows, UserDto::class);
Typed MappedCollection
// 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);
Pagination
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(); // 10
Enum Mapping
enum 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 ✓
DateTime Mapping
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);
Polymorphic Mapping
#[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 ✓
Lifecycle Hooks
#[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.
Circular Reference Protection
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);
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']);
Plugin System
Using PluginBuilder
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);
Implementing PluginInterface
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());
Custom Caster
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;
Custom Transformer
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'; } }
MappingPipeline
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'
PSR-16 Cache
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 lifetime
Performance
Benchmark 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 bench
Performance 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.
FAQ
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.
License
MIT — see LICENSE.
Contributing
See CONTRIBUTING.md. Security issues: see SECURITY.md.