romanzipp/laravel-dto

A strongly typed Data Transfer Object integration for Laravel

1.0.0 2024-03-05 08:33 UTC

README

Latest Stable Version Total Downloads License GitHub Build Status

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

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.

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

Authors