Creating API Resources in Laravel
Tags: laravel, php, api, webdev
Published: 2024-01-16 02:02:12

---
canonical_url: https://seankegel.com/creating-api-resources-in-laravel
---

## Using controller-like classes and Laravel-data for efficient API communication

Welcome back to my series for Integrating Third-Party APIs in Laravel. In this post, I will discuss creating API Resources. An API resource in this case is like a RESTful controller. It adds CRUD-like methods for communicating with the API when dealing with a specific resource, like books, products, users, etc. If you haven’t read the previous posts, I suggest reading them first.

- [Simplifying API Integration with Laravel's Http Facade](https://seankegel.com/simplifying-api-integration-with-laravels-http-facade?source=more_series_bottom_blogs)
- [Streamlining API Responses in Laravel with DTOs](https://seankegel.com/streamlining-api-responses-in-laravel-with-dtos?source=more_series_bottom_blogs)

In the first two parts of the series, I used the Google Books API for my examples. For simplification and to have more routes readily available without needing to setup OAuth 2.0, I will be using [Fake Store API](https://fakestoreapi.com/docs) and querying products. I will also be using the [Spatie Laravel-data](https://spatie.be/docs/laravel-data/v3/introduction) package for my data-transfer objects (DTOs) instead of creating custom DTOs from simple PHP classes like I did in the previous posts. This helps to remove some of the boilerplate of defining `fromArray` and `toArray` methods.

## Getting Started

In the previous posts in the series, we had an `ApiRequest` class and `ApiClient` class. To create the API client for the Fake Store API; we can extend the `ApiClient` class.

```php
<?php

namespace App\Support;

class StoreApiClient extends ApiClient
{
    protected function baseUrl(): string
    {
        return config('services.store_api.url');
    }
}
```

Since the Fake Store API does not have any authentication required, this class can be pretty simple. For the test, we can just make sure the base URL is getting set as expected.

```php
<?php

use App\Support\ApiRequest;
use App\Support\StoreApiClient;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;

beforeEach(function () {
    Http::fake();
    config([
        'services.store_api.url' => 'https://example.com',
    ]);
});

it('sets the base url', function () {
    $request = ApiRequest::get('products');

    app(StoreApiClient::class)->send($request);

    Http::assertSent(static function (Request $request) {
        expect($request)->url()->toStartWith('https://example.com/products');

        return true;
    });
});
```

For this test, I manually set a test URL in my configuration. It is also possible to just use an `.env.test` file to define the value if you prefer. For simple tests, I like the approach of defining the config in the test so I can easily see what was expected in the tests versus having to compare it to another file.

Now, let’s create our API resource. What I am calling an API resource is a simple class to treat the product resource similar to a REST controller in Laravel. The API resource will allow me to list products, show a product, create a product, update a product, and delete a product. In the previous post, we had an action for fetching books from the Google Books API, but by using a resource, I combined the various calls for a resource into a single class.

```php
<?php

namespace App\ApiResources;

use App\Data\ProductData;
use App\Support\StoreApiClient;
use Spatie\LaravelData\DataCollection;

/**
 * ApiResource for products.
 */
class ProductResource
{
    /**
     * Use dependency injection to get the StoreApiClient.
     */
    public function __construct(private readonly StoreApiClient $client)
    {
    }

    /**
     * List all products.
     */
    public function list()
    {
        ...
    }

    /**
     * Show a single product.
     */
    public function show(int $id)
    {
        ...
    }

    /**
     * Create a new product.
     */
    public function create($data)
    {
        ...
    }

    /**
     * Update a product.
     */
    public function update(int $id, $data)
    {
        ...
    }

    /**
     * Delete a product.
     */
    public function delete(int $id)
    {
        ...
    }
}
```

## Filling out the API Resource

### List Method

We’ll start with the list method. The first thing I like to do is model the data that we will be receiving from the API. I will use Laravel-data for this and create a `ProductData` class. If you haven’t installed Laravel-data, install it with composer:

```bash
composer require spatie/laravel-data
```

The `ProductData` class can be created manually by extending the `Spatie\LaravelData\Data` class or by using Artisan:

```bash
php artisan make:data ProductData
```

Looking at the documentation for the Fake Store API, we can map that to a DTO like the following:

```php
<?php

namespace App\Data;

use Spatie\LaravelData\Data;

class ProductData extends Data
{
    public function __construct(
        public int $id,
        public string $title,
        public float $price,
        public string $description,
        public string $category,
        public string $image,
        public ?RatingData $rating = null,
    ) {}
}
```

Notice the `$rating` property has a type of `RatingData`. This is another DTO:

```php
<?php

namespace App\Data;

use Spatie\LaravelData\Data;

class RatingData extends Data
{
    public function __construct(
        public float $rate,
        public int $count,
    ) {}
}
```

Now, in the resource `list` method, we can add the following to fetch the products and map them to a collection of `ProductData` instances.

```php
public function list(): DataCollection
{
    // Create the request to the products endpoint.
    $request = ApiRequest::get('/products');

    // Send the request using the client.
    $response = $this->client->send($request);

    // Map the response to the ProductData DTO.
    return ProductData::collection($response->json());
}
```

Looking at the API documentation for the Fake Store API, it is possible to limit the number of results and sort the results that are returned. We can map this using a DTO as well.

```php
<?php

namespace App\Data;

use App\Enums\SortDirection;
use Spatie\LaravelData\Data;

class ListProductsData extends Data
{
    public function __construct(
        public readonly ?int $limit = null,
        public readonly ?SortDirection $sort = null,
    ) {}

    public function toArray(): array
    {
        return collect(parent::toArray())
            ->filter()
            ->toArray();
    }
}
```

The `SortDirection` type is just a simple enum with `asc` and `desc` cases. I added a custom `toArray` method which uses Laravel collections and the `filter` method to remove any null properties from the array. This prevents sending things like `?sort=null` to the API.

Let’s add this to our `list` method.

```php
public function list(?ListProductsData $data = null): DataCollection
{
    $request = ApiRequest::get('/products');

    if ($data) {
        // Add the ListProductsData to the query string of the request.
        $request->setQuery($data->toArray());
    }

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

    return ProductData::collection($response->json());
}
```

Now, if we want to request a list of five products in descending order, we can do something like the following:

```php
$resource = resolve(ProductResource::class);
$requestData = new ListProductsData(
  limit: 5,
  sort: SortDirection::DESC,
);

$response = $resource->list($requestData);
```

Let’s add a few tests for this method.

```php
<?php

use App\ApiResources\ProductResource;
use App\Data\ListProductsData;
use App\Data\ProductData;
use App\Enums\SortDirection;
use Illuminate\Support\Facades\Http;
use Illuminate\Http\Client\Request;
use Spatie\LaravelData\DataCollection;
use Tests\Helpers\StoreApiTestHelper;

uses(StoreApiTestHelper::class);

it('shows a list of products', function () {
    // Fake the response from the API.
    Http::fake([
        '*/products' => Http::response([
            $this->getFakeProduct(['id' => 1]),
            $this->getFakeProduct(['id' => 2]),
            $this->getFakeProduct(['id' => 3]),
            $this->getFakeProduct(['id' => 4]),
            $this->getFakeProduct(['id' => 5]),
        ]),
    ]);

    $resource = resolve(ProductResource::class);

    $response = $resource->list();

    // Assert that the response is a collection of product data objects.
    expect($response)
        ->toBeInstanceOf(DataCollection::class)
        ->count()->toBe(5)
        ->getIterator()->each->toBeInstanceOf(ProductData::class);

    // Assert that a GET request was sent to the correct endpoint.
    Http::assertSent(function (Request $request) {
        expect($request)
            ->url()->toEndWith('/products')
            ->method()->toBe('GET');

        return true;
    });
});

it('limits and sorts products', function () {
    // Fake the response from the API.
    Http::fake([
        '*/products?*' => Http::response([
            $this->getFakeProduct(['id' => 3]),
            $this->getFakeProduct(['id' => 2]),
            $this->getFakeProduct(['id' => 1]),
        ]),
    ]);

    $resource = resolve(ProductResource::class);

    // Create a request data object with a three-item limit and descending direction.
    $requestData = new ListProductsData(3, SortDirection::DESC);

    $response = $resource->list($requestData);

    // Assert that the response is a collection of product data objects.
    expect($response)
        ->toBeInstanceOf(DataCollection::class)
        ->count()->toBe(3)
        ->getIterator()->each->toBeInstanceOf(ProductData::class);

    // Assert that a GET request was sent to the correct endpoint with the correct query data.
    Http::assertSent(function (Request $request) {
        parse_str(parse_url($request->url(), PHP_URL_QUERY), $queryParams);
        $path =  (parse_url($request->url(), PHP_URL_PATH));

        expect($queryParams)->toMatchArray(['limit' => 3, 'sort' => 'desc'])
            ->and($path)->toEndWith('/products')
            ->and($request)->method()->toBe('GET');

        return true;
    });
});
```

You’ll notice the `uses(StoreApiTestHelper::class);` call in the test. That loads a simple trait to provide the `getFakeProduct()` method which is used to generate fake product responses.

```php
<?php

namespace Tests\Helpers;

trait StoreApiTestHelper
{
    private function getFakeProduct(array $data = []): array {
        return [
            'id' => data_get($data, 'id', fake()->numberBetween(1, 1000)),
            'title' => data_get($data, 'title', fake()->text()),
            'price' => data_get($data, 'price', fake()->randomFloat(2, 0, 100)),
            'description' => data_get($data, 'description', fake()->paragraph()),
            'category' => data_get($data, 'category', fake()->text()),
            'image' => data_get($data, 'image', fake()->url()),
            'rating' => data_get($data, 'rating', [
                'rate' => fake()->randomFloat(2, 0, 5),
                'count' => fake()->numberBetween(1, 1000),
            ]),
        ];
    }
}
```

I like using traits like this in tests to try and make it easier to fake API responses. This can easily be expanded on, too, like I did in my previous [post](https://seankegel.com/streamlining-api-responses-in-laravel-with-dtos).

In the tests, we ensure the proper endpoints are being called and the expected responses are being returned.

### Show and Delete Methods

I am combining the `show` and `delete` methods here since they will be very similar. According to the API documentation, they both return the product for the ID provided, so they have the same return value. They also accept an `id` URL parameter to fetch the specific product.

```php
/**
 * Show a single product.
 */
public function show(int $id): ProductData
{
    $request = ApiRequest::get("/products/{$id}");
    $response = $this->client->send($request);

    return ProductData::from($response->json());
}

/**
 * Delete a product.
 */
public function delete(int $id): ProductData
{
    $request = ApiRequest::delete("/products/$id");
    $response = $this->client->send($request);

    return ProductData::from($response->json());
}
```

Now, let’s add the following tests:

```php
it('fetches a product', function () {
    // Create a fake product
    $fakeProduct = $this->getFakeProduct();

    // Fake the response from the API.
    Http::fake(["*/products/{$fakeProduct['id']}" => Http::response($fakeProduct)]);

    $resource = resolve(ProductResource::class);

    // Request a product
    $response = $resource->show($fakeProduct['id']);
    expect($response)
        ->toBeInstanceOf(ProductData::class)
        ->id->toBe($fakeProduct['id']);

    // Assert that a GET request was sent to the correct endpoint with the correct method.
    Http::assertSent(function (Request $request) use ($fakeProduct) {
        expect($request)
            ->url()->toEndWith("/products/{$fakeProduct['id']}")
            ->method()->toBe('GET');

        return true;
    });
});

it('deletes a product', function () {
    // Create a fake product
    $fakeProduct = $this->getFakeProduct();

    // Fake the response from the API.
    Http::fake(["*/products/{$fakeProduct['id']}" => Http::response($fakeProduct)]);

    $resource = resolve(ProductResource::class);

    // Request a product
    $response = $resource->delete($fakeProduct['id']);
    expect($response)
        ->toBeInstanceOf(ProductData::class)
        ->id->toBe($fakeProduct['id']);

    // Assert that a DELETE request was sent to the correct endpoint.
    Http::assertSent(function (Request $request) use ($fakeProduct) {
        expect($request)
            ->url()->toEndWith("/products/{$fakeProduct['id']}")
            ->method()->toBe('DELETE');

        return true;
    });
});
```

### Create and Update Methods

For the create and update methods, we know we need to send data to the API. Sometimes it is possible to reuse the same DTO that the API returns, but oftentimes, I like to create dedicated DTOs for the request. So I will create the `SaveProductData` DTO:

```php
<?php

namespace App\Data;

use Spatie\LaravelData\Data;

class SaveProductData extends Data
{
    public function __construct(
        public string $title,
        public float $price,
        public string $description,
        public string $category,
        public string $image,
    ) {}
}
```

For this API, this single DTO is sufficient for both the `create` and `update` methods, however, sometimes it is necessary to have a dedicated DTO for each method. For example, using this specific DTO for the `update` method requires all the fields to be specified. However, you may want a DTO that allows optional properties and filters out the ones not set so you can easily update specific properties instead of everything.

With that in place, let’s update the `create` method:

```php
/**
 * Create a new product.
 */
public function create(SaveProductData $data): ProductData
{
    $request = ApiRequest::post('/products')->setBody($data->toArray());
    $response = $this->client->send($request);

    return ProductData::from($response->json());
}
```

The update method is similar but with an additional `id` parameter and a `PUT` request instead of a `POST` request.

```php
/**
 * Update a product.
 */
public function update(int $id, SaveProductData $data): ProductData
{
    $request = ApiRequest::put("/products/$id")->setBody($data->toArray());
    $response = $this->client->send($request);

    return ProductData::from($response->json());
}
```

Just like the `show` and `delete` methods, we are returning a `ProductData` instance for `create` and `update`.

Add the following tests for the methods.

```php
it('creates a product', function () {
    // Create a fake product
    $fakeProduct = $this->getFakeProduct();

    // Fake the response from the API.
    Http::fake(["*/products" => Http::response($fakeProduct)]);

    $resource = resolve(ProductResource::class);

    // Data for creating a product.
    $data = new SaveProductData(
        title: $fakeProduct['title'],
        price: $fakeProduct['price'],
        description: $fakeProduct['description'],
        category: $fakeProduct['category'],
        image: $fakeProduct['image'],
    );

    // Request a product
    $response = $resource->create($data);
    expect($response)
        ->toBeInstanceOf(ProductData::class)
        ->id->toBe($fakeProduct['id']);

    // Assert that a POST request was sent to the correct endpoint.
    Http::assertSent(function (Request $request) {
        expect($request)
            ->url()->toEndWith('/products')
            ->method()->toBe('POST');

        return true;
    });
});

it('updates a product', function () {
    // Create a fake product
    $fakeProduct = $this->getFakeProduct();

    // Fake the response from the API.
    Http::fake(["*/products/{$fakeProduct['id']}" => Http::response($fakeProduct)]);

    $resource = resolve(ProductResource::class);

    $data = new SaveProductData(
        title: $fakeProduct['title'],
        price: $fakeProduct['price'],
        description: $fakeProduct['description'],
        category: $fakeProduct['category'],
        image: $fakeProduct['image'],
    );

    // Request a product
    $response = $resource->update($fakeProduct['id'], $data);
    expect($response)
        ->toBeInstanceOf(ProductData::class)
        ->id->toBe($fakeProduct['id']);

    // Assert that a PUT request was sent to the correct endpoint.
    Http::assertSent(function (Request $request) use ($fakeProduct) {
        expect($request)
            ->url()->toEndWith("/products/{$fakeProduct['id']}")
            ->method()->toBe('PUT');

        return true;
    });
});
```

## Summary

In this post, we discussed a method of combining requests related to a resource into a single class. When using the combination of Laravel’s `Http` facade and data transfer objects, the methods of these classes can be made similar to a Laravel controller and be kept small and concise. I hope you enjoyed this series on integrating third-party APIs in Laravel. You can view the repository with all the code we went through in this post, [here](https://github.com/skegel13/api-resources).

Thanks for reading and as always, feel free to comment or ask questions!