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.
composer require romanzipp/laravel-dto
All data objects must extend the romanzipp\LaravelDTO\AbstractModelData
class.
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'],
]
]);
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
name |
current_age |
---|---|
John Doe | 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.
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
}
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;
}
}
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.
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'
]
]);
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']
]
]);
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']
]
]);
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 casts will convert any given value to a specified type.
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;
}
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;
}
}
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;
}
./vendor/bin/phpunit
./vendor/bin/phpstan