bssphp/laraveldto

A strongly typed Data Transfer Object integration for Laravel

1.7 2023-01-16 16:45 UTC

This package is auto-updated.

Last update: 2024-10-16 21:12:34 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 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

Authors