« back published by @mmartin_joo on May 11, 2022

How To Use Data Transfer Objects and Actions in Laravel

Data Model

This article describes how to use:

  • Data Transfer Objects (or DTO for short)
  • Actions
  • ViewModels

The best way to demonstrate these concepts is a simple create and update operation. In this example, we're going to create subscribers in an e-mail marketing application or e-mail service provider (something like MailChimp or ConvertKit). First, let's discuss the properties of a subscriber:

  • E-mail
  • First name
  • Last name
  • Form ID (belongs to relationship)
  • Tags (belongs to many relationship)

The first three are self-explanatory. The form_id references the forms table. Each subscriber subscribed to a form. This is the form where you can submit your e-mail address when you want to subscribe to a newsletter. On top of that, each subscriber has tags, something "PHP developer." You can see the tables on this diagram:

Subscriber Data Model

Data Transfer Object

First, let's take a look at the subscriber model:

class Subscriber extends BaseModel
{
  protected $fillable = [
    'email',
    'first_name',
    'last_name',
    'form_id',
    'user_id',
  ];

  public function tags(): BelongsToMany
  {
    return $this->belongsToMany(Tag::class);
  }

  public function form(): BelongsTo
  {
    return $this->belongsTo(Form::class)
      ->withDefault();
  }
}

At this point, there's nothing too interesting. A many-to-many tag and a one-to-many form relationship. In the form relationship, I use the withDefault method. By default, if a subscriber does not have a form (it's a nullable column), Laravel will return null, and it can cause some bugs and unnecessary if statements. Using the withDefault Laravel will return an empty Form object instead of null. It can be helpful in some situations.

Now, let's see the subscriber DTO. In code, I don't use the word 'DTO'. I call the namespace DataTransferObjects, and I suffix the classes with the term Data.

This is the SubscriberData class:

namespace Domain\Subscriber\DataTransferObjects;

use Spatie\LaravelData\Data;

class SubscriberData extends Data
{
  public function __construct(
    public readonly ?int $id,
    public readonly string $email,
    public readonly string $first_name,
    public readonly ?string $last_name,
    /** @var DataCollection<TagData> */
    public readonly ?DataCollection $tags,
    public readonly ?FormData $form,
  ) {}
}

In this example, I'm using the laravel-data package from Spatie. If you're not familiar what a DTO is, please read my dedicated article on the topic.

All DTO look similar to this one:

  • The ID is always optional because there's no ID when a POST request comes in, and we transform it into a DTO. But when a PUT request comes in, there's an ID. And remember, this DTO will also be used when querying the subscribers and returning a response.

  • Properties are in $snake_case format. By default, laravel-data will map the request or the model attributes with the DTO's properties. By using snake_case variables, there's no extra work to do. This also applies to plain DTO classes (when you're not using the laravel-data package). If you want your properties to be in camelCase you have to write the logic that transforms the model_attributes to dtoAttributes. I have done it in the past, but after a while, it gets messy. So nowadays, I'm using snake_case everywhere:

    • Models
    • DTOs
    • Request parameters
  • $form is a nested property. As you can see, there's a FormData class that is a nested property of the SubscriberData. Just as the Subscriber model has a Form attribute. laravel-data helps us make this nesting very easy. We'll talk about it in more detail later.

  • $tags is also a nested property, but a subscriber has many tags, and as you can see in DTOs, we can use the DataCollection class to do this mapping. It comes from the laravel-data package.

  • The $form and $tags are nullable properties because not every subscriber has them.

Now let's see what the request and the validation rules look like:

public static function rules(): array
{
  return [
    'email' => [
      'required',
      'email',
      Rule::unique('subscribers', 'email')->ignore(request('subscriber')),
    ],
    'first_name' => ['required', 'string'],
    'last_name' => ['nullable', 'sometimes', 'string'],
    'tag_ids' => ['nullable', 'sometimes', 'array'],
    'form_id' => ['nullable', 'sometimes', 'exists:forms,id'],
  ];
}

There are some basic validation rules here, maybe the e-mail that is a bit more complicated, so let me explain it:

  • The email has to be unique. Laravel will run a select query to check if there's a record in the subscribers table with the e-mail in the request.
  • It works perfectly when creating a new subscriber.
  • When it's an update, we need to ignore the currently updated subscriber's e-mail address. Otherwise, Laravel will throw an exception because the subscribers table already contains this e-mail address.

By the way, can you spot the two differences compared to a standard Laravel request rules function?

  • This method is static.
  • There's a request call in ignore.

What the hack is going on? Here's the thing: this method is in the SubscriberData class. When using the laravel-data package, we have one class for:

  • Request
  • Resource
  • DTO

And the package expects a static rules function. Since it's static, in the ignore, I cannot write something like $this->id as I would in the case of a Request class. So I get the subscriber ID from the request parameter. In the case of an update request, the URL looks like this: * subscribers/{subscriber}*, so the request('subscriber') will return the Subscriber model that is being updated.

How can we use this class? Let's see the controller:

class SubscriberController
{
  public function store(
    SubscriberData $data, 
    Request $request
  ): RedirectResponse {
    UpsertSubscriberAction::execute($data, $request->user());

    return Redirect::route('subscribers.index');
  }
}

I will talk about the action later, but now let's focus on the DTO. As you can see, we can inject the SubscriberData class into the method, and the package will automatically create a new instance from the request. But I also inject the Request itself to get the currently logged-in user.

I could put a user property in the SubscriberData but remember we will also use this class to return as a response, and I don't want to include sensitive user data in the responses if it's not necessary.

By default, laravel-data will do a one-to-one mapping from the request to the data object. For each property within the data object, a value with the same key will be searched within the request values. This default behavior works in most cases. But if you take a look at the data class, it has properties like tags and form; meanwhile, in request, we expect keys like tag_ids and form_id. So how can we override the default mapping?

We need to define a fromRequest method in the data class:

public static function fromRequest(Request $request): self
{
  return self::from([
    ...$request->all(),
    'tags' => TagData::collection(
      Tag::whereIn('id', $request->collect('tag_ids'))->get()
    ),
    'form' => FormData::from(Form::findOrNew($request->form_id)),
  ]);
}

In a moment, I'll show you the TagData and the FormData classes. A from function is available in every data class; it's a simple factory function that creates a new DTO from an array. We can use this and override the array keys we want to transform:

  • form will be a FormData instance that wraps the query's result.
  • tags will be a collection of TagData that wraps the query's result.

It's very similar to Laravel resource classes. Before we move on, let's summarize how the whole flow works:

  • The SubscriberData is type-hinted in the constructor.
  • laravel-data will run the validation rules against the request.
  • The package will create a SubscriberData instance from the request using the fromRequest factory function.
  • This function will create a FormData instance from the form_id.
  • And a collection of TagData from the tag_ids.

As a result, from the following request:

{
  "id": null,
  "first_name": "Test",
  "last_name": "Subscriber",
  "email": "[email protected]",
  "form_id": 2,
  "tag_ids": [1, 2]
}

We got this DTO:

Subscriber Data

Now let's see the FormData class:

namespace Domain\Subscriber\DataTransferObjects;

use Spatie\LaravelData\Data;

class FormData extends Data
{
  public function __construct(
    public readonly ?int $id,
    public readonly string $title,
    public readonly string $content,
  ) {}
}

It only has a title and content property (the HTML of the form itself). The TagData is even more simple:

namespace Domain\Subscriber\DataTransferObjects;

use Spatie\LaravelData\Data;

class TagData extends Data
{
  public function __construct(
    public readonly ?int $id,
    public readonly string $title,
  ) {}
}

It only has a title. As you can see, every DTO has an optional ID property. Now that you have seen the basic data structure, let's move on to the action that creates a new subscriber:

Action

If you're not familiar with actions, read my in-depth article here.

namespace Domain\Subscriber\Actions;

class UpsertSubscriberAction
{
  public static function execute(
    SubscriberData $data, 
    User $user
  ): Subscriber {
    $subscriber = Subscriber::updateOrCreate(
      [
        'id' => $data->id,
      ],
      [
        ...$data->all(),
        'form_id' => $data->form?->id,
        'user_id' => $user->id,
      ],
    );

    $subscriber->tags()->sync($data->tags->toCollection()->pluck('id'));

    return $subscriber->load('tags', 'form');
  }
}

There are two common patterns:

  • I use the name upsert for almost all of my actions. It can be used to create or update a subscriber.
  • In these kinds of actions, I always use Eloquent's updateOrCreate method. It takes two arrays:
    • The first one is used in a select query. If a record is found with these values, it will run an update query. If it's not found, it will run an insert query.
    • The second one contains the attributes that will be inserted or updated.

Every DTO has an all method that returns the properties as an array. This is a handy method and can be used with Eloquent, as in this example. But when you have nested properties, the array looks like this:

[
  'first_name' => 'John',
  'last_name' => 'Doe',
  'form' => [
    'id' => 1,
    'title' => 'Some Form',
    'content' => '...',
  ],
]

Obviously, we can't save the whole form array; instead, we only want to store the id. This is why the second parameter of updateOrCreate is constructed like this:

[
  ...$data->all(),
  'form_id' => $data->form?->id,
  'user_id' => $user->id,
];

Since PHP 8, the ... operator can be used with associative arrays, so the example above is equivalent to this:

[
  array_merge(
    $data->all(),
    [
      'form_id' => $data->form?->id,
      'user_id' => $user->id,
    ],
  );
];

Using ..., we have a much cleaner solution. I will use this technique a lot. There are three other important things in this action:

  • The id of the SubscriberData (and any other DTO) is nullable. So when it contains a new subscriber, the updateOrCreate will get null as the ID, and it will run an insert query.
  • The $form is a nullable property in the SubscriberData class, so I use the ? operator. If the form is null, it won't throw an exception but instead, use a null value. It's also a PHP8 feature.

And the third important thing is this line:

 $subscriber->tags()->sync(
   $data->tags->toCollection()->pluck('id')
 );

If you remember, the $tags property in the SubscriberData class is a DataCollection. This class comes from the laravel-data package, and it can be converted into a Laravel collection by calling the toCollection method. Since tags is a belongsToMany relationship in the Subscriber model, we can use the sync method:

  • It will attach every tag from the given collection.
  • And detach every other tag that the subscriber had earlier (in case of an update).

Now let's see how the update method looks in the SubscriberController:

public function update(
  SubscriberData $data, 
  Request $request
): RedirectResponse {
  UpsertSubscriberAction::execute($data, $request->user());

  return Redirect::route('subscribers.index');
}

As you can see, it's the same as the store. That's because the action takes care of both actions. Why doesn't it have a Subscriber $subscriber argument from a route binding?

  • The frontend will send the ID in the request.
  • The SubscriberData loads this ID alongside the other attributes.
  • The updateOrCreate in the action will run an update query.

So it doesn't need a Subscriber parameter; the DTO takes care of everything.

View Model

To better understand creating and updating a subscriber, we need to take a closer look at view models. These classes contain data that the frontend needs. The only place they used in the backend is the controller:

namespace App\Http\Web\Controllers\Subscriber;

use Inertia\Response;

class SubscriberController
{
  public function create(): Response
  {
    return Inertia::render('Subscriber/Form', [
      'model' => new UpsertSubscriberViewModel(),
    ]);
  }

  public function edit(Subscriber $subscriber): Response
  {
    return Inertia::render('Subscriber/Form', [
      'model' => new UpsertSubscriberViewModel($subscriber),
    ]);
  }
}

If you remember from the good old Blade days, create and edit renders the create and edit page for a subscriber. But instead of rendering a Blade view, they are returning an Inertia\Response instance. This is the only difference when using Inertia. The Subscriber/Form is the path of a Vue component that contains the subscriber form. The second argument is the data we want to pass to this component as its props. In both cases, I give an instance of the UpsertSubscriberViewModel. Here's what this class looks like:

namespace Domain\Subscriber\ViewModels;

use Domain\Shared\ViewModels\ViewModel;

class UpsertSubscriberViewModel extends ViewModel
{
  public function __construct(public readonly ?Subscriber $subscriber = null)
  {
  }

  public function subscriber(): ?SubscriberData
  {
    if (!$this->subscriber) {
      return null;
    }

    return SubscriberData::from(
      $this->subscriber->load('tags', 'form')
    );
  }
  
  /**
   * @return Collection<TagData>
   */
  public function tags(): Collection
  {
    return Tag::all()->map(fn (Tag $tag) => TagData::from($tag));
  }
  
  /**
   * @return Collection<FormData>
   */
  public function forms(): Collection
  {
    return Form::all()->map(fn (Form $form) => FormData::from($form));
  }
}

The view model queries every tag and form from the database because we need to display them in a dropdown so users can choose from them.

Four essential things apply to most view models:

  • Since this class handles both the create and the edit page, the subscriber can be null. When creating a new one, it will be null, so the input fields on the form will all be empty. When updating an existing one, it will have a value, so the input values on the form will be populated.
  • Every method returns a data class. Now you have a better understanding of DTOs:
    • They are being used when handling incoming requests.
    • They are being used when returning responses.
    • Basically, DTOs are the "single source of truth" when it comes to data.
  • Every method we want to access on the frontend as a data property (more on that in a minute) must be public.
  • There is no get prefix in the method names. Two reasons for this:
    • A view model only has getters, so there's no reason to use a prefix.
    • These methods will be converted into an array, and they become properties of the Vue component.

When returning a view model from a controller, we want an array such as:

[
  'subscriber' => [
    'first_name' => 'John',
    'last_name' => 'Doe',
    'form' => [
      'id' => 1,
      'title' => 'Join the Newsletter',
      'content' => '...',
    ],
    'tags' => [
      [
        'id' => 1,
        'title' => 'Laravel',
      ],
      [
        'id' => 2,
        'title' => 'Vue',
      ],
    ],
  ],
  'forms' => [
    // Every form from the database for the dropdown
  ],
  'tags' => [
    // Every tag from the database for the dropdown
  ],
];

In a moment, I will show you how we can do this. For now, imagine we have this array from the view model. When we use this in the controller by this line:

return Inertia::render('Subscriber/Form', [
  'model' => new UpsertSubscriberViewModel(),
]);

Inertia will pass this array to the Vue component, where we can accept it as a prop:

export default {
  props: {
    model: {
      type: Object,
      required: true,
    },
  },
}

And the result:

Vue Props

This screenshot was taken on an edit form (the ID is not null). Of course, you don't have to use the root level model key, but I find it very useful.

Here's a fancy figure to help you understand the whole flow:

View Models Flow

From this flow, you can also see that Inertia is not a framework. It's a library that glues Laravel and Vue together.

Now, let's see the magic that creates an array from methods:

namespace Domain\Shared\ViewModels;

abstract class ViewModel implements Arrayable
{
  public function toArray(): array
  {
    return collect((new ReflectionClass($this))->getMethods())
      ->reject(fn (ReflectionMethod $method) => 
        in_array($method->getName(), ['__construct', 'toArray'])
      )
      ->filter(fn (ReflectionMethod $method) => 
        in_array(
          'public', 
          Reflection::getModifierNames($method->getModifiers())
        )
      )
      ->mapWithKeys(fn (ReflectionMethod $method) => [
        Str::snake($method->getName()) => $this->{$method->getName()}()
      ])
      ->toArray();
  }
}

This method is defined in the parent ViewModel class that every view model extends. We only need to implement the Arrayable interface, and Laravel will take care of the rest. I don't want to go into too many details about how the reflection API works, but the main logic is this:

  • We want every method from the view model except __construct and toArray.
  • We also want to reject every private or protected method. Only public methods represent data properties.
  • We want the array keys to be in snake_case. So a method called automationSteps will become automation_steps in the array and the Vue component.

This is how the view model gets converted into an array and passed to the Vue component as a property.

This whole example comes from my 259-page book Domain-Driven Design with Laravel. If you liked it, check out the landing page for more details. You can download another 49-page sample chapter that describes the basic concepts of domain-driven design:

Domain-Driven Design with Laravel