Skip to content

HypothesisPHP/Mappers

Repository files navigation

Mappers

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


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 output
  • Direction::TARGET — property is not hydrated from input, but still appears in output
  • Direction::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 $data parameter in BeforeMap is passed by reference — mutate the $data array 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:

  1. Reuse Mappers::create() — create once, use everywhere (singleton in DI container).
  2. Use PSR-16 cache in production to persist metadata across requests.
  3. Use #[Ignore] on properties that don't need mapping — fewer properties = faster.
  4. 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.

About

Mapper & Hydrator library — the de facto standard for object mapping in PHP

Topics

Resources

License

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Packages

 
 
 

Contributors

Languages