- Overview
- Installation
- How to use
- The Mapper service
- The Mappable trait
- Polymorphic types with Subtype
- Flat rows with Layout
- JSON columns with JsonColumn
- Collections that map themselves
- Scalar codecs with Codec
- Self-describing scalars with ScalarCodec
- Transparent delegation for single-property wrappers
- Factory construction with FactoryMethod
- Configuration and naming
- Exceptions
- License
- Contributing
Maps PHP objects to and from arrays, JSON, and iterables through reflection and pluggable strategies. Handles backed and pure enums, value objects, nested objects, date-time types, and collections out of the box. Designed for DTO hydration, serialization at the HTTP boundary, flat-row decoding at the persistence boundary, and data transfer between bounded contexts.
The library exposes two complementary ways to use it. The primary one is the Mapper service, an immutable
builder that keeps the mapped class fully decoupled from the library. No interface to implement, no trait to
use, nothing on the domain side. It fulfills two narrow service contracts, Serializer (object to array and
JSON) and Deserializer (array, JSON, or iterable to object), so a consumer can depend on the capability it
needs rather than on the concrete service.
The second is the Mappable interface plus the MappableBehavior trait, an opt-in hook for application DTOs
that prefer to map themselves. Mappable combines Serializable (render to array and JSON) and
Deserializable (build from a source). The mapping logic lives in the mapper, not on the type: objects are
reflected by the engine and collections are built internally, so a Mappable type exposes no engine-facing
method. When a configured Mapper maps a Mappable value, the engine reflects it through the active naming,
so nested children resolve through any registered mappings.
composer require tiny-blocks/mapperMapper::create() returns an empty mapper with identity naming and lenient unknown keys. The same instance
hydrates from arrays, JSON strings, and iterables, and serializes back to arrays or JSON.
A currency-bearing amount is the value object the mapper hydrates.
<?php
declare(strict_types=1);
final readonly class Amount
{
public function __construct(public int $amount, public Currency $currency)
{
}
}The amount references a backed enum that names the currency.
<?php
declare(strict_types=1);
enum Currency: string
{
case BRL = 'BRL';
case USD = 'USD';
}With those in place, the mapper reads and writes them through a single service. toObject hydrates from
an array, a JSON string, or any iterable.
<?php
declare(strict_types=1);
use TinyBlocks\Mapper\Mapper;
$mapper = Mapper::create();
$amount = $mapper->toObject(type: Amount::class, source: ['amount' => 50, 'currency' => 'BRL']);
$amount = $mapper->toObject(type: Amount::class, source: '{"amount":50,"currency":"BRL"}');toObjectOrNull returns null when the source is null, and behaves like toObject otherwise.
$missing = $mapper->toObjectOrNull(type: Amount::class, source: null);The same service serializes back to an array or a JSON string.
$array = $mapper->toArray(source: $amount);
$json = $mapper->toJson(source: $amount);withNaming, withMapping, and rejectingUnknownKeys return a new mapper each time. The original instance
keeps its previous configuration.
<?php
declare(strict_types=1);
use TinyBlocks\Mapper\Mapper;
use TinyBlocks\Mapper\SnakeCase;
$strict = Mapper::create()
->withNaming(namingStrategy: SnakeCase::create())
->rejectingUnknownKeys();The mapper fulfills two service contracts: Serializer (toArray, toJson, and their *OrNull variants)
and Deserializer (toObject, toProperties, toObjectOrNull). A consumer can type-hint the narrow
contract it depends on instead of the concrete Mapper.
When an application DTO prefers to map itself, implement Mappable and use MappableBehavior. Mappable
combines Serializable (the toArray and toJson output methods) and Deserializable (the buildFrom
factory). The trait implements all three on top of the same engine that backs the Mapper service, so the
type needs no explicit Mapper instance at the call site and exposes no engine-facing method.
A two-field address wires itself into the mapper through the trait.
<?php
declare(strict_types=1);
use TinyBlocks\Mapper\Mappable;
use TinyBlocks\Mapper\MappableBehavior;
final readonly class Address implements Mappable
{
use MappableBehavior;
public function __construct(public string $city, public string $street)
{
}
}The DTO drives its own hydration and serialization.
<?php
declare(strict_types=1);
$address = Address::buildFrom(source: ['street' => 'Av. Paulista', 'city' => 'São Paulo']);
$array = $address->toArray();
$json = $address->toJson();buildFrom, toArray, and toJson map the instance on its own, with identity naming and no registered
mappings. When a configured Mapper serializes or hydrates a Mappable subject, the engine reflects it
through the active naming, so any mapping registered for a nested type applies. A mapping registered for the
Mappable type itself takes precedence over the reflection-based default.
Subtype::by builds a mapping that selects a concrete class by the value of a discriminator field. It lists the
concrete types only and derives each discriminator value from the type's short name. The naming strategy applied
to the short name produces the value and defaults to snake_case. On read, the field value picks the class. On
write, the class is reverse-looked-up to its derived value, which is written back exactly once.
The abstract parent the discriminator resolves to.
<?php
declare(strict_types=1);
abstract readonly class PaymentMethod
{
}A Pix concrete type carries a payer identifier and a static factory used as the Subtype default.
<?php
declare(strict_types=1);
final readonly class Pix extends PaymentMethod
{
public function __construct(public string $payerId)
{
}
public static function pending(): Pix
{
return new Pix(payerId: 'pending');
}
}A debit-card concrete type the same discriminator can resolve to.
<?php
declare(strict_types=1);
final readonly class DebitCard extends PaymentMethod
{
public function __construct(public string $cardNumber)
{
}
}The mapping wires both concrete types under a shared discriminator field. The field names the discriminator,
types are the concrete classes whose short names derive the values, naming is the optional convention
(snake_case by default), and default is the optional factory used when no case matches.
<?php
declare(strict_types=1);
use TinyBlocks\Mapper\Mapper;
use TinyBlocks\Mapper\Subtype;
$mapper = Mapper::create()->withMapping(
type: PaymentMethod::class,
mapping: Subtype::by(
field: 'type',
types: [Pix::class, DebitCard::class],
default: static fn(): Pix => Pix::pending()
)
);
$method = $mapper->toObject(type: PaymentMethod::class, source: ['type' => 'pix', 'payerId' => 'Alice']);With the snake_case default, DebitCard derives debit_card and Pix derives pix, so the listed types alone
define the discriminator values. The optional default factory is invoked when no case matches and when the
discriminator field is absent. With no default, an unmatched case raises UnknownSubtype. A misconfigured
mapping raises InvalidSubtypeCase: when two types derive the same value, or when a registered case is not a
subtype of the mapped type.
Layout::from builds a mapping for a flat relational row whose columns map onto a nested object graph. Columns
that follow the prefix-derivation convention ({field}{separator}{subfield} under the active naming strategy)
are derived and need no entry. Only renamed leaves, columns outside their expected prefix, and JSON-encoded
columns are declared.
A camera with a serial number and a shot counter.
<?php
declare(strict_types=1);
final readonly class Camera
{
public function __construct(public string $serialNumber, public int $shotCount)
{
}
}A studio composed of a main camera and a tag.
<?php
declare(strict_types=1);
final readonly class Studio
{
public function __construct(public Camera $mainCamera, public string $tag)
{
}
}The flat row maps onto the nested studio through prefix derivation.
<?php
declare(strict_types=1);
use TinyBlocks\Mapper\Layout;
use TinyBlocks\Mapper\Mapper;
use TinyBlocks\Mapper\SnakeCase;
$mapper = Mapper::create()
->withNaming(namingStrategy: SnakeCase::create())
->withMapping(type: Studio::class, mapping: Layout::from(paths: []));
$studio = $mapper->toObject(type: Studio::class, source: [
'main_camera_serial_number' => 'sn-1',
'main_camera_shot_count' => 7,
'tag' => 'studio-a'
]);The empty paths array means every column is derived from the property prefix. A non-empty array overrides
specific leaves with a different column name.
A JsonColumn marks a column as holding a JSON document. On read, the column is decoded and mapped onto the
graph path. On write, the corresponding subgraph is encoded back into the column. Other declared paths combine
freely with JSON-marked ones in the same Layout::from(paths: [...]) call.
A member identifier wraps a single scalar with a value accessor.
<?php
declare(strict_types=1);
final readonly class MemberId
{
public function __construct(private string $value)
{
}
public function value(): string
{
return $this->value;
}
}An owner composed of a member identifier and a display name.
<?php
declare(strict_types=1);
final readonly class Owner
{
public function __construct(public MemberId $memberId, public string $name)
{
}
}The mapping decodes the member column as JSON onto the memberId path.
<?php
declare(strict_types=1);
use TinyBlocks\Mapper\JsonColumn;
use TinyBlocks\Mapper\Layout;
use TinyBlocks\Mapper\Mapper;
$mapper = Mapper::create()->withMapping(
type: Owner::class,
mapping: Layout::from(paths: ['memberId' => new JsonColumn(column: 'member')])
);
$owner = $mapper->toObject(
type: Owner::class,
source: ['member' => '{"value":"m-1"}', 'name' => 'Alice']
);PHP carries no runtime element type for a typed collection, so a collection implements IterableMappable and
declares its element type with the #[ElementType] attribute. On read, the mapper maps each source element to
that type and hands the built elements to createFrom. Absence of the attribute means passthrough: elements
are kept as-is. On write, the collection is iterated and each element is serialized through the engine, so a
mapping registered for the element type is honored on every element.
A refund row bound to a single amount.
<?php
declare(strict_types=1);
final readonly class Refund
{
public function __construct(public string $reference, public Amount $amount)
{
}
}A refund collection that maps itself element by element.
<?php
declare(strict_types=1);
use Generator;
use IteratorAggregate;
use TinyBlocks\Mapper\ElementType;
use TinyBlocks\Mapper\IterableMappable;
use TinyBlocks\Mapper\Mapper;
#[ElementType(Refund::class)]
class Refunds implements IteratorAggregate, IterableMappable
{
private function __construct(public readonly iterable $elements)
{
}
public static function createFrom(iterable $elements): static
{
return new static(elements: $elements);
}
public function toJson(): string
{
return Mapper::create()->toJson(source: $this);
}
public function toArray(): array
{
return Mapper::create()->toArray(source: $this);
}
public function getIterator(): Generator
{
foreach ($this->elements as $key => $element) {
yield $key => $element;
}
}
}Mapping a list of refund rows into the collection needs no registration. The mapper builds each element from
the declared #[ElementType] and hands the result to createFrom.
<?php
declare(strict_types=1);
use TinyBlocks\Mapper\Mapper;
$mapper = Mapper::create();
$refunds = $mapper->toObject(type: Refunds::class, source: [
['reference' => 'r-1', 'amount' => ['amount' => 100, 'currency' => 'BRL']],
['reference' => 'r-2', 'amount' => ['amount' => 200, 'currency' => 'BRL']]
]);The tiny-blocks/collection library ships this behavior built in. Its Collection base class already
implements IterableMappable, so a typed collection only declares its element with the #[ElementType]
attribute.
Codec::from builds a mapping for a value object that has a single canonical scalar form, when the default reflection
would not reproduce it. A typical case is a value object that wraps an inner representation whose direct serialization
drifts from the canonical form, for example a date-only value that would otherwise widen to a full datetime. The
consumer owns both conversions, so the library stays decoupled from the wrapped type.
A calendar date that wraps an inner value but presents a canonical date-only string.
<?php
declare(strict_types=1);
use DateTimeImmutable;
final readonly class CalendarDate
{
private function __construct(private DateTimeImmutable $value)
{
}
public static function fromIso(string $iso): CalendarDate
{
return new CalendarDate(value: DateTimeImmutable::createFromFormat('!Y-m-d', $iso));
}
public function toIso(): string
{
return $this->value->format('Y-m-d');
}
}A reservation composed of a single calendar date.
<?php
declare(strict_types=1);
final readonly class Reservation
{
public function __construct(public CalendarDate $checkIn)
{
}
}The codec pins the canonical scalar form on both directions.
<?php
declare(strict_types=1);
use TinyBlocks\Mapper\Codec;
use TinyBlocks\Mapper\Mapper;
$mapper = Mapper::create()->withMapping(
type: CalendarDate::class,
mapping: Codec::from(
decode: static fn(string $iso): CalendarDate => CalendarDate::fromIso(iso: $iso),
encode: static fn(CalendarDate $date): string => $date->toIso()
)
);
# The encode closure drives every write, so the nested date stays a canonical YYYY-MM-DD string.
$row = $mapper->toArray(source: new Reservation(checkIn: CalendarDate::fromIso(iso: '2026-05-23')));
# A bare scalar is decoded directly into the value object.
$checkIn = $mapper->toObject(type: CalendarDate::class, source: '2026-05-23');The encode closure is consulted for every write of the type, nested or top-level. The decode closure is consulted whenever the type is resolved, top-level or as a nested scalar property: a registered mapping takes precedence over the built-in single-property unwrap. Only unregistered single-value wrappers fall back to that unwrap.
#[ScalarCodec] is the self-describing twin of Codec: rather than registering closures, a value object names
the methods that convert it to and from a scalar, and the mapper calls them with no configuration. Each attribute
is one decode and encode pair, and the attribute is repeatable. On read, the pair whose decode parameter type
accepts the source scalar is selected, so a type can be built from more than one scalar form. On write, the first
declared pair's encode is used. The attribute adds no public methods: it names methods the type already has. A
mapping registered through withMapping for the same type takes precedence over the attribute.
A version that builds from a label string or a release number, and renders back to its label.
<?php
declare(strict_types=1);
use TinyBlocks\Mapper\ScalarCodec;
#[ScalarCodec(decode: 'fromLabel', encode: 'toLabel')]
#[ScalarCodec(decode: 'fromNumber', encode: 'toLabel')]
final readonly class Version
{
private function __construct(private string $label)
{
}
public static function fromLabel(string $label): Version
{
return new Version(label: $label);
}
public static function fromNumber(int $number): Version
{
return new Version(label: (string) $number);
}
public function toLabel(): string
{
return $this->label;
}
}A release composed of a single version.
<?php
declare(strict_types=1);
final readonly class Release
{
public function __construct(public Version $version)
{
}
}No registration is needed: the attribute carries the conversion in both directions.
<?php
declare(strict_types=1);
use TinyBlocks\Mapper\Mapper;
$mapper = Mapper::create();
# The string source selects the decode whose parameter is typed string.
$fromLabel = $mapper->toObject(type: Version::class, source: 'v7');
# The integer source, nested in a release, selects the decode whose parameter is typed int.
$release = $mapper->toObject(type: Release::class, source: ['version' => 7]);
# The first declared pair's encode renders the scalar form.
$label = $mapper->toArray(source: Version::fromLabel(label: 'v7'));A mapping is specified in one of two ways. Registered mappings are attached through withMapping: Codec,
FactoryMethod, Layout, and Subtype each build a Mapping the mapper consults first. Self-describing types
carry the rule themselves: the Mappable and IterableMappable interfaces, and the #[ElementType] and
#[ScalarCodec] attributes. A registered mapping always wins over a self-describing one for the same type.
A type with a single property needs no mapping of its own to share the scalar form of the value it wraps. When that single property reduces to a scalar, the mapper unwraps the type on write and rebuilds it on read, delegating to the inner type's own mapping. The wrapper declares nothing, and the delegation recurses through nested wrappers until it reaches the scalar.
A property reduces to a scalar when its type is a native scalar, a backed enum, a DateTimeInterface, a type
annotated with #[ScalarCodec], or, recursively, another single-property type whose own property reduces to a
scalar. A pure enum reduces to its case name. A Traversable never reduces, and neither does an object
with two nor more properties.
A priority label that wraps a backed enum and declares no mapping.
<?php
declare(strict_types=1);
enum Priority: string
{
case LOW = 'low';
case HIGH = 'high';
}
final readonly class Label
{
public function __construct(public Priority $priority)
{
}
}A task composed of a name and a single label.
<?php
declare(strict_types=1);
final readonly class Task
{
public function __construct(public string $name, public Label $label)
{
}
}The label collapses to the backing value on write and rebuilds from the same scalar on read.
<?php
declare(strict_types=1);
use TinyBlocks\Mapper\Mapper;
$mapper = Mapper::create();
# The label adopts the backed enum's scalar form, so the column holds a bare value.
$row = $mapper->toArray(source: new Task(name: 'deploy', label: new Label(priority: Priority::HIGH)));
# The same scalar reconstructs the nested label.
$task = $mapper->toObject(type: Task::class, source: ['name' => 'deploy', 'label' => 'high']);The serialized form of the single property follows the inner type.
| Inner type of the single property | Serialized form |
|---|---|
| A native scalar (int, float, string, bool). | The scalar itself. |
| A backed enum. | The backing value. |
| A pure enum. | The case name. |
A DateTimeInterface. |
The ISO 8601 string. |
A type annotated with #[ScalarCodec]. |
The encoded scalar. |
| A single-property type that reduces to a scalar. | The funneled inner scalar. |
A Traversable collection. |
A nested array under the property key. |
| An object with two or more properties. | A nested object under the property key. |
Delegation is the fallback for a single-property type that neither registers a mapping nor self-describes. To
take over the scalar form, make the wrapper itself carry the rule: register a mapping through withMapping, or
annotate the wrapper with #[ScalarCodec]. The order of precedence is a registered mapping first, then a
#[ScalarCodec] on the wrapper, then delegation, then plain reflection. A wrapper that owns a Codec or a
#[ScalarCodec] always wins over the delegation to its inner type.
FactoryMethod::using builds a mapping that constructs the target through one of its own public static factory methods,
reflecting the factory parameters from the source. It complements Codec: where a codec converts a scalar through
closures, a factory mapping is reflection-based and works for any arity. The mapped type imports nothing from the
library, and the factory drives the real construction path (invariants, lookups, parsing) that plain reflection
injection would skip.
A money value object reconstructed through a named factory that normalizes the currency code.
<?php
declare(strict_types=1);
final readonly class Money
{
private function __construct(public int $cents, public string $currency)
{
}
public static function of(int $cents, string $currency): Money
{
return new Money(cents: $cents, currency: strtoupper($currency));
}
}The mapping is registered like any other, and the factory drives every read.
<?php
declare(strict_types=1);
use TinyBlocks\Mapper\FactoryMethod;
use TinyBlocks\Mapper\Mapper;
$mapper = Mapper::create()->withMapping(
type: Money::class,
mapping: FactoryMethod::using(method: 'of')
);
# Each parameter is resolved from the array by its name, then passed to the factory.
$money = $mapper->toObject(type: Money::class, source: ['cents' => 500, 'currency' => 'brl']);
# Reflection over the declared properties writes the array back, symmetric with the read input.
$row = $mapper->toArray(source: $money);The factory parameter names must match the target's property names. Each parameter is resolved by its name under the
active NamingStrategy, with scalar coercion and recursive mapping, honoring any registered mapping for nested types.
A single-parameter factory is fed the scalar source directly. A multi-parameter factory is fed an array, so a
top-level multi-parameter source must be an array, not a JSON string.
Writing is reflection over the instance's declared properties, not the inverse of the factory. A single-property object writes back to a scalar and a compound one to an array, so the round-trip is lossless only when the persisted form is the canonical form the factory consumes.
Layout::from(paths: [...], factory: 'of') composes the two: the flat row is reshaped onto the nested graph and the
final object is built through the factory instead of reflection injection. The nested values inside resolve through
the registry, just as a top-level factory mapping does.
Configuration carries per-call output options. The default preserves keys and omits no fields. omitting
excludes properties from the output. discardingKeys reindexes iterable content with numeric keys, switching
the underlying KeyPreservation from PRESERVE to DISCARD.
A profile with a name, an optional title, a creation timestamp, and a severity.
<?php
declare(strict_types=1);
use DateTimeImmutable;
final readonly class Profile
{
public function __construct(
public string $name,
public ?string $title,
public DateTimeImmutable $createdAt,
public Severity $severity
) {
}
}A pure enum that classifies the profile severity.
<?php
declare(strict_types=1);
enum Severity
{
case LOW;
case HIGH;
}The profile is hydrated through the mapper from a source array, so omitting operates on a visible typed
property. The $refunds collection from the previous section is reindex with numeric keys.
<?php
declare(strict_types=1);
use TinyBlocks\Mapper\Configuration;
use TinyBlocks\Mapper\Mapper;
$mapper = Mapper::create();
$profile = $mapper->toObject(type: Profile::class, source: [
'name' => 'Alice',
'title' => 'Owner',
'createdAt' => '2026-01-01T00:00:00+00:00',
'severity' => 'HIGH'
]);
$array = $mapper->toArray(
source: $profile,
configuration: Configuration::default()->omitting('title')
);
$reindex = $mapper->toArray(
source: $refunds,
configuration: Configuration::default()->discardingKeys()
);NamingStrategy is the interface controlling how source keys translate to property names. Identity (the
default) expects keys that already match property names. SnakeCase translates between snake_case source keys
and camelCase properties, and it also drives the prefix-derivation column names used by Layout. A custom
convention implements NamingStrategy directly, defining toSourceKey (a property name to its source key) and
derivedColumn (the ordered property-path segments to a flat column name).
Mapper::create()->withNaming(namingStrategy: SnakeCase::create());The library raises four public exceptions, all under TinyBlocks\Mapper\Exceptions.
| Exception | Raised when |
|---|---|
UnmappableSource |
The source value cannot be mapped to the requested type (malformed JSON, type mismatch). |
UnknownSubtype |
A Subtype value matches no case and no default factory is configured. |
UnexpectedKey |
A source key matches no property and the mapper was built with rejectingUnknownKeys(). |
InvalidSubtypeCase |
A Subtype maps a case outside the registered type, or derives one value for two types. |
Mapper is licensed under MIT.
Please follow the contributing guidelines to contribute to the project.