Streamlining API Responses in Laravel with DTOs

Streamlining API Responses in Laravel with DTOs

A comprehensive guide for creating custom Data Transfer Objects (DTOs) to enhance readability, efficiency, and testability in Laravel API integrations

·

13 min read

Introduction

Handling API responses effectively is crucial for integrating third-party APIs. In my previous post, I discussed setting up simple client and request classes using the Http facade. If that's not something that you've already read, I recommend you take a look at it.

Amplifying these concepts, this post brings you an in-depth guide on how to create custom data transfer objects (DTOs) that can map the data from the API responses. I’ll be using an ongoing Google Books API integration scenario as a practical example to make things more relatable.

Map the Response Data to a DTO

To start, let’s look at a sample response from the Google Books API when we perform a search. To do this, I call the QueryBooksByTitle action I created previously and search for the book “The Ferryman”:

$response = app(QueryBooksByTitle::class)("The Ferryman");

dump($response->json());

This dumps out the following JSON which I have narrowed down to fields I’d like to track:

[
    'kind' => 'books#volumes',
    'totalItems' => 367,
    'items' => [
        0 => [
            ...
        ],
        1 => [
            ...
        ],
        2 => [
            'kind' => 'books#volume',
            'id' => 'dO5-EAAAQBAJ',
            'volumeInfo' => [
                'title' => 'The Ferryman',
                'subtitle' => 'A Novel',
                'authors' => [
                    0 => 'Justin Cronin',
                ],
                'publisher' => 'Doubleday Canada',
                'publishedDate' => '2023-05-02',
                'description' => 'From the #1 New York Times bestselling author of The Passage comes a riveting standalone novel about a group of survivors on a hidden island utopia--where the truth isn\'t what it seems. Founded by a mysterious genius, the archipelago of Prospera lies hidden from the horrors of a deteriorating outside world. In this island paradise, Prospera\'s lucky citizens enjoy long, fulfilling lives until the monitors embedded in their forearms, meant to measure their physical health and psychological well-being, fall below 10 percent. Then they retire themselves, embarking on a ferry ride to the island known as the Nursery, where their failing bodies are renewed, their memories are wiped clean, and they are readied to restart life afresh. Proctor Bennett, of the Department of Social Contracts, has a satisfying career as a ferryman, gently shepherding people through the retirement process--and, when necessary, enforcing it. But all is not well with Proctor. For one thing, he\'s been dreaming--which is supposed to be impossible in Prospera. For another, his monitor percentage has begun to drop alarmingly fast. And then comes the day he is summoned to retire his own father, who gives him a disturbing and cryptic message before being wrestled onto the ferry. Meanwhile, something is stirring. The support staff, ordinary men and women who provide the labor to keep Prospera running, have begun to question their place in the social order. Unrest is building, and there are rumors spreading of a resistance group--known as Arrivalists--who may be fomenting revolution. Soon Proctor finds himself questioning everything he once believed, entangled with a much bigger cause than he realized--and on a desperate mission to uncover the truth.',
                'pageCount' => 507,
                'categories' => [
                    0 => 'Fiction',
                ],
                'imageLinks' => [
                    'smallThumbnail' => 'http://books.google.com/books/content?id=dO5-EAAAQBAJ&printsec=frontcover&img=1&zoom=5&edge=curl&source=gbs_api',
                    'thumbnail' => 'http://books.google.com/books/content?id=dO5-EAAAQBAJ&printsec=frontcover&img=1&zoom=1&edge=curl&source=gbs_api',
                ],
                ...
            ],
            ...
        ],
        ...
    ]

Now that we know the format of the response, let’s create the necessary DTOs to map the data. Let’s start with BookListData which can be a simple PHP class.

<?php

namespace App\DataTransferObjects;

use Illuminate\Contracts\Support\Arrayable;

/**
 * Stores the top-level data from the Google Books volumes API.
 */
readonly class BooksListData implements Arrayable
{
    public function __construct(
        public string $kind,
        public string $id,
        public int $totalItems,
    ) {
    }

    /**
     * Creates a new instance of the class from an array of data.
     */
    public static function fromArray(array $data): BooksListData
    {
        return new self(
            data_get($data, 'kind'),
            data_get($data, 'id'),
            data_get($data, 'totalItems'),
        );
    }

    /**
     * Implements Laravel's Arrayable interface to allow the object to be
     * serialized to an array.
     */
    public function toArray(): array
    {
        return [
            'kind' => $this->kind,
            'items' => $this->items,
            'totalItems' => $this->totalItems,
        ];
    }
}

With the DTO created, we can update the QueryBooksByTitle action that we created in the previous post.

<?php

namespace App\Actions;

use App\DataTransferObjects\BooksListData;
use App\Support\ApiRequest;
use App\Support\GoogleBooksApiClient;
use Illuminate\Http\Client\Response;

/**
 * The QueryBooksByTitle class is an action for querying books by title from the
 * Google Books API.
 * It provides an __invoke method that takes a title and returns the response
 * from the API.
 */
class QueryBooksByTitle
{
    /**
     * Query books by title from the Google Books API and return the BookListData.
     * This method creates a GoogleBooksApiClient and an ApiRequest for the
     * 'volumes' endpoint
     * with the given title as the 'q' query parameter and 'books' as the
     * 'printType' query parameter.
     * It then sends the request using the client and returns the book list data.
     */
    public function __invoke(string $title): BooksListData
    {
        $client = app(GoogleBooksApiClient::class);

        $request = ApiRequest::get('volumes')
            ->setQuery('q', 'intitle:'.$title)
            ->setQuery('printType', 'books');

        $response = $client->send($request);

        return BooksListData::fromArray($response->json());
    }
}

Test the Response Data

We can create a test to make sure we return a BooksListData object when calling the action:

<?php

use App\Actions\QueryBooksByTitle;
use App\DataTransferObjects\BooksListData;

it('fetches books by title', function () {
    $title = 'The Lord of the Rings';

    $response = resolve(QueryBooksByTitle::class)($title);

    expect($response)->toBeInstanceOf(BooksListData::class);
});

You might not have noticed, but there is an issue with the test above. We are reaching out to the Google Books API. This might be okay for an integration test that is not run often, but in our Laravel tests, this should be fixed. We can use the power of the Http facade for this since our Client class is built using the facade.

Prevent HTTP Requests in Tests

The first step I like to do is make sure none of my tests are making external HTTP requests that I am not expecting. We can add Http::preventStrayRequests(); to the Pest.php file. Then, in any test using the Http facade to make a request, an exception will be thrown unless we mock the request.

<?php

use Illuminate\Foundation\Testing\TestCase;
use Illuminate\Support\Facades\Http;
use Tests\CreatesApplication;

uses(
    TestCase::class,
    CreatesApplication::class,
)
    ->beforeEach(function () {
        Http::preventStrayRequests();
    })
    ->in('Feature');

If I run my QueryBooksByTitle test again, I now get a failed test that says:

RuntimeException: Attempted request to [https://www.googleapis.com/books/v1/volumes?key=XXXXXXXXXXXXX&q=intitle%3AThe%20Lord%20of%20the%20Rings&printType=books] without a matching fake.

Now, let’s use the Http facade to fake the response.

<?php

use App\Actions\QueryBooksByTitle;
use App\DataTransferObjects\BooksListData;
use Illuminate\Support\Facades\Http;

it('fetches books by title', function () {
    $title = fake()->sentence();

    // Generate a fake response from the Google Books API.
    $responseData = [
        'kind' => 'books#volumes',
        'totalItems' => 1,
        'items' => [
            [
                'id' => fake()->uuid,
                'volumeInfo' => [
                    'title' => $title,
                    'subtitle' => fake()->sentence(),
                    'authors' => [fake()->name],
                    'publisher' => fake()->company(),
                    'publishedDate' => fake()->date(),
                    'description' => fake()->paragraphs(asText: true),
                    'pageCount' => fake()->numberBetween(100, 500),
                    'categories' => [fake()->word],
                    'imageLinks' => [
                        'thumbnail' => fake()->url(),
                    ],
                ],
            ],
        ],
    ];

    // Return the fake response when the client sends a request to the Google Books API.
    Http::fake(['https://www.googleapis.com/books/v1/*' => Http::response(
        body: $responseData,
        status: 200
    )]);

    $response = resolve(QueryBooksByTitle::class)($title);

    expect($response)->toBeInstanceOf(BooksListData::class);
    expect($response->items[0]['volumeInfo']['title'])->toBe($title);
});

When running the test now, we no longer have the RuntimeException because we are faking the request using the Http::fake() method. The Http::fake() method is very flexible and can accept an array of items and different URLs. Depending on your application, you can just use '*' instead of the full URL or even make it more specific and include query parameters or other dynamic URL data. You can even fake sequences of requests if needed. Refer to the Laravel docs for more information.

This test works great but there are still some improvements to be made.

Expand the DTOs

First, let’s look at the response data again. It’s nice that we map the top level of the response in the BooksListData object, but having items[0]['volumeInfo']['title']) is not very developer-friendly friendly and the IDE cannot provide any type of autocompletion. To fix this, we need to create more DTOs. It’s usually easiest to start with the lowest-level items that need to be mapped. In this case, that would be the imageLinks data from the response. Looking at the response from Google Books, it looks like that could contain a thumbnail and smallThumbnail properties. We’ll create an ImageLinksData object to map this.

<?php

namespace App\DataTransferObjects;

use Illuminate\Contracts\Support\Arrayable;

readonly class ImageLinksData implements Arrayable
{
    public function __construct(
        public ?string $thumbnail = null,
        public ?string $smallThumbnail = null,
    ) {
    }

    public static function fromArray(array $data): self
    {
        return new self(
            thumbnail: data_get($data, 'thumbnail'),
            smallThumbnail: data_get($data, 'smallThumbnail'),
        );
    }

    public function toArray(): array
    {
        return [
            'thumbnail' => $this->thumbnail,
            'smallThumbnail' => $this->smallThumbnail,
        ];
    }
}

From there, go up a level and we have the VolumeInfoData.

<?php

namespace App\DataTransferObjects;

use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Support\Collection;

readonly class VolumeInfoData implements Arrayable
{
    public function __construct(
        public string $title,
        public string $subtitle,
        // Using collections instead of arrays is a personal preference.
        // It makes dealing with the data a little easier.
        /** @var Collection<int, string> */
        public Collection $authors,
        public string $publisher,
        public string $publishedDate,
        public string $description,
        public int $pageCount,
        /** @var Collection<int, string> */
        public Collection $categories,
        // The image links are mapped by the ImageLinksData object.
        public ImageLinksData $imageLinks,
    ) {
    }

    public static function fromArray(array $data): self
    {
        return new self(
            title: data_get($data, 'title'),
            subtitle: data_get($data, 'subtitle'),
            // Create collections from the arrays of data.
            authors: collect(data_get($data, 'authors')),
            publisher: data_get($data, 'publisher'),
            publishedDate: data_get($data, 'publishedDate'),
            description: data_get($data, 'description'),
            pageCount: data_get($data, 'pageCount'),
            // Create collections from the arrays of data.
            categories: collect(data_get($data, 'categories')),
            // Map the image links to the ImageLinksData object.
            imageLinks: ImageLinksData::fromArray(data_get($data, 'imageLinks')),
        );
    }

    public function toArray(): array
    {
        return [
            'title' => $this->title,
            'subtitle' => $this->subtitle,
            // Convert the collections to arrays since they implement the
            // arrayable interface.
            'authors' => $this->authors->toArray(),
            'publisher' => $this->publisher,
            'publishedDate' => $this->publishedDate,
            'description' => $this->description,
            'pageCount' => $this->pageCount,
            'categories' => $this->categories->toArray(),
            // Since we are using the arrayable interface, we can just call the
            // toArray method on the imageLinks object.
            'imageLinks' => $this->imageLinks->toArray(),
        ];
    }
}

Notice instead of using arrays, I used Laravel’s collections instead. I prefer working with Collections so I make sure anytime I have arrays in my responses, I map to Collections instead. Also, since the VolumeInfoData contains the imageLinks property, we can map it using the ImageLinksData object.

Going up another level, we have the list of items, so we can create the ItemData object.

<?php

namespace App\DataTransferObjects;

use Illuminate\Contracts\Support\Arrayable;

readonly class ItemData implements Arrayable
{
    public function __construct(
        public string $id,
        public VolumeInfoData $volumeInfo,
    ) {
    }

    public static function fromArray(array $data): self
    {
        return new self(
            id: data_get($data, 'id'),
            volumeInfo: VolumeInfoData::fromArray(data_get($data, 'volumeInfo')),
        );
    }

    public function toArray(): array
    {
        return [
            'id' => $this->id,
            'volumeInfo' => $this->volumeInfo->toArray(),
        ];
    }
}

Finally, we need to go back to the original BooksListData object and instead of mapping an array of data, we want to map a Collection of ItemData objects.

<?php

namespace App\DataTransferObjects;

use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Support\Collection;

/**
 * Stores the top-level data from the Google Books volumes API.
 */
readonly class BooksListData implements Arrayable
{
    public function __construct(
        public string $kind,
        /** @var Collection<int, ItemData> */
        public Collection $items,
        public int $totalItems,
    ) {
    }

    /**
     * Creates a new instance of the class from an array of data.
     */
    public static function fromArray(array $data): BooksListData
    {
        return new self(
            data_get($data, 'kind'),
            // Map the items to a collection of ItemData objects.
            collect(data_get($data, 'items', []))->map(fn (array $item) => ItemData::fromArray($item)),
            data_get($data, 'totalItems'),
        );
    }

    /**
     * Implements Laravel's Arrayable interface to allow the object to be
     * serialized to an array.
     */
    public function toArray(): array
    {
        return [
            'kind' => $this->kind,
            'items' => $this->items->toArray(),
            'totalItems' => $this->totalItems,
        ];
    }
}

With all the new DTOs created, let’s go back to the test and update.

Test the Full DTO

<?php

use App\Actions\QueryBooksByTitle;
use App\DataTransferObjects\BooksListData;
use App\DataTransferObjects\ImageLinksData;
use App\DataTransferObjects\ItemData;
use App\DataTransferObjects\VolumeInfoData;
use Illuminate\Support\Facades\Http;

it('fetches books by title', function () {
    $title = fake()->sentence();

    // Generate a fake response from the Google Books API.
    $responseData = [
        'kind' => 'books#volumes',
        'totalItems' => 1,
        'items' => [
            [
                'id' => fake()->uuid,
                'volumeInfo' => [
                    'title' => $title,
                    'subtitle' => fake()->sentence(),
                    'authors' => [fake()->name],
                    'publisher' => fake()->company(),
                    'publishedDate' => fake()->date(),
                    'description' => fake()->paragraphs(asText: true),
                    'pageCount' => fake()->numberBetween(100, 500),
                    'categories' => [fake()->word],
                    'imageLinks' => [
                        'thumbnail' => fake()->url(),
                    ],
                ],
            ],
        ],
    ];

    // Return the fake response when the client sends a request to the Google Books API.
    Http::fake(['https://www.googleapis.com/books/v1/*' => Http::response(
        body: $responseData,
        status: 200
    )]);

    $response = resolve(QueryBooksByTitle::class)($title);

    expect($response)->toBeInstanceOf(BooksListData::class)
        ->and($response->items->first())->toBeInstanceOf(ItemData::class)
        ->and($response->items->first()->volumeInfo)->toBeInstanceOf(VolumeInfoData::class)
        ->imageLinks->toBeInstanceOf(ImageLinksData::class)
        ->title->toBe($title);
});

Now in our expectations, we can see that the response is mapping all the various DTOs and correctly setting the title.

By having the action return the DTO versus the default Illuminate/Http/Client/Response, we now have type safety for the API response and get better autocompletion in the editor which greatly improves the developer experience.

Create Test Response Helpers

One other bonus tip for this test that I like to do is create something like a response factory. It is time-consuming to mock out the responses on every single test that you might need for querying books, so I prefer to create a simple trait that helps me mock the responses much quicker.

<?php

namespace Tests\Helpers;

use Illuminate\Support\Facades\Http;

trait GoogleBooksApiResponseHelpers
{
    /**
     * Generate a fake response for querying books by title.
     */
    private function fakeQueryBooksByTitleResponse(array $items = [], int $status = 200, bool $raw = false): void
    {
        // If raw is true, return the items array as-is. Otherwise, return a
        // fake response from the Google Books API.
        $data = $raw ? $items : [
            'kind' => 'books#volumes',
            'totalItems' => count($items),
            'items' => array_map(fn (array $item) => $this->createItem($item), $items),
        ];

        Http::fake(['https://www.googleapis.com/books/v1/*' => Http::response(
            body: $data,
            status: $status
        )]);
    }

    // Create a fake item array.
    private function createItem(array $data = []): array
    {
        return [
            'id' => data_get($data, 'id', '123'),
            'volumeInfo' => $this->createVolumeInfo(data_get($data, 'volumeInfo', [])),
        ];
    }

    // Create a fake volume info array.
    private function createVolumeInfo(array $data = []): array
    {
        return [
            'title' => data_get($data, 'title', fake()->sentence),
            'subtitle' => data_get($data, 'subtitle', 'Book Subtitle'),
            'authors' => data_get($data, 'authors', ['Author 1', 'Author 2']),
            'publisher' => data_get($data, 'publisher', 'Publisher'),
            'publishedDate' => data_get($data, 'publishedDate', '2021-01-01'),
            'description' => data_get($data, 'description', 'Book description'),
            'pageCount' => data_get($data, 'pageCount', 123),
            'categories' => data_get($data, 'categories', ['Category 1', 'Category 2']),
            'imageLinks' => data_get($data, 'imageLinks', ['thumbnail' => 'https://example.com/image.jpg']),
        ];
    }
}

To use the trait in a Pest test, we just need to use the uses method.

uses(GoogleBooksApiResponseHelpers::class);

With that, we can now easily add additional tests without needing to have all the mock data written in each test.

<?php

use App\Actions\QueryBooksByTitle;
use App\DataTransferObjects\BooksListData;
use App\DataTransferObjects\ImageLinksData;
use App\DataTransferObjects\ItemData;
use App\DataTransferObjects\VolumeInfoData;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Tests\Helpers\GoogleBooksApiResponseHelpers;

uses(GoogleBooksApiResponseHelpers::class);

it('fetches books by title', function () {
    $title = fake()->sentence();

    // Generate a fake response from the Google Books API.
    $this->fakeQueryBooksByTitleResponse([['volumeInfo' => ['title' => $title]]]);

    $response = resolve(QueryBooksByTitle::class)($title);

    expect($response)->toBeInstanceOf(BooksListData::class)
        ->and($response->items->first())->toBeInstanceOf(ItemData::class)
        ->and($response->items->first()->volumeInfo)->toBeInstanceOf(VolumeInfoData::class)
        ->imageLinks->toBeInstanceOf(ImageLinksData::class)
        ->title->toBe($title);
});

it('passes the title as a query parameter', function () {
    $title = fake()->sentence();

    // Generate a fake response from the Google Books API.
    $this->fakeQueryBooksByTitleResponse([['volumeInfo' => ['title' => $title]]]);

    resolve(QueryBooksByTitle::class)($title);

    Http::assertSent(function (Illuminate\Http\Client\Request $request) use ($title) {
        expect($request)
            ->method()->toBe('GET')
            ->data()->toHaveKey('q', 'intitle:'.$title);

        return true;
    });
});

it('fetches a list of multiple books', function () {
    // Generate a fake response from the Google Books API.
    $this->fakeQueryBooksByTitleResponse([
        $this->createItem(),
        $this->createItem(),
        $this->createItem(),
    ]);

    $response = resolve(QueryBooksByTitle::class)('Fake Title');

    expect($response->items)->toHaveCount(3);
});

it('throws an exception', function () {
    // Generate a fake response from the Google Books API.
    $this->fakeQueryBooksByTitleResponse([
        $this->createItem(),
    ], 400);

    resolve(QueryBooksByTitle::class)('Fake Title');
})->throws(RequestException::class);

With that, we now have cleaner tests, and our API responses are mapped to a DTO. For even more optimizations, you may consider using the Laravel Data package by Spatie to create the DTOs, it can help reduce some of the boilerplate code for having to create the fromArray and toArray methods.

Summary

In this post, you've learned how to streamline your process for developing and testing API integrations in Laravel by leveraging DTOs.

We explored the process of creating DTOs, mapping API responses to these DTOs, and developing test response helpers. This not only improved the readability of our code, but also facilitated a more type-safe, efficient, and testable development process.

The techniques discussed here and in my previous post are useful for all types of API integrations, however, for more advanced solutions, I recommend looking at the Saloon PHP library.

I hope this post proves beneficial in your future Laravel projects. Nevertheless, the discussion doesn't have to end here. Do you have extra tips or alternative methods you'd like to share? Or perhaps there are points you'd like to discuss or need clarification on? I'd love to hear your perspective! Feel free to leave a comment.

Did you find this article valuable?

Support Sean Kegel by becoming a sponsor. Any amount is appreciated!