zero-to-prod / data-model
Transform data into a class.
Requires
- php: >=8.1.0
Requires (Dev)
- phpunit/phpunit: ^10.0
Suggests
- zero-to-prod/data-model-factory: Factoryies for a DataModel.
- zero-to-prod/data-model-helper: Helpers for a DataModel.
- zero-to-prod/transformable: Transform a class into different types.
README
Simplify deserialization for your DTOs.
Use PHP Attributes to resolve and map values to properties on a class.
Transform data into hydrated objects by describing how to resolve values.
Features
- Simple Interface: A single entry point to create class instances from associative arrays or objects.
- Recursive Instantiation: Recursively instantiate classes based on their type.
- Type Casting: Supports primitives, custom classes, enums, and more.
- Life-Cycle Hooks: Run methods before and after a value is resolved with pre and post.
- Transformations: Describe how to resolve a value before instantiation.
- Required Properties: Throw an exception when a property is not set.
- Default Values: Set a default property value.
- Nullable Missing Values: Resolve a missing value as null.
- Remapping: Re-map a key to a property of a different name.
Installation
You can install the package via Composer:
composer require zerotoprod/data-model
Examples
Additional Packages
- DataModelHelper: Helpers for a
DataModel
. - DataModelFactory: A factory helper to set the value of your
DataModel
. - Transformable: Transform a
DataModel
into different types.
Usage
Use the DataModel
trait in a class.
class User { use \Zerotoprod\DataModel\DataModel; public string $name; public int $age; }
Hydrating from Data
Use the from
method to instantiate your class, passing an associative array or object.
$user = User::from([ 'name' => 'John Doe', 'age' => '30', ]); echo $user->name; // 'John Doe' echo $user->age; // 30
Recursive Hydration
The DataModel
trait recursively instantiates classes based on their type declarations.
If a property’s type hint is a class, its value is passed to that class’s from()
method.
In this example, the address
element is automatically converted into an Address
object,
allowing direct access to its properties: $user->address->city
.
class Address { use \Zerotoprod\DataModel\DataModel; public string $street; public string $city; } class User { use \Zerotoprod\DataModel\DataModel; public string $username; public Address $address; } $user = User::from([ 'username' => 'John Doe', 'address' => [ 'street' => '123 Main St', 'city' => 'Hometown', ], ]); echo $user->address->city; // Outputs: Hometown
Transformations
The DataModel
trait provides a variety of ways to transform data before the value is assigned to a property.
The Describe
attribute provides a declarative way describe how property values are resolved.
Describe Attribute
Resolve a value by adding the Describe
attribute to a property.
The Describe
attribute can accept these arguments.
#[\Zerotoprod\DataModel\Describe([ // Re-map a key to a property of a different name 'from' => 'key', // Runs before 'cast' 'pre' => [MyClass::class, 'preHook'] // Targets the static method: `MyClass::methodName()` 'cast' => [MyClass::class, 'castMethod'], // 'cast' => 'my_func', // alternately target a function // Runs after 'cast' passing the resolved value as `$value` 'post' => [MyClass::class, 'postHook'] 'required' => true, 'default' => 'value', 'missing_as_null' => true, ])]
Order of Precedence
There is an order of precedence when resolving a value for a property.
- Property-level Cast
- Method-level Cast
- Union Types
- Class-level Casts
- Types that have a concrete static method
from()
. - Native Types
Property-Level Cast
The using the Describe
attribute directly on the property takes the highest precedence.
use Zerotoprod\DataModel\Describe; class User { use \Zerotoprod\DataModel\DataModel; #[Describe(['cast' => [self::class, 'firstName'], 'function' => 'strtoupper'])] public string $first_name; #[Describe(['cast' => 'uppercase'])] public string $last_name; #[Describe(['cast' => [self::class, 'fullName']])] public string $full_name; private static function firstName(mixed $value, array $context, ?\ReflectionAttribute $ReflectionAttribute, \ReflectionProperty $ReflectionProperty): string { return $ReflectionAttribute->getArguments()[0]['function']($value); } public static function fullName(mixed $value, array $context, ?\ReflectionAttribute $Attribute, \ReflectionProperty $Property): string { return "{$context['first_name']} {$context['last_name']}"; } } function uppercase(mixed $value, array $context){ return strtoupper($value); } $user = User::from([ 'first_name' => 'Jane', 'last_name' => 'Doe', ]); $user->first_name; // 'JANE' $user->last_name; // 'DOE' $user->full_name; // 'Jane Doe'
Life-Cycle Hooks
You can run methods before and after a value is resolved.
pre
Hook
You can use pre
to run a void
method before the value is resolved.
use Zerotoprod\DataModel\Describe; class BaseClass { use \Zerotoprod\DataModel\DataModel; #[Describe(['pre' => [self::class, 'pre'], 'message' => 'Value too large.'])] public int $int; public static function pre(mixed $value, array $context, ?\ReflectionAttribute $Attribute, \ReflectionProperty $Property): void { if ($value > 10) { throw new \RuntimeException($Attribute->getArguments()[0]['message']); } } }
post
Hook
You can use post
to run a void
method after the value is resolved.
use Zerotoprod\DataModel\Describe; class BaseClass { use \Zerotoprod\DataModel\DataModel; public const int = 'int'; #[Describe(['post' => [self::class, 'post'], 'message' => 'Value too large.'])] public int $int; public static function post(mixed $value, array $context, ?\ReflectionAttribute $Attribute, \ReflectionProperty $Property): void { if ($value > 10) { throw new \RuntimeException($value.$Attribute->getArguments()[0]['message']); } } }
Method-level Cast
Use the Describe
attribute to resolve values with class methods. Methods receive $value
and $context
as parameters.
use Zerotoprod\DataModel\Describe; class User { use \Zerotoprod\DataModel\DataModel; public string $first_name; public string $last_name; public string $fullName; #[Describe('last_name')] public function lastName(mixed $value, array $context, ?\ReflectionAttribute $Attribute, \ReflectionProperty $Property): string { return strtoupper($value); } #[Describe('fullName')] public function fullName(mixed $value, array $context, ?\ReflectionAttribute $Attribute, \ReflectionProperty $Property): string { return "{$context['first_name']} {$context['last_name']}"; } } $user = User::from([ 'first_name' => 'Jane', 'last_name' => 'Doe', ]); $user->first_name; // 'Jane' $user->last_name; // 'DOE' $user->fullName; // 'Jane Doe'
Union Types
A value passed to property with a union type is directly assigned to the property. If you wish to resolve the value in a specific way, use a class method.
Class-Level Cast
You can define how to resolve different types at the class level.
use Zerotoprod\DataModel\Describe; function uppercase(mixed $value, array $context){ return strtoupper($value); } #[Describe([ 'cast' => [ 'string' => 'uppercase', \DateTimeImmutable::class => [self::class, 'toDateTimeImmutable'], ] ])] class User { use \Zerotoprod\DataModel\DataModel; public string $first_name; public DateTimeImmutable $registered; public static function toDateTimeImmutable(mixed $value, array $context): DateTimeImmutable { return new DateTimeImmutable($value); } } $user = User::from([ 'first_name' => 'Jane', 'registered' => '2015-10-04 17:24:43.000000', ]); $user->first_name; // 'JANE' $user->registered->format('l'); // 'Sunday'
Required Properties
Enforce that certain properties are required using the Describe attribute:
use Zerotoprod\DataModel\Describe; class User { use \Zerotoprod\DataModel\DataModel; #[Describe(['required' => true])] public string $username; public string $email; } $user = User::from(['email' => 'john@example.com']); // Throws PropertyRequiredException exception: Property: username is required
Default Values
You can set a default value for a property like this:
use Zerotoprod\DataModel\Describe; class User { use \Zerotoprod\DataModel\DataModel; #[Describe(['default' => 'N/A'])] public string $username; } $user = User::from(); echo $user->username // 'N/A'
Nullable Missing Values
Set missing values to null by setting missing_as_null => true
. This can be placed at the class or property level.
This prevents an Error when attempting to assess a property that has not been initialized.
Error: Typed property User::$age must not be accessed before initialization
use Zerotoprod\DataModel\Describe; #[Describe(['missing_as_null' => true])] class User { use \Zerotoprod\DataModel\DataModel; public ?string $name; #[Describe(['missing_as_null' => true])] public ?int $age; } $User = User::from(); echo $User->name; // null echo $User->age; // null
Re-Mapping
You can map a key to a property of a different name like this:
use Zerotoprod\DataModel\Describe; class User { use \Zerotoprod\DataModel\DataModel; #[Describe(['from' => 'firstName'])] public string $first_name; } $User = User::from([ 'firstName' => 'John', ]); echo $User->first_name; // John
Examples
Array of DataModels
This examples uses the DataModelHelper.
composer require zero-to-prod/data-model-helper
use Zerotoprod\DataModel\Describe; class User { use \Zerotoprod\DataModel\DataModel; use \Zerotoprod\DataModelHelper\DataModelHelper; /** @var Alias[] $Aliases */ #[Describe([ 'cast' => [self::class, 'mapOf'], // Use the mapOf helper method 'type' => Alias::class, // Target type for each item ])] public array $Aliases; } class Alias { use \Zerotoprod\DataModel\DataModel; public string $name; } $User = User::from([ 'Aliases' => [ ['name' => 'John Doe'], ['name' => 'John Smith'], ] ]); echo $User->Aliases[0]->name; // Outputs: John Doe echo $User->Aliases[1]->name; // Outputs: John Smith
Collection of DataModels
This examples uses the DataModelHelper and Laravel Collections.
composer require zero-to-prod/data-model-helper composer require illuminate/collections
use Zerotoprod\DataModel\Describe; class User { use \Zerotoprod\DataModel\DataModel; use \Zerotoprod\DataModelHelper\DataModelHelper; /** @var Collection<int, Alias> $Aliases */ #[Describe([ 'cast' => [self::class, 'mapOf'], 'type' => Alias::class, ])] public \Illuminate\Support\Collection $Aliases; } class Alias { use \Zerotoprod\DataModel\DataModel; public string $name; } $User = User::from([ 'Aliases' => [ ['name' => 'John Doe'], ['name' => 'John Smith'], ] ]); echo $User->Aliases->first()->name; // Outputs: John Doe
Laravel Validation
By leveraging the pre
life-cycle hook, you run a validator before a value is resolved.
use Illuminate\Support\Facades\Validator; use Zerotoprod\DataModel\Describe; readonly class FullName { use \Zerotoprod\DataModel\DataModel; #[Describe([ 'pre' => [self::class, 'validate'], 'rule' => 'min:2' ])] public string $first_name; public static function validate(mixed $value, array $context, ?\ReflectionAttribute $Attribute): void { $validator = Validator::make(['value' => $value], ['value' => $Attribute?->getArguments()[0]['rule']]); if ($validator->fails()) { throw new \RuntimeException($validator->errors()->toJson()); } } }
Testing
./vendor/bin/phpunit