cloudbear / class-mapper
Map any array, JSON or YAML structure onto typed PHP classes using reflection and attributes.
Requires
- php: ^8.4
- psr/http-message: ^2.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.75
- nesbot/carbon: ^3.13
- nyholm/psr7: ^1.8
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^12.0
- symfony/yaml: ^8.0
Suggests
- nesbot/carbon: ^2.0|^3.0 to type date properties as Carbon or CarbonImmutable instead of DateTime.
- symfony/yaml: ^7.0|^8.0 to hydrate models from YAML response bodies via fromResponse().
README
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/yamlis optional, and only needed to read YAML response bodiesnesbot/carbonis optional, and only needed to type date properties asCarbonorCarbonImmutable
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 type | Behavior |
|---|---|
int, float, string, bool | Scalars are cast to the declared type |
array | Passed through untouched |
DateTime, DateTimeImmutable | Parsed from DateFormat or static::DATE_FORMAT, or a timestamp |
Carbon, CarbonImmutable | Same, if nesbot/carbon is installed |
stdClass | Arrays are converted recursively, for structures you cannot type |
BackedEnum | Resolved with Enum::from() |
Another AutoResolvingModel | Mapped recursively |
AutoResolvingArrayModel | Mapped recursively as a collection |
| Untyped property | Passed 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
privateproperty on a model throwsError: Cannot access private property. Useprotectedinstead. DateTimeInterfaceproperties are not resolved. The mapper needs a concrete class to construct, so type the property asDateTime,DateTimeImmutable,CarbonorCarbonImmutableinstead.- No typed collections on plain properties.
arrayproperties are passed through as-is. To get an array of models, use anAutoResolvingArrayModel. - Union and intersection types are not resolved. Only single named types are inspected; anything else is passed through untouched.
- The constructor is
final. OverridesetUp()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
foreachloops over the same collection object does not work. Iterateitems()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.