cloudbear/class-mapper

Map any array, JSON or YAML structure onto typed PHP classes using reflection and attributes.

Maintainers

Package info

gitlab.com/cloudbear/open-source/php-class-mapper

Issues

pkg:composer/cloudbear/class-mapper

Transparency log

Statistics

Installs: 1 595

Dependents: 2

Suggesters: 0

Stars: 0

v2.1.0 2026-07-28 18:39 UTC

This package is auto-updated.

Last update: 2026-07-31 06:07:11 UTC


README

Packagist Version PHP Version Pipeline License

Map any array, JSON string or HTTP response body onto typed PHP classes. Declare the shape you want as ordinary typed properties, and the mapper fills them in.

use Cloudbear\ClassMapper\Models\AutoResolvingModel;

class User extends AutoResolvingModel
{
    public string $name;
    public int $age;
}

$user = new User(['name' => 'Ada', 'age' => '36']);

$user->name; // (string) 'Ada'
$user->age;  // (int) 36

No mapping configuration, no code generation, no service container. Your model class is the schema.

Why

Most API clients end up either passing raw arrays around or hand-writing a constructor per response shape. This package sits in between: you get typed, autocompletable objects for the cost of declaring the properties, and the mapper handles the coercion that real-world APIs make necessary (numeric strings, timestamps, enums, nested objects).

It is deliberately small. If you need validation, groups, circular references or a normalizer pipeline, use symfony/serializer instead.

Requirements

  • PHP 8.4 or newer
  • symfony/yaml is optional, and only needed to read YAML response bodies
  • nesbot/carbon is optional, and only needed to type date properties as Carbon or CarbonImmutable

Installation

composer require cloudbear/class-mapper

Usage

Mapping

Every public and protected property is looked up by name in the input data. Keys that have no matching property are ignored, and properties that have no matching key are left uninitialized rather than being set to null:

class User extends AutoResolvingModel
{
    public string $name;
    public int $age;
}

$user = new User(['name' => 'Ada', 'unknown' => 'ignored']);

$user->name; // (string) 'Ada'
$user->age;  // Error: must not be accessed before initialization

A null in the input is treated the same as an absent key, so property defaults survive:

class User extends AutoResolvingModel
{
    public ?string $nickname = 'none';
}

new User(['nickname' => null])->nickname; // (string) 'none'

You can also pass a JSON string:

new User('{"name":"Ada"}')->name; // (string) 'Ada'

Supported types

Declared typeBehavior
int, float, string, boolScalars are cast to the declared type
arrayPassed through untouched
DateTime, DateTimeImmutableParsed from DateFormat or static::DATE_FORMAT, or a timestamp
Carbon, CarbonImmutableSame, if nesbot/carbon is installed
stdClassArrays are converted recursively, for structures you cannot type
BackedEnumResolved with Enum::from()
Another AutoResolvingModelMapped recursively
AutoResolvingArrayModelMapped recursively as a collection
Untyped propertyPassed through untouched

Any other class type is instantiated with the raw value, so a class whose constructor accepts it works too. Namespaced and global class names are both resolved.

Scalar casting is lenient on purpose, because APIs are:

class User extends AutoResolvingModel
{
    public int $age;
    public bool $active;
}

new User(['age' => '36', 'active' => 'true'])->age;      // (int) 36
new User(['age' => '36', 'active' => 'true'])->active;   // (bool) true

bool uses FILTER_VALIDATE_BOOLEAN, so 'true', '1', 'on' and 'yes' are true and everything else is false.

Dates

Date properties are parsed with static::DATE_FORMAT, which defaults to DateTimeInterface::RFC3339. Override the constant to change it:

class Event extends AutoResolvingModel
{
    protected const string DATE_FORMAT = 'Y-m-d';

    public DateTime $happenedOn;
}

new Event(['happenedOn' => '2025-07-02'])->happenedOn; // (DateTime) 2025-07-02

An integer is always read as a Unix timestamp, regardless of DATE_FORMAT:

class User extends AutoResolvingModel
{
    public DateTime $createdAt;
}

new User(['createdAt' => 1751446865])->createdAt; // (DateTime) 2025-07-02T09:01:05+00:00

If the value matches neither, an InvalidDataException is thrown rather than the property being assigned false.

Per-property formats

DATE_FORMAT sets the default for the whole class. Use DateFormat when a single property differs, which is common when one endpoint mixes styles:

use Cloudbear\ClassMapper\Attributes\DateFormat;

class Booking extends AutoResolvingModel
{
    protected const string DATE_FORMAT = 'Y-m-d';

    public DateTime $bookedOn;

    #[DateFormat('d/m/Y H:i')]
    public DateTime $startsAt;

    #[DateFormat(DATE_RFC2822)]
    public DateTimeImmutable $confirmedAt;
}

$booking = new Booking([
    'bookedOn' => '2025-07-02',
    'startsAt' => '02/07/2025 09:01',
    'confirmedAt' => 'Wed, 02 Jul 2025 09:01:05 +0000',
]);

The attribute wins over DATE_FORMAT, properties without it keep using the class default, and it combines with Key. A Unix timestamp is still read as a timestamp, so DateFormat only affects string values.

Carbon

DateTime, DateTimeImmutable and any subclass of either are all handled the same way, so Carbon works by typing the property and nothing else:

composer require nesbot/carbon
use Carbon\Carbon;
use Carbon\CarbonImmutable;

class Meeting extends AutoResolvingModel
{
    public Carbon $startsAt;
    public CarbonImmutable $endsAt;
}

$meeting = new Meeting(['startsAt' => '2025-07-02T09:01:05+00:00', 'endsAt' => 1751446865]);

$meeting->startsAt->toDateString();  // (string) '2025-07-02'
$meeting->startsAt->isWednesday();   // (bool) true
$meeting->endsAt->addDay();          // a new CarbonImmutable

DATE_FORMAT and Unix timestamps apply exactly as they do to DateTime. There is no reference to Carbon anywhere in this package, so it stays a suggestion rather than a dependency, and your own DateTime subclasses work the same way.

Note that Carbon throws its own InvalidFormatException on a value that does not match the format, where DateTime produces an InvalidDataException. Both extend InvalidArgumentException.

Enums

Backed enums are resolved through from(), so an unknown value throws a ValueError. An empty string, or any value that cannot back an enum such as a bool or an array, is treated as "no value" and resolves to null, which requires a nullable property:

enum Role: string
{
    case Admin = 'admin';
    case Member = 'member';
}

class User extends AutoResolvingModel
{
    public ?Role $role;
}

new User(['role' => 'admin'])->role; // Role::Admin
new User(['role' => ''])->role;      // null

Nested models

Any property typed as another model is mapped recursively:

class Address extends AutoResolvingModel
{
    public string $city;
}

class User extends AutoResolvingModel
{
    public Address $address;
}

new User(['address' => ['city' => 'Amsterdam']])->address->city; // (string) 'Amsterdam'

Renaming keys

Keys are matched case-sensitively. Use Key when the input name differs from your property name:

use Cloudbear\ClassMapper\Attributes\Key;

class User extends AutoResolvingModel
{
    #[Key('e_mail')]
    public string $email;
}

new User(['e_mail' => 'ada@example.com'])->email; // (string) 'ada@example.com'

The attribute replaces the property name rather than adding an alias, so ['email' => '...'] no longer maps. Key is also used when serializing, so a model round-trips to the shape it came from.

Excluding properties

Exclude opts a property out of mapping entirely. Use it for properties you populate yourself:

use Cloudbear\ClassMapper\Attributes\Exclude;

class User extends AutoResolvingModel
{
    #[Exclude]
    public string $internalId;
}

new User(['internalId' => 'nope'])->internalId; // Error: must not be accessed before initialization

Collections

Extend AutoResolvingArrayModel and name the class its items map to. The result is Countable, Iterator and ArrayAccess:

use Cloudbear\ClassMapper\Models\AutoResolvingArrayModel;

/**
 * @extends AutoResolvingArrayModel<int, User>
 */
class UserList extends AutoResolvingArrayModel
{
    protected function getItemClass(): string
    {
        return User::class;
    }
}

$users = new UserList([['name' => 'Ada'], ['name' => 'Linus']]);

count($users);   // (int) 2
$users[0]->name; // (string) 'Ada'
$users->items(); // User[]

foreach ($users as $user) {
    $user->name;
}

A plain list is treated as the collection itself. Any other array is expected to carry the collection under static::LIST_KEY, which lets the collection have properties of its own:

/**
 * @extends AutoResolvingArrayModel<int, User>
 */
class PaginatedUserList extends AutoResolvingArrayModel
{
    protected const string LIST_KEY = 'objects';

    public int $total;

    protected function getItemClass(): string
    {
        return User::class;
    }
}

$users = new PaginatedUserList(['total' => 42, 'objects' => [['name' => 'Ada']]]);

$users->total;   // (int) 42
count($users);   // (int) 1

LIST_KEY defaults to items. If the key is missing or does not hold an array, an InvalidDataException is thrown.

getItemClass() does not have to name an AutoResolvingModel. Any class is instantiated with the raw item value, so a small value object works too:

class Tag
{
    public function __construct(public int $id) {}
}

// with getItemClass() returning Tag::class
new TagList([1, 2])[0]->id; // (int) 1

HTTP responses

fromResponse() accepts a PSR-7 ResponseInterface and decodes the body based on its Content-Type:

$user = User::fromResponse($response);

A media type of application/json or anything ending in +json (such as application/vnd.api+json) is decoded as JSON; parameters like ; charset=utf-8 and casing are ignored. An empty body, or a literal null, produces a model without data. A body that decodes to anything other than an array, such as an error page or a bare scalar, throws an InvalidDataException rather than silently mapping to a blank model.

Anything else is decoded as YAML, which requires the optional symfony/yaml package:

composer require symfony/yaml

Without it, a non-JSON body throws a MissingDependencyException explaining what to install. If your API only speaks JSON, you never need the package.

Serializing

Models implement JsonSerializable. Only initialized properties are serialized, and Key names are used for the output keys:

class User extends AutoResolvingModel
{
    public string $name;
    public int $age;

    #[Key('e_mail')]
    public string $email;
}

json_encode(new User(['name' => 'Ada', 'e_mail' => 'ada@example.com']));
// {"name":"Ada","e_mail":"ada@example.com"}

A collection serializes to the bare list of its items, not to an object with a LIST_KEY.

Limitations

These are known and intentional. Open an issue if one of them blocks you.

  • Properties must be public or protected. The mapper assigns from the base class, so a private property on a model throws Error: Cannot access private property. Use protected instead.
  • DateTimeInterface properties are not resolved. The mapper needs a concrete class to construct, so type the property as DateTime, DateTimeImmutable, Carbon or CarbonImmutable instead.
  • No typed collections on plain properties. array properties are passed through as-is. To get an array of models, use an AutoResolvingArrayModel.
  • Union and intersection types are not resolved. Only single named types are inspected; anything else is passed through untouched.
  • The constructor is final. Override setUp() to massage input before mapping.
  • No validation. The mapper coerces, it does not verify. A missing key yields an uninitialized property, which throws on access rather than at construction time.
  • A collection has a single iteration cursor. Nesting two foreach loops over the same collection object does not work. Iterate items() for the inner loop.

Contributing

Merge requests are welcome. See CONTRIBUTING.md for how to get set up and what the checks are, and CODE_OF_CONDUCT.md for the ground rules.

composer install
composer check   # tests, static analysis and linting

Security

Please do not report security issues publicly. See SECURITY.md.

License

Released under the MIT License.