romanzipp / laravel-dto
A strongly typed Data Transfer Object integration for Laravel
Installs: 4 816
Dependents: 0
Suggesters: 0
Security: 0
Stars: 9
Watchers: 3
Forks: 1
Open Issues: 1
Requires
- php: ^8.1
- ext-json: *
- romanzipp/dto: ^2.3.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.0
- laravel/framework: ^9.45
- orchestra/testbench: ^3.8|^4.0|^5.0|^6.0|^7.0
- phpstan/phpstan: ^0.12.99|^1.0
- phpunit/phpunit: ^9.0
- romanzipp/php-cs-fixer-config: ^3.0
README
A strongly typed Data Transfer Object for Laravel without magic for PHP 8.0+
This package extends the functionality of romanzipp/DTO to provide more narrow usecases for Laravel applications.
Laravel-DTO serves as an intermediate and reusable layer between request input & validation and model attribute population.
Contents
Installation
composer require romanzipp/laravel-dto
Usage
All data objects must extend the romanzipp\LaravelDTO\AbstractModelData
class.
Validation
When attaching the #[ValidationRule]
any given data will be passed to the Laravel Validator so you can make use of all available validation rules and even built-in rules instances.
use App\Models\Person; use App\Models\Project; use Illuminate\Validation\Rules\Exists; use romanzipp\LaravelDTO\AbstractModelData; use romanzipp\LaravelDTO\Attributes\ForModel; use romanzipp\LaravelDTO\Attributes\ValidationRule; use romanzipp\LaravelDTO\Attributes\ValidationChildrenRule; class PersonData extends AbstractModelData { #[ValidationRule(['required', 'string', 'min:1', 'max:255'])] public string $name; #[ModelAttribute(['sometimes', 'min:18'])] public int $currentAge; #[ValidationRule(['nullable', 'string', 'in:de,en'])] public ?string $language; #[ValidationRule(['required', 'numeric', new Exists(Project::class, 'id')])] public int $projectId; #[ValidationRule(['required', 'array', 'min:1']), ValidationChildrenRule(['string'], '*.device'), ValidationChildrenRule(['ipv4'], '*.ip')] public array $logins; }
This will throw a Illuminate\Validation\ValidationException
if any rule does not pass.
$data = new PersonData([ 'name' => 'John Doe', 'currentAge' => 25, 'language' => 'de', 'projectId' => 2, 'logins' => [ ['device' => 'PC', 'ip' => '85.120.61.36'], ['device' => 'iOS', 'ip' => '85.120.61.36'], ] ]);
Hydrate Models
You can attach a model to any DTO using the #[ForModel(Model::class)]
attribute.
To associate DTO properties with Model attributes, you need to attach the #[ModelAttribute()]
attribute to each property.
If no parameter is passed to the #[ModelAttribute]
attribute, DTO uses the property name itself.
use App\Models\Person; use romanzipp\LaravelDTO\AbstractModelData; use romanzipp\LaravelDTO\Attributes\ForModel; use romanzipp\LaravelDTO\Attributes\ModelAttribute; #[ForModel(Person::class)] class PersonData extends AbstractModelData { #[ModelAttribute] // The `$name` DTO property will populate the `name` model attribute public string $name; #[ModelAttribute('current_age')] // The `$currentAge` DTO property will populate the `current_age` model attribute public int $currentAge; public string $language; // The `$language` DTO property will be ignored } $data = new PersonData([ 'name' => 'John Doe', 'currentAge' => 25, 'language' => 'de', ]); $person = $data->toModel()->save();
Attributes saved in Person
model
Note: You can also pass an existing model to the toModel()
method.
use App\Models\Person; $person = $data->toModel($person)->save();
Note: When passing no existing model to the toModel()
method, default values declared in the DTO will be populated. If a model is passed as argument toModel($model)
default values will not override existing model attributes.
Populate DTO from request input data
When attaching the #[RequestAttribute]
and creating a DTO instance via the fromRequest(Request $request)
method all matching attributes will be populated by the input data. If no parameter is passed to the #[RequestAttribute]
attribute, DTO uses the property name itself.
use App\Models\Person; use romanzipp\LaravelDTO\AbstractModelData; use romanzipp\LaravelDTO\Attributes\ForModel; use romanzipp\LaravelDTO\Attributes\ModelAttribute; use romanzipp\LaravelDTO\Attributes\RequestAttribute; #[ForModel(Person::class)] class PersonData extends AbstractModelData { #[RequestAttribute] // The `$name` DTO property will be populated by the `name` request attribute public string $name; #[RequestAttribute('my_age')] // The `$currentAge` DTO property will be populated by `my_age` request attribute public int $currentAge; public string $language; // The `$language` DTO property will not be populated }
The controller
use App\Data\PersonData; use Illuminate\Http\Request; class TestController { public function store(Request $request) { $data = PersonData::fromRequest($request); } }
Request input data
{ "name": "John Doe", "my_age": 25, "language": "de" }
The PersonData
DTO instance
App\Data\PersonData^ {
+name: "John Doe"
+currentAge: 25
}
Combined usage
Of course all those attributes start to make sense if used together. You can attach all attributes separately of make use of the #[ValidatedRequestModelAttribute]
attribute which combines the functionality of all #[RequestAttribute]
, #[ModelAttribute]
and #[ValidationRule]
attributes.
Both properties in the following example behave exactly the same. Use as you prefer.
use App\Models\Person; use Illuminate\Validation\Rules\Exists; use romanzipp\LaravelDTO\AbstractModelData; use romanzipp\LaravelDTO\Attributes\ForModel; use romanzipp\LaravelDTO\Attributes\ModelAttribute; use romanzipp\LaravelDTO\Attributes\RequestAttribute; use romanzipp\LaravelDTO\Attributes\ValidatedRequestModelAttribute; use romanzipp\LaravelDTO\Attributes\ValidationRule; #[ForModel(Person::class)] class PersonData extends AbstractModelData { // All attributes attached separately (looks disgusting doesn't it?) #[ ValidationRule(['required', 'numeric', 'min:18']), RequestAttribute('my_age'), ModelAttribute('current_age') ] public string $currentAge; // The `my_age` request attribute will be validated and set to the `current_age` model attribute. // // RequestAttribute // ValidationRule │ ModelAttribute // ┌────────────────┴──────────────┐ ┌──┴───┐ ┌─────┴─────┐ #[ValidatedRequestModelAttribute(['required', 'numeric', 'min:18'], 'my_age', 'current_age')]; public string $currentAge; }
Request input data
{ "my_age": 25 }
The controller
use App\Data\PersonData; use Illuminate\Http\Request; class TestController { public function index(Request $request) { $person = PersonData::fromRequest($request)->toModel()->save(); return $person->id; } }
Validate arrays
If you only want to validate an array without casting the children items to another DTO, you can make use of the ValidationChildrenRule
attribute.
The first parameter to the ValidationChildrenRule
attribute is the validation rule for the children items. The second parameter is the validator path to access the children key to validate.
Validate a simple array with numeric indexes
use romanzipp\LaravelDTO\AbstractModelData; use romanzipp\LaravelDTO\Attributes\ValidationChildrenRule; class PersonData extends AbstractModelData { #[ValidationChildrenRule(['string', 'ipv4'], '*')]; public array $logins; } $data = new PersonData([ 'logins' => [ '127.0.0.1', '127.0.0.1' ] ]);
Validate associative arrays with named keys
use romanzipp\LaravelDTO\AbstractModelData; use romanzipp\LaravelDTO\Attributes\ValidationChildrenRule; class PersonData extends AbstractModelData { #[ValidationChildrenRule(['string', 'ipv4'], '*.ip')]; public array $logins; } $data = new PersonData([ 'logins' => [ ['ip' => '127.0.0.1'], ['ip' => '127.0.0.1'] ] ]);
Multiple validation rules
use romanzipp\LaravelDTO\AbstractModelData; use romanzipp\LaravelDTO\Attributes\ValidationChildrenRule; class PersonData extends AbstractModelData { #[ ValidationChildrenRule(['string', 'ipv4'], '*.ip'), ValidationChildrenRule(['string'], '*.device') ]; public array $logins; } $data = new PersonData([ 'logins' => [ ['ip' => '127.0.0.1', 'device' => 'iOS'], ['ip' => '127.0.0.1', 'device' => 'macOS'] ] ]);
Cast arrays to DTOs (Nested data)
In some cases you also want to create realted models with a single HTTP call. In this case you can make use of the #[NestedModelData(NestedData::class)]
which will populate the DTO property with n instances of the defined DTO.
Note that we will not attach an #[ModelAttribute]
attribute to the $address
DTO property since it should not be set to a model attribute.
All attributes attached to the nested DTO will just work as expected.
use App\Models\Person; use romanzipp\LaravelDTO\AbstractModelData; use romanzipp\LaravelDTO\Attributes\ForModel; use romanzipp\LaravelDTO\Attributes\NestedModelData; use romanzipp\LaravelDTO\Attributes\RequestAttribute; use romanzipp\LaravelDTO\Attributes\ValidatedRequestModelAttribute; use romanzipp\LaravelDTO\Attributes\ValidationRule; #[ForModel(Person::class)] class PersonData extends AbstractModelData { #[ValidatedRequestModelAttribute(['required', 'string'])] public string $name; /** * @var AddressData[] */ #[NestedModelData(AddressData::class), ValidationRule(['required', 'array']), RequestAttribute] public array $adresses; }
use App\Models\Address; use romanzipp\LaravelDTO\AbstractModelData; use romanzipp\LaravelDTO\Attributes\ValidatedRequestModelAttribute; #[ForModel(Address::class)] class AddressData extends AbstractModelData { #[ValidatedRequestModelAttribute(['string'])] public string $street; #[ValidatedRequestModelAttribute(['nullable', 'int'])] public ?int $apartment = null; }
Request input data
{ "name": "John Doe", "addresses": [ { "street": "Sample Street" }, { "street": "Debugging Alley", "apartment": 43 } ] }
The controller
use App\Data\PersonData; use Illuminate\Http\Request; class TestController { public function index(Request $request) { $personData = PersonData::fromRequest($request); $person = $personData->toModel()->save(); foreach ($personData->addresses as $addressData) { // We assume the `Person` model has a has-many relation with the `Address` model $person->addresses()->save( $addressData->toModel() ); } return $person->id; } }
Type Casting
Type casts will convert any given value to a specified type.
Built-in type casts
CastToDate
The #[CastToDate]
attribute will respect your customly defined date class from Date::use(...)
.
You can also specify a custom date class to be used by passing the date class name as single argument #[CastToDate(MyDateClass::class)]
.
use Carbon\Carbon; use romanzipp\LaravelDTO\AbstractModelData; use romanzipp\LaravelDTO\Attributes\Casts\CastToDate; class PersonData extends AbstractModelData { #[CastToDate] public Carbon $date; }
Custom type casts
You can declare custom type cast attributes by simply implementing the CastInterface
interface and attaching an attribute.
use Attribute; use romanzipp\LaravelDTO\Attributes\Casts\CastInterface; #[Attribute] class MyCast implements CastInterface { public function castToType(mixed $value): mixed { return (string) $value; } }
IDE Support
Make sure to add a @method
PHPDoc comment like shown below to allow IDE and static analyzer support when calling the toModel()
method.
use App\Models\Person; use romanzipp\LaravelDTO\AbstractModelData; use romanzipp\LaravelDTO\Attributes\ForModel; use romanzipp\LaravelDTO\Attributes\ModelAttribute; /** * @method Person toModel() */ #[ForModel(Person::class)] class PersonData extends AbstractModelData { #[ModelAttribute] public string $name; }
Testing
PHPUnit
./vendor/bin/phpunit
PHPStan
./vendor/bin/phpstan