Moving from Laravel API resources to DTOs

February 26, 20204 min read

Laravel’s API resources are a transformation layer that sits between your Eloquent models and the JSON responses returned by your API. This approach is completely fine, however the use case of these resources is quite limited to API responses only, they aren’t type hinted and lack autocompletion.

API resources can be replaced with DTOs. Since native DTOs aren’t supported in PHP, we can use data-transfer-object by Spatie (I’d recommend taking a look at it first, before reading this post), which can be installed by running:

composer require spatie/data-transfer-object

Our First Data Transfer Object

<?php

declare(strict_types=1);

namespace App\DataTransferObjects;

use App\Models\User;
use Spatie\DataTransferObject\DataTransferObject;

final class UserData extends DataTransferObject
{
    public int $id;

    public string $name;

    public string $email;

    public static function fromModel(User $user): UserData
    {
        return new static([
            'id' => $user->id,
            'name' => $user->name,
            'email' => $user->email,
        ]);
    }
}

Notice that we’ve defined a static constructor fromModel, which receives a type hinted User model. This way we have maximum flexibility to construct the DTO, inject relationships and so on.

Now we can instantiate UserData:

$user = User::first();

UserData::fromModel($user);

Returning the DTO as a Response

Unfortunately, UserData can’t be returned by API endpoints, since Laravel doesn’t know how to serialize it.

To achieve this, we can implement the Illuminate\Contracts\Support\Responsable interface. It would be nice to have whole thing reusable, so we can define ResponseData, which will implement the Responsable interface and wrap other DTOs.

Keep in mind, that we still want to wrap our DTOs in a data key, just like Laravel’s resources do.

<?php

declare(strict_types=1);

namespace App\DataTransferObjects;

use Illuminate\Contracts\Support\Responsable;
use Spatie\DataTransferObject\DataTransferObject;

final class ResponseData extends DataTransferObject implements Responsable
{
    public int $status = 200;

    /** @var \Spatie\DataTransferObject\DataTransferObject|\Spatie\DataTransferObject\DataTransferObjectCollection */
    public $data;

    /**
     * @param \Illuminate\Http\Request $request
     * @return \Illuminate\Http\JsonResponse|\Symfony\Component\HttpFoundation\Response
     */
    public function toResponse($request)
    {
        return response()->json(
            [
                'data' => $this->data->toArray(),
            ],
            $this->status
        );
    }
}

We’ve defined 2 attributes:

  • status - response status (default 200).
  • data - payload, which can be either a DataTransferObject or DataTransferObjectCollection.

Since we’ve implemented the Responsable interface, we have to define the toResponse method, where we can return a JSON response.

Notice the $this->data->toArray() part? Both DataTransferObject and DataTransferObjectCollection expose the toArray method.

Now inside controller methods, we can return the DTO as a response like that:

$user = User::first();

return new ResponseData([
    'data' => UserData::fromModel($user),
]);

And we’ll get:

{
  "data": {
    "id": 1,
    "name": "John",
    "email": "john@example.org"
  }
}

Returning Paginated Collection Responses

The same way we’ve defined ResponseData, we can define ResponsePaginationData:

<?php

declare(strict_types=1);

namespace App\DataTransferObjects;

use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Contracts\Support\Responsable;
use Spatie\DataTransferObject\DataTransferObject;
use Spatie\DataTransferObject\DataTransferObjectCollection;

final class ResponsePaginationData extends DataTransferObject implements Responsable
{
    public LengthAwarePaginator $paginator;

    public DataTransferObjectCollection $collection;

    public int $status = 200;

    /**
     * @param \Illuminate\Http\Request $request
     * @return \Illuminate\Http\JsonResponse|\Symfony\Component\HttpFoundation\Response
     */
    public function toResponse($request)
    {
        return response()->json(
            [
                'data' => $this->collection->toArray(),
                'meta' => [
                    'currentPage' => $this->paginator->currentPage(),
                    'lastPage' => $this->paginator->lastPage(),
                    'path' => $this->paginator->path(),
                    'perPage' => $this->paginator->perPage(),
                    'total' => $this->paginator->total(),
                ],
            ],
            $this->status
        );
    }
}

Here, we’ve defined 3 attributes:

  • status - response status (default 200).
  • paginator - Laravel’s paginator.
  • collection - our collection.

Usage in controller methods:

$users = User::paginate(30);

return new ResponsePaginationData([
    'paginator' => $users,
    'collection' => new UserCollection($users->items()),
]);

Output:

{
  "data": [
    {
      "id": 1,
      "name": "John",
      "email": "john@example.org"
    },
    {
      "id": 2,
      "name": "Mike",
      "email": "mike@example.org"
    }
  ],
  "meta": {
    "currentPage": 1,
    "lastPage": 1,
    "path": "http://localhost/api/users",
    "perPage": 30,
    "total": 2
  }
}

However, if you’ve decided to instantiate your DTOs with static constructors, like the fromModel example above, you won’t be able to instantiate your collections with the default constructor. Instead, we need to define a static constructor in collections as well:

<?php

declare(strict_types=1);

namespace App\DataTransferObjects;

use App\Models\User;
use Spatie\DataTransferObject\DataTransferObjectCollection;

final class UserCollection extends DataTransferObjectCollection
{
    public function current(): UserData
    {
        return parent::current();
    }

    /**
     * @param  User[]  $data
     * @return UserCollection
     */
    public static function fromArray(array $data): UserCollection
    {
        return new static(
            array_map(fn(User $item) => UserData::fromModel($item), $data)
        );
    }
}

So here’s how the controller method will look in the end:

$users = User::paginate(30);

return new ResponsePaginationData([
    'paginator' => $users,
    'collection' => UserCollection::fromArray($users->items()),
]);

Hey, thank you for reading this post entirely! Hopefully it was helpful for you. If you’ve enjoyed this post, subscribe below and get notified when new articles will be released.


Join the Newsletter

Subscribe to get my latest posts by email.

I respect your privacy. Unsubscribe at any time.