bssphp / laraveldto
A strongly typed Data Transfer Object integration for Laravel
Requires
- php: ^8.0
- ext-json: *
- bssphp/dto: ^1.4
Requires (Dev)
- bssphp/php-cs-fixer-config: ^1.2
- friendsofphp/php-cs-fixer: ^3.0
- laravel/framework: ^9.7
- orchestra/testbench: ^3.8|^4.0|^5.0|^6.0|^7.0
- phpstan/phpstan: ^0.12.99|^1.0
- phpunit/phpunit: ^9.0
README
A strongly typed Data Transfer Object for Laravel without magic for PHP 8.0+
This package extends the functionality of bssphp/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 bssphp/laraveldto
Usage
All data objects must extend the bssphp\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 bssphp\laraveldto\AbstractModelData; use bssphp\laraveldto\Attributes\ForModel; use bssphp\laraveldto\Attributes\ValidationRule; 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; }
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, ]);
Populate 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 bssphp\laraveldto\AbstractModelData; use bssphp\laraveldto\Attributes\ForModel; use bssphp\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 }
Create DTO and store to model
$data = new PersonData([ 'name' => 'John Doe', 'currentAge' => 25, 'language' => 'de', ]); $person = $data->toModel()->save();
Attributes saved in Person
model
{ "name": "John Doe", "current_age": 25 }
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 bssphp\laraveldto\AbstractModelData; use bssphp\laraveldto\Attributes\ForModel; use bssphp\laraveldto\Attributes\ModelAttribute; use bssphp\laraveldto\Attributes\RequestAttribute; #[ForModel(Person::class)] class PersonData extends AbstractModelData { #[RequestAttribute] // The `$name` DTO property will de 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 bssphp\laraveldto\AbstractModelData; use bssphp\laraveldto\Attributes\ForModel; use bssphp\laraveldto\Attributes\ModelAttribute; use bssphp\laraveldto\Attributes\RequestAttribute; use bssphp\laraveldto\Attributes\ValidatedRequestModelAttribute; use bssphp\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; // Combined usage // The `my_age` request attribute will be validated and set to the `current_age` model attribute. #[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; } }
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 bssphp\laraveldto\AbstractModelData; use bssphp\laraveldto\Attributes\ForModel; use bssphp\laraveldto\Attributes\NestedModelData; use bssphp\laraveldto\Attributes\RequestAttribute; use bssphp\laraveldto\Attributes\ValidatedRequestModelAttribute; use bssphp\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 bssphp\laraveldto\AbstractModelData; use bssphp\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 bssphp\laraveldto\AbstractModelData; use bssphp\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 bssphp\laraveldto\Attributes\Casts\CastInterface; #[Attribute] class MyCast implements CastInterface { public function castToType(mixed $value): mixed { return (string) $value; } }
Best practices
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 bssphp\laraveldto\AbstractModelData; use bssphp\laraveldto\Attributes\ForModel; use bssphp\laraveldto\Attributes\ModelAttribute; /** * @method Person toModel() */ #[ForModel(Person::class)] class PersonData extends AbstractModelData { #[ModelAttribute] public string $name; }
TODO
- Allow array validation rules
field.*
& Map into nested DTO - Add correct validation exception error messages from nested fields
- Pass existing model to
toModel()
method - Create DTO from existing model
- Only run validation rules if data provided from request
Testing
PHPUnit
./vendor/bin/phpunit
PHPStan
./vendor/bin/phpstan