shureban / laravel-object-mapper
Laravel SDK mapping data in object
Requires
- php: ^8.1
README
Maps raw data — JSON string, array or FormRequest — into typed PHP objects (DTOs) using reflection.
Installation
Require this package with composer using the following command:
composer require shureban/laravel-object-mapper
The service provider is registered automatically via Laravel package auto-discovery.
If auto-discovery is disabled, add the provider to the providers array in config/app.php:
Shureban\LaravelObjectMapper\ObjectMapperServiceProvider::class,
The package works out of the box. Publish the config only when you want to change type mappings:
php artisan vendor:publish --provider="Shureban\LaravelObjectMapper\ObjectMapperServiceProvider"
How to use
You have 3 options to use ObjectMapper
Inheritance
Your mapped object (dto) must inheritance from \Shureban\LaravelObjectMapper\MappableObject
class User extends MappableObject { public int $id; } $user1 = (new User())->mapFromJson('{"id": 10}'); $user2 = (new User())->mapFromArray(['id' => 10]); $user3 = (new User())->mapFromRequest($formRequest);
Using trait
Your mapped object (dto) must use \Shureban\LaravelObjectMapper\MappableTrait
class User { use MappableTrait; public int $id; } $user1 = (new User())->mapFromJson('{"id": 10}'); $user2 = (new User())->mapFromArray(['id' => 10]); $user3 = (new User())->mapFromRequest($formRequest);
Delegate mapping to ObjectMapper
class User { public int $id; } $user1 = (new ObjectMapper(new User()))->mapFromJson('{"id": 10}'); $user2 = (new ObjectMapper(new User()))->mapFromArray(['id' => 10]); $user3 = (new ObjectMapper(new User()))->mapFromRequest($formRequest);
There is also a universal map() method that detects the data format itself:
a string is treated as JSON, an array as an array, a FormRequest as a request.
mapFromRequest($request, $onlyValidated = true) maps $request->validated() by default;
pass false to map $request->all() instead.
Readonly DTOs and static constructors
Passing a class name (instead of an instance) builds the object through its constructor — every parameter is resolved from the data with the same naming rules. This is how you map modern readonly DTOs:
class User { use MappableTrait; public function __construct( public readonly int $id, #[MapFrom('full_name')] public readonly string $name, public readonly string $role = 'user', ) {} } $user = User::from('{"id": 1, "full_name": "John"}'); // single instance $users = User::fromMany('[{"id":1,"full_name":"A"}, {"id":2,"full_name":"B"}]'); // User[] // or without the trait: $user = (new ObjectMapper(User::class))->mapFromJson($json); $users = ObjectMapper::mapArrayOf(User::class, $json);
Data missing for a required parameter without a default raises MissingConstructorValueException
listing all missing parameters at once. Note: readonly properties that are NOT promoted
constructor parameters still cannot be mapped and are skipped.
Attributes
PHP attributes are the preferred way to configure mapping. phpDoc keeps working — resolution priority is attribute → phpDoc → native type, so existing DTOs stay untouched.
| Attribute | Target | Effect |
|---|---|---|
#[MapFrom('user_id')] |
property, ctor param | reads the value from another data key; dot notation digs into nested arrays ('data.attributes.name') |
#[Ignore] |
property | the property is never mapped (and never serialized) |
#[CastWith(MyType::class)] |
property, ctor param | converts the value with your own Types\Type subclass |
#[ArrayOf(Address::class)] |
property, ctor param | maps a list into typed items; depth: 2 for nested lists; works with simple types too (#[ArrayOf('int')]) |
#[DateFormat('d.m.Y')] |
property, ctor param | parses Carbon/DateTime values with createFromFormat; mismatch throws InvalidDateTimeValueException |
#[EnumFallback(Status::Unknown)] |
property, ctor param | unknown enum values resolve to the fallback case instead of throwing |
#[FindModel] |
property, ctor param | opts an Eloquent-typed property into the Model::find() lookup (see below) |
class Order { #[MapFrom('data.attributes.number')] public string $number; #[ArrayOf(OrderLine::class)] public array $lines = []; #[DateFormat('Y-m-d H:i:s')] public Carbon $paidAt; #[EnumFallback(OrderStatus::Unknown)] public OrderStatus $status; }
Strict mode
By default the mapper is forgiving: unknown keys are ignored and scalars are coerced PHP-style.
strict() turns on validation and reports all problems at once:
try { $user = (new ObjectMapper(new User()))->strict()->mapFromArray($request->all()); } catch (MappingFailedException $e) { return response()->json(['errors' => array_map( fn(array $list) => array_map(fn($err) => $err->getMessage(), $list), $e->getErrors() // ['propertyOrKey' => ObjectMapperException[], ...] )], 422); }
Strict mode collects:
UnknownDataKeyException— a data key matches no property;LossyConversionException— a value would be silently mangled ('abc'intoint,'yes'intobool);MissingRequiredValueException— a non-nullable property received no value and has no default;- plus every regular conversion error, instead of failing on the first one.
Serialization (toArray / toJson)
The reverse direction uses the same naming rules (MapFrom keys, phpDoc renames):
$user->toArray(); // ['user_id' => 1, 'full_name' => 'John', ...] $user->toJson(); // without the trait: (new \Shureban\LaravelObjectMapper\Serializer())->toArray($user);
Enums serialize to their value (->name for pure enums), dates honor #[DateFormat]
(ISO 8601 otherwise), Eloquent models collapse to their primary key, nested objects and
collections are serialized recursively. #[Ignore]d and uninitialized properties are skipped.
Set config serialize_snake_case => true to snake_case all unmapped property names.
Controller injection
A DTO implementing the MapsFromRequest marker interface resolves automatically from the
current request when type-hinted in a controller:
class CreateUserDto implements MapsFromRequest { public string $email; public string $name; } class UserController { public function store(CreateUserDto $dto) // already mapped from request() { // ... } }
FormRequest bound to the container maps from validated(); a plain request maps from all().
Mappable cases
Below you will see cases which you can use for mapping data into your object
Simple types
mixedstringbool,booleanint,integerdouble,floatarrayobject
Box types
Carbon(bothCarbon\CarbonandIlluminate\Support\Carbon)DateTimeCollection
Date properties accept a date string, a unix timestamp (int), or a ready DateTimeInterface instance.
Custom types
CustomClassEnum(backed enums only)Eloquent
Array of types
That type of mapping may be realized only via phpDoc notation
int[]int[][]DateTime[]CustomClass[]
Not supported
- Union (
int|string) and intersection typed properties —UnknownPropertyTypeExceptionis thrown when a value for such a property arrives (other properties of the object still map fine). staticproperties andstatic/non-public setters are ignored.
Matching rules
For a property named fooBar the mapper looks up the data key in this order:
fooBar— exact property name (or the name from the@var Type $namephpDoc, see below);foo_bar— snake_case fallback, enabled by theobject_mapper.snake_case_to_camelconfig option (defaulttrue).
You can map a data key with a completely different name onto a property using phpDoc:
class User { /** @var int $user_id */ public int $id; // takes the value of $data['user_id'] }
Null values
null values in the data are always skipped: the property keeps its default value
(or stays uninitialized when it has none). The same happens when the data key is missing.
Readonly properties
readonly properties are always skipped. Note: if a skipped typed property has no default
value, it stays uninitialized — reading it throws the standard PHP Error
("must not be accessed before initialization").
Special cases
Constructor
If the property type is a custom class and the value is not an array/object, the mapper builds
the instance by passing the value to the constructor. The constructor must be able to accept exactly
one argument: at least one parameter overall and at most one required parameter. Otherwise
WrongConstructorParametersNumberException is thrown.
Correct case:
class User { public int $id; public function __construct(int $id) { $this->id = $id; } }
Wrong case (two required parameters):
class User { public int $id; public string $name; public function __construct(int $id, string $name) { $this->id = $id; $this->name = $name; } }
If the value is an array/object, the class must be instantiable without arguments
(no constructor, or no required parameters) — the mapper creates the instance and maps the
array into it recursively. A class with required constructor parameters receiving an array
value throws WrongConstructorParametersNumberException.
If the value already is an instance of the target class, it is assigned as-is.
PhpDoc
PhpDoc type hinting has much more priority than main type.
class User { /** * @var int */ public int $id; /** * @var DateTime */ public $dateOfBirthday; /** * @var Address[] */ public array $addresses; }
Setters
If you want to realize your own logic for setting value, you may place setter method in your mapped object.
This setter should start from set word and been in camelCase notation. Only public setters are used —
private/protected methods are ignored and the value is assigned directly.
class User { public string $id; public DateTime $dateOfBirthday; public function setId(int $id, mixed $rawData = null): void { $this->id = Hash::make($id); } public function setDateOfBirthday(string $dateOfBirthday, mixed $rawData = null): void { $this->dateOfBirthday = new DateTime($dateOfBirthday); } } $user = (new ObjectMapper(new User()))->mapFromArray(['id' => 10, 'dateOfBirthday' => '1991-01-01']); echo $user->id; // $2y$10$XqHrk0oXa7.9AihthdVxW.dd637zj9EhlTJX0eUEKiV61dbs7a7ZO echo $user->dateOfBirthday->format('Y'); // 1991
Some words about second parameter $rawData. Value of this parameter depends on method selected for mapping:
mapFromJson— $rawData is the raw JSON string (not decoded);mapFromArray— $rawData is the source array;mapFromRequest— $rawData is theFormRequestobject.
Type-hint it accordingly (or use mixed) — a array $rawData hint would fail for the JSON and request variants.
Eloquent models
A property typed as an Eloquent model is resolved via Model::find($value) — but only when
you opt in explicitly (new in v2, because mapping raw request data into a DB lookup is a
surprise nobody should get implicitly):
class Order { #[FindModel] /** @var User $user_id */ public User $user; // $data['user_id'] = 10 => User::find(10) }
Without the attribute, mapping such a property throws ImplicitModelLookupException.
To restore the v1 behavior globally set object_mapper.implicit_model_lookup => true.
Be aware:
- Mapping executes a database query. If the data comes from an HTTP request, the client controls the looked-up primary key — apply authorization checks yourself.
- When the model is not found, the property is left untouched (no exception).
- A non-
int|stringvalue throwsInvalidModelKeyException.
Error handling
All mapping errors throw subclasses of Shureban\LaravelObjectMapper\Exceptions\ObjectMapperException,
so a single catch covers everything:
use Shureban\LaravelObjectMapper\Exceptions\ObjectMapperException; try { $user = (new User())->mapFromJson($json); } catch (ObjectMapperException $e) { // react to malformed input }
| Exception | Thrown when |
|---|---|
ParseJsonException |
the JSON string is syntactically invalid |
InvalidJsonStructureException |
the JSON is valid but decodes to a scalar or null (e.g. 'null', '123', '"str"') |
UnknownDataFormatException |
map() receives an unsupported data format |
UnknownPropertyTypeException |
a property type cannot be resolved (unknown class, union/intersection type) |
WrongConstructorParametersNumberException |
a custom class constructor cannot accept the given value |
InvalidDateTimeValueException |
a Carbon/DateTime property receives an empty, non-string or unparseable value |
InvalidEnumValueException |
an enum property receives an unknown backing value, or the enum is not backed |
InvalidModelKeyException |
an Eloquent-typed property receives a non-`int |
InvalidValueTypeException |
a string/int/float property receives an array or object (also: abstract class targets) |
ImplicitModelLookupException |
an Eloquent-typed property is mapped without #[FindModel] or the config opt-in |
MissingConstructorValueException |
constructor mapping cannot resolve required parameters (lists all of them) |
MappingFailedException |
strict mode: aggregate of all collected errors (getErrors()) |
UnknownDataKeyException |
strict mode: a data key matches no property |
LossyConversionException |
strict mode: a scalar coercion would lose information |
MissingRequiredValueException |
strict mode: a required property received no value |
Config rewriting
In object_mapper.php config file have been presented all mappable types classes. You have opportunity to rewrite
mapping flow or realize you own one.
If you need to create your own type mapping, follow this way:
- create class inherited from
\Shureban\LaravelObjectMapper\Types\Type - place your type into the config file in the
types.boxarray (key — class name or its short alias)
Security notes
- Every public property is mapped. The mapper fills any declared public property whose name matches
a data key — there is no allowlist like Eloquent's
$fillable. Keep DTOs that receive raw request data free of internal fields, or make such fields private/readonly. - Eloquent-typed properties execute a DB lookup with a client-supplied key (see above).
Config reference
| Key | Default | Meaning |
|---|---|---|
snake_case_to_camel |
true |
foo_bar data key feeds the fooBar property |
implicit_model_lookup |
false |
true restores v1 implicit Model::find() (no #[FindModel] needed) |
assign_explicit_null |
false |
true assigns explicit nulls to nullable properties instead of skipping |
serialize_snake_case |
false |
true snake_cases unmapped property names in toArray() |
types.* |
see file | type registry: override or register your own Type classes |
Migration from 1.x
- Eloquent-typed properties now require the
#[FindModel]attribute — or setobject_mapper.implicit_model_lookup => truefor the old behavior. Everything else is backward compatible: DTOs based on phpDoc/native types map exactly as before. - Malformed input that used to crash with raw
TypeError/ValueErrornow throwsObjectMapperExceptionsubclasses — if you caught those raw errors, catchObjectMapperExceptioninstead. - The internal helper
Attributes\SetterNamemoved toSupport\SetterName.
Testing
composer install ./vendor/bin/phpunit
The suite is standalone — no Laravel application required.
See docs/COVERAGE_BADGE.md for wiring a test-coverage badge into this README.