zero-to-prod / service-model
A modern approach to extensible, typesafe DTOs with factory support.
Fund package maintenance!
zero-to-prod
Installs: 5 871
Dependents: 0
Suggesters: 0
Security: 0
Stars: 1
Watchers: 1
Forks: 0
Open Issues: 0
Requires
- php: ^8.1
Requires (Dev)
- pestphp/pest: ^2.19
- pestphp/pest-plugin-type-coverage: ^2.8
- symfony/var-dumper: *
- dev-main
- v2.9.9
- v2.9.8
- v2.9.7
- v2.9.6
- v2.9.5
- v2.9.4
- v2.9.3
- v2.9.2
- v2.9.1
- v2.9.0
- v2.8.0
- v2.7.0
- v2.6.0
- v2.5.0
- v2.4.0
- v2.3.6
- v2.3.5
- v2.3.4
- v2.3.3
- v2.3.2
- v2.3.1
- v2.3.0
- v2.2.0
- v2.1.1
- v2.1.0
- v2.0.0
- v1.7.5
- v1.7.4
- v1.7.3
- v1.7.2
- v1.7.1
- v1.7.0
- v1.6.3
- v1.6.2
- v1.6.1
- v1.6.0
- v1.5.2
- v1.5.1
- v1.5.0
- v1.4.2
- v1.4.1
- v1.4.0
- v1.3.1
- v1.3.0
- v1.2.0
- v1.1.1
- v1.1.0
- v1.0.10
- v1.0.9
- v1.0.8
- v1.0.7
- v1.0.6
- v1.0.5
- v1.0.4
- v1.0.3
- v1.0.2
- v1.0.1
- v1.0.0
- dev-zero-to-prod-patch-1
- dev-feat/v3
This package is auto-updated.
Last update: 2024-10-25 09:05:53 UTC
README
A modern approach to extensible, typesafe Data Transfer Objects (DTOs) with factory support.
This zero-dependency package provides a way to serialize data into typesafe DTOs.
In the Extract Transform Load (ETL) process, this package assist in the Transformation of data into a model.
Features
- Simple: Use the
ServiceModel
trait to automatically map your data. - Custom Type Casting: Define your own value casters for infinite control.
- Plugin Architecture: Build your own plugins with PHP Attributes.
- Nested Relationships: Easily define one-to-many relationships with native PHP attributes.
- Mapping: Rename and map your data how you please.
- Validation: Control when required properties are validated.
- Factory Support: Use the
factory()
method to make a DTO with default values. - Native Object Support: Native object support for Enums and Classes, with no extra steps.
- Resource Support: Transform you ServiceModel to an associative array with snake_case keys or implement your own.
- Fast: Designed with performance in mind.
Getting Started
If upgrading from v1, see the upgrade guide.
Install the service-model
package with composer.
composer require zero-to-prod/service-model
Use the ServiceModel
trait in your model.
Add properties to your model that match the keys of your data.
use Zerotoprod\ServiceModel\ServiceModel; class Order { use ServiceModel; public readonly int $id; }
Pass an associative array or json string to the make()
method of your model.
$Order = Order::make(['id' => 1]); $Order->id; // 1 $Order = Order::make('{"id":1}'); $Order->id; // 1
Use the factory()
method to make a new model with default values.
See the Factories section for more information.
$order = Order::factory()->make(); $order->id; // 1
Usage
Create a ServiceModel
by passing an associative array or json string to the make()
method of
your model that uses the ServiceModel
trait.
$Order = Order::make(['id' => 1]); $Order->id; // 1 $Order = Order::make('{"id":1}'); $Order->id; // 1
Setting Up Your Model
Define properties in your class to match the keys of your data.
The ServiceModel
trait will automatically match the keys, detect the type, and cast the value.
use Zerotoprod\ServiceModel\Attributes\Cast; use Zerotoprod\ServiceModel\Attributes\CastUsing; use Zerotoprod\ServiceModel\Attributes\MapFrom; use Zerotoprod\ServiceModel\Attributes\ArrayOf; use Zerotoprod\ServiceModel\ServiceModel; class Order { use ServiceModel; /** * Automatically cast OrderDetails to a model by using * the ServiceModel trait in OrderDetails. */ public readonly OrderDetails $details; /** * Define you own value caster. */ #[Cast(ToJson::class)] public readonly string $metadata; /** * If you have a class the can cast the value, * you can use the CastUsing attribute to * define the method to pass values to. */ #[CastUsing('set')] public readonly TimeClass $time; /** * Rename your values. */ #[MapFrom('AcknowledgedAt')] public readonly string $acknowledged_at; /** * Remap your values. */ #[MapFrom('vendor_details.serial_number')] public readonly string $serial_number; /** * Use a value-backed enum to automatically cast the value. */ public readonly Status $status; /** * Casts to an array of enums. * @var Tag[] $tags */ #[ArrayOf(Tag::class)] public readonly array $tags; /** * Unpacks the array into the constructor of the type-hinted class. * NOTE: PickupInfo does not use the ServiceModel trait. */ public readonly PickupInfo $PickupInfo; /** * Casts to an array of PickupInfo. * NOTE: PickupInfo does not use the ServiceModel trait. * * @var PickupInfo[] $previous_pickups */ #[ArrayOf(PickupInfo::class)] public readonly array $previous_pickups; /** * Because Carbon uses the static method `parse`, this will * cast the value to a Carbon instance for free. */ public readonly Carbon $created_at; /** * Creates an array of Items. * @var Item[] $items */ #[ArrayOf(Item::class)] public readonly array $items; /** * Use a custom cast. * @var Collection<int, View> $views */ #[CollectionOf(View::class)] public readonly Collection $views; }
Pass an associative array or json string to the make()
method of your model.
$order = Order::make([ 'details' => ['id' => 1, 'name' => 'Order 1'], 'metadata' => ['id' => 1, 'name' => 'Order 1'], 'time' => '2021-01-01 00:00:00', 'vendor_details' => ['serial_number' => '123456789'], 'status' => 'pending', 'tags' => ['important', 'rush'], 'PickupInfo' => ['location' => 'Location 1', 'time' => '2021-01-01 00:00:00'], 'created_at' => '2021-01-01 00:00:00', 'items' => [ ['id' => 1,'name' => 'Item 1'], ['id' => 2,'name' => 'Item 2']], 'views' => [ ['id' => 1,'name' => 'View 1'], ['id' => 2,'name' => 'View 2']], ]);
Accessing Type Safe Properties
Access your data with arrow syntax.
// Nested Models $details = $order->details; // Order::class $details = $order->details->name; // 'Order 1' // Custom Casters $metadata = $order->metadata; // '{"id":1,"name":"Order 1"}' // CastUsing $time = $order->time; // TimeClass::class $time = $order->time->value; // '2021-01-01 00:00:00' // Remapped Properties $serial_number = $order->serial_number; // '123456789' // Enums $status = $order->status; // Status::pending $status = $order->status->value; // 'pending' // Array of Enums $tags = $order->tags[0]; // Tag::important $tags = $order->tags[0]->value; // 'important' // Value Casting $created_at = $order->created_at; // Carbon::class $created_at = $order->created_at->toDateTimeString(); // '2021-01-01 00:00:00' // One-to-many array Casting $item_id = $order->items[0]; // Item::class $item_id = $order->items[0]->id; // 1 // One-to-many custom Casting $view_name = $order->views->first(); // View::class $view_name = $order->views->first()->name; // 'View 1'
Factory Support
Use the factory()
method to make a new model instance with default values.
See the Factories section for how to set up and use factories.
$order = Order::factory()->make(); $order->status; // Status::pending
Basic Implementation
Define properties in your class to match the keys of your data.
The ServiceModel
trait will automatically match the keys, detect the type, and cast the value.
use Zerotoprod\ServiceModel\ServiceModel; class Order { use ServiceModel; /** * Using the `ServiceModel` trait in the child class (OrderDetails) * class will automatically instantiate new class. */ public readonly OrderDetails $details; }
IMPORTANT: Use the
ServiceModel
trait in the child classes.
use Zerotoprod\ServiceModel\ServiceModel; class OrderDetails { use ServiceModel; public readonly int $id; public readonly string $name; }
NOTICE: The
details
key matches the$details
property inOrder
.
$order = Order::make([ 'details' => [ 'id' => 1, 'name' => 'Order 1' ], ]); // This is also equivalent. $order = Order::make([ 'details' => OrderDetail::make([ 'id' => 1, 'name' => 'Order 1' ]), ]); $order->details->id; // 1 $order->details->name; // 'Order 1'
Native Object Support
This package provides native support for the following objects:
Enums
Use a value backed enum to automatically cast the value.
use Zerotoprod\ServiceModel\ServiceModel; class Order { use ServiceModel; /** * Casts to a Status enum */ public readonly Status $status; /** * Casts to an array of Status enum. * @var Status[] $statuses */ #[CastToArray(Status::class)] public readonly array $statuses; }
enum Status: string { case pending = 'pending'; case completed = 'completed'; }
$order = Order::make([ 'status' => 'pending', 'statuses' => ['pending', 'completed'], ]); $order->status; // Status::pending $order->status->value; // 'pending' $order->statuses[0]; // Status::pending $order->statuses[1]->value; // completed
Classes
Sometimes you may want to cast to a class you cannot use the ServiceModel
trait in.
For a simple cast, simply typehint with the property with the class. This will automatically unpack the array into the constructor of the class.
For a one-to-many
cast, use the CastToArray
attribute to cast an array of classes.
Simple Class Casting
Simply typehint with the property with the class you want to cast to.
use Zerotoprod\ServiceModel\ServiceModel; class Order { use ServiceModel; /** * Unpacks the array into the constructor of the type-hinted class. */ public readonly PickupInfo $pickups; }
class PickupInfo { public function __construct(public readonly string $location, public readonly string $time) { } }
$order = Order::make([ 'pickups' => [ 'location' => 'Location 1', 'time' => '2021-01-01 00:00:00', ] ]); $order->pickups; // PickupInfo::class $order->pickups->location; // Location 1 $order->pickups->time; // 2021-01-01 00:00:00
Using a Class Method for Parsing
In some cases, you might have a class with a method that accepts a value and returns an instance of the class itself.
The ServiceModel
package allows you to leverage such methods for parsing values.
You can specify the method to be used for parsing by applying the CastUsing
attribute to the property in your model.
The attribute takes the name of the method as its argument.
Here's an example:
use Zerotoprod\ServiceModel\Attributes\CastUsing; use Zerotoprod\ServiceModel\ServiceModel; class MyModel { use ServiceModel; // The 'set' method of the TimeClass will be used for parsing the value #[CastUsing('set')] public readonly TimeClass $time; }
In the TimeClass
below, the set
method accepts a value, assigns it to the value
property of a new TimeClass
instance, and then returns the instance:
class TimeClass { public string $value; public static function set($value): self { $self = new self(); $self->value = $value; return $self; } }
In this exampleWhen the ServiceModel
trait processes the time
property of the MyModel
, it will invoke
the set
method of the TimeClass
, passing the value to be parsed. The method will return a TimeClass
instance,
which will then be assigned to the time
property.
One-to-many Class Casting
Sometimes you may want to cast an array of classes you cannot use the ServiceModel
trait in.
Use the ArrayOf
attribute to cast an array of classes.
use Zerotoprod\ServiceModel\Attributes\ArrayOf; use Zerotoprod\ServiceModel\ServiceModel; class Order { use ServiceModel; /** * Casts to an array of PickupInfo. * @var PickupInfo[] $pickups */ #[ArrayOf(PickupInfo::class)] public readonly array $pickups; }
class PickupInfo { public function __construct(public readonly string $location, public readonly string $time) { } }
$order = Order::make([ 'pickups' => [ [ 'location' => 'Location 1', 'time' => '2021-01-01 00:00:00', ], [ 'location' => 'Location 2', 'time' => '2021-01-01 00:00:00', ], ], ]); $order->pickups[0]->location; // Location 1 $order->pickups[0]->time; // 2021-01-01 00:00:00
Value Casting
Implement the CanCast
interface to make a custom type.
use Zerotoprod\ServiceModel\ServiceModel; class Order { use ServiceModel; /** * Transforms the value to a custom instance. */ #[Cast(ToCustomTime::class)] public readonly ToCustomTime $ordered_at; /** * Because Carbon uses the static method `parse`, this will * cast the value to a Carbon instance for free. */ public readonly Carbon $created_at;
use Zerotoprod\ServiceModel\Contracts\CanParse; class ToCustomTime implements CanParse { public function parse(array $values): Carbon { return ToCustomTime::parse($values[0]); } }
$order = Order::make([ 'ordered_at' => '2021-01-01 00:00:00', 'created_at' => '2021-01-01 00:00:00', ]); $order->ordered_at; // Carbon::class $order->ordered_at->toDateTimeString(); // '2021-01-01 00:00:00' $order->created_at; // Carbon::class $order->created_at->toDateTimeString(); // '2021-01-01 00:00:00'
One-to-many
Casting
Use the CastToArray
attribute to cast an array of classes.
use Zerotoprod\ServiceModel\ServiceModel; use Illuminate\Support\Collection; class Order { use ServiceModel; /** * Casts to a Collection containing View classes. * @var Collection<int, View> $views */ #[CollectionOf(View::class)] public Collection $views; }
IMPORTANT: The class name passed in the Attribute (
View::class
) is passed in the constructor of theCollectionOf
class.
IMPORTANT: Don't forget to add
#[Attribute]
to the top of your class.
use Zerotoprod\ServiceModel\Contracts\CanParse; #[Attribute] class CollectionOf implements CanParse { public function __construct(public readonly string $class) { } public function parse(array $values): Collection { return collect($values)->map(fn(array $item) => $this->class::make($item)); } }
$order = Order::make([ 'views' => [ [ 'id' => 1, 'name' => 'View 1' ], [ 'id' => 2, 'name' => 'View 2' ] ], ]); $order->views->first(); // View::class $order->views->first()->name; // 'View 1'
Plugins
You can define your own attributes to extend the functionality of the ServiceModel
trait.
The property values are passed to the parse()
method of the attribute.
The Attribute values are passed to the constructor of the attribute.
use Zerotoprod\ServiceModel\ServiceModel; class MyModel { use ServiceModel; #[CustomCaster(1)] public readonly int $add_one; #[CustomValueCaster(1, 2)] public readonly int $add_two; }
$MyModel = MyModel::make(['add_one' => 1, 'add_two' => 1]); $MyModel->add_one; // 2 $MyModel->add_two; // 4
use Attribute; use Zerotoprod\ServiceModel\Contracts\CanParse; #[Attribute] class CustomCaster implements CanParse { public function __construct(public readonly int $attribute_constructor_value) { } public function parse(array $values): int { return $values[0] + $this->attribute_constructor_value; } }
use Attribute; use Zerotoprod\ServiceModel\Contracts\CanParse; #[Attribute] class CustomValueCaster implements CanParse { public function __construct(public readonly int $value_1, public readonly int $value_2) { } public function parse(array $values): int { return $values[0] + $this->value_1 + $this->value_2; } }
Factories
Factories provide a convenient way to generate DTOs with default values.
- Use the
ServiceModel
and theHasFactory
trait in your model. - Create a class that
extends
theFactory
class for your factory. - Set the
public string $model =
property in your factory pointing to your model. - Set the
public static string $factory =
property in your model pointing to your factory. - Return your default values as an array in the
definition()
method in your factory.
use Zerotoprod\ServiceModel\HasFactory; use Zerotoprod\ServiceModel\ServiceModel; class Order { use ServiceModel; use HasFactory; public static string $factory = OrderFactory::class; public OrderDetails $details; public Status $status; }
use Zerotoprod\ServiceModel\Factory; class OrderFactory extends Factory { public string $model = Order::class; public function definition(): array { return [ 'details' => ['id' => 1, 'name' => 'Order 1'], 'status' => 'pending', ]; } public function setStatus(Status $status): self { return $this->state(fn() => [ 'status' => $status->value ]); } }
$order = Order::factory()->make(); $order->status; // Status::pending $order->details->name; // 'Order 1' $order = Order::factory()->setStatus(Status::completed)->make(); $order->status; // Status::completed
Extending the ServiceModel
Trait
You can extend the ServiceModel
trait and add your own functionality to your models.
<?php namespace App\Channels\Amazon\ServiceModels\Support; use Illuminate\Support\Collection; trait ServiceModel { use \Zerotoprod\ServiceModel\ServiceModel; public function toArray(): array { return $this->collect()->toArray(); } public function toJson(): string { return $this->collect()->toJson(); } public function collect(): Collection { return collect($this); } }
This allows you to access custom methods on the model.
Order::make([...])->toJson();
Mapping
The #[MapFrom] attribute allows you to associate a property from a DTO (Data Transfer Object) with another property that possesses a different name.
This association is achievable using either a "dot" notation property name or an index.
Renaming
The following example shows how to rename a property using the #[MapFrom]
attribute.
use Zerotoprod\ServiceModel\Attributes\MapFrom; use Zerotoprod\ServiceModel\ServiceModel; class MyModel { use ServiceModel; #[MapFrom('MyValue')] public readonly string $my_value; }
$MyModel = MyModel::make(['MyValue' => 'value']); $MyModel->my_value; // 'value'
Mapping Nested Properties
The following example shows how to map a nested property using the #[MapFrom]
attribute.
use Zerotoprod\ServiceModel\Attributes\MapFrom; use Zerotoprod\ServiceModel\ServiceModel; class MyModel { use ServiceModel; #[MapFrom('value.nested')] public readonly string $value; }
$MyModel = MyModel::make(['value' => ['nested' => 'value']]); $MyModel->value; // 'value'
Validation
Sometimes you want to know if required properties are initialized on your model. Validating your model will
throw the Zerotoprod\ServiceModel\Exceptions\ValidationException
if required properties are uninitialized.
There are two ways to validate your model.
- Use the
Strict
trait in your model. - Or validate manually after making your model:
Model::make()->validate()
.
Using the Strict
Trait
When the Strict
trait is used, the ServiceModel
trait will throw an exception if the model is missing a required
property.
You can mix and match the Service Models that uses the Strict
trait. Validation will only be called on the class that
uses that trait.
use Zerotoprod\ServiceModel\ServiceModel; use Zerotoprod\ServiceModel\Strict; class MyModel { use ServiceModel; use Strict; /** * Throws Zerotoprod\ServiceModel\Exceptions\ValidationException * if the required properties are not initialized. */ public readonly string $id; public readonly MyEnum $type; public string $name; /** * Does not throw an Exception because the property is not required. */ public readonly ?string $version; public readonly null|string $date; public ?string $time; public null|string $data; }
Manually Validating
You can call the validate()
method on your model to manually validate your model.
MyModel::make()->validate()
Lifecycle Hooks
Use the afterMake()
method to run code after the model is instantiated.
The $attributes
parameter is the value passed to the make()
method.
use Zerotoprod\ServiceModel\ServiceModel; class MyModel { use ServiceModel; public readonly string $value; public function afterMake($attributes): void { // Example of manual assignment. $this->value = $attributes['Value']; } }
Caching
The ServiceModel
trait in this project uses an in-memory caching mechanism to improve performance. The caching is
implemented using a Singleton pattern, which ensures that only a single instance of the cache is created and used
throughout the application.
The caching mechanism is used in the constructor of the ServiceModel trait. When an object is constructed, the trait
checks if a ReflectionClass
instance for the current class already exists in the cache. If it doesn't, a
new ReflectionClass
instance is created and stored in the cache.
The cache is also used when processing the properties of the object. For each property, the trait checks if
a ReflectionProperty
instance and the property type name are already stored in the cache. If they aren't, they are
retrieved using reflection and stored in the cache.
Resource Support
Sometimes you want to convert the case of your key to snake_case.
You can do this by adding the #[MapOutputNames(ToSnakeCase::class)]
attribute to your model and using
the toResource()
method.
use Zerotoprod\ServiceModel\Attributes\MapOutputNames; use Zerotoprod\ServiceModel\Attributes\ToSnakeCase; use Zerotoprod\ServiceModel\ServiceModel; #[MapOutputNames(ToSnakeCase::class)] class MyModel { use ServiceModel; public readonly string $LastName; }
$MyModel = MyModel::make(['LastName' => 'Doe']); $MyModel->toResource(); // ['last_name' => 'Doe']
Build Your Own Resource Transformer
You can build your own resource transformer by doing this implementing the CanParse
interface.
The $values
parameter is the value of the object typecast as an array.
use Attribute; use UnitEnum; use Zerotoprod\ServiceModel\Contracts\CanParse; #[Attribute] class ToCustomCase implements CanParse { public function parse(array $values): array { return $values; } }
You can add this to the top of your model like this:
use Zerotoprod\ServiceModel\Attributes\MapOutputNames; use Zerotoprod\ServiceModel\ServiceModel; #[MapOutputNames(ToCustomCase::class)] class MyModel { use ServiceModel; public readonly string $LastName; }
When you call the toResource()
method, it will use your custom resource transformer.
Upgrading to v2
This guide will help you upgrade your existing codebase to use the new features and improvements in the latest version of the Service Models package
- Update the Package
composer update zero-to-prod/service-model
- Update Attribute Namespaces
The namespaces for the attributes have changed. Update your import statements to reflect these changes:
IMPORTANT: The namespace for attributes have moved from
Zerotoprod\ServiceModel
toZerotoprod\ServiceModel\Attributes
.
Before:
use Zerotoprod\ServiceModel\Cast; use Zerotoprod\ServiceModel\CastToArray; use Zerotoprod\ServiceModel\CastToClasses; use Zerotoprod\ServiceModel\CanCast;
After:
use Zerotoprod\ServiceModel\Attributes\Cast; use Zerotoprod\ServiceModel\Attributes\CastToArray; use Zerotoprod\ServiceModel\Attributes\CastToClasses; use Zerotoprod\ServiceModel\Contracts\CanParse;
- Update Caster Interface
The CanCast
interface has been replaced with CanParse
. Update your classes that implement this interface:
IMPORTANT: The
CanCast
interface has been replaced withCanParse
. Before:
use Zerotoprod\ServiceModel\CanCast;
After:
use Zerotoprod\ServiceModel\Contracts\CanParse;
- Update Caster Method Names
The method names in classes implementing the caster interface have changed from set
to parse
. Update these methods
in your classes:
IMPORTANT: The
set
method has been replaced withparse
.
Before:
use Zerotoprod\ServiceModel\CanCast; class ToCarbon implements CanCast { public function set(array $values): Carbon { return Carbon::parse($values[0]); } }
After:
use Zerotoprod\ServiceModel\Contracts\CanParse; class ToCarbon implements CanParse { public function parse(array $values): Carbon { return Carbon::parse($values[0]); } }
Before:
use Zerotoprod\ServiceModel\CanCast; #[Attribute] class CollectionOf implements CanCast { public function __construct(public readonly string $class) { } public function set(array $values): Collection { return collect($values)->map(fn(array $item) => $this->class::make($item)); } }
After:
use Zerotoprod\ServiceModel\Contracts\CanParse; #[Attribute] class CollectionOf implements CanParse { public function __construct(public readonly string $class) { } public function parse(array $values): Collection { return collect($values)->map(fn(array $item) => $this->class::make($item)); } }
Please test your application thoroughly after making these changes to ensure everything works as expected.