Building A SOLID foundation for API's in Laravel

Learn how to structure your Laravel application to consume external API's. This article has a strong focus on consuming third party API's in a clean and scalable way. Ready to level up? Let's dive right in.

All developers will eventually need to integrate an app with a third-party API. However, the APIs that we are trying to make sense of often do not provide a lot of flexibility. This article aims to address this issue within your codebase to seamlessly integrate apps into any API using Laravel.

What we aim to achieve

Previously, this blog published a great article on Consuming APIs in Laravel with Guzzle, which showcases how Laravel can handle API calls out of the box. If you are looking for a way to do simple API calls and have not yet given this article a read, I would suggest starting there. In this article, we will build on top of this idea and learn how to structure our codebase to make use of a more SOLID approach to handling third-party APIs.

Structuring the application

Before we start creating files, we will install a third-party package that will help a ton. Laravel Saloon is a package created by Sam Carré that works as a middleman between Laravel and any third-party API to allow us to build an incredible developer experience around these APIs.

Installation instructions to get this package into your project can be found here.

Once this package is installed, we can start building. The idea behind Laravel Saloon is that each third-party API will consist of a connector and multiple requests. The connector class will act as the base class for all requests, while each request will act as a specified class for each endpoint. These classes will live in the app/Http/Integrations folder of our app. With this said, we are ready to look at an example for our new app.

Get connected

For our project, we will use Rest Countries, which is a simple and free open source API. To get started, we need to create the Laravel Saloon Connector:

php artisan saloon:connector Countries CountriesConnector

This will create a new file in app/Http/Integrations/Countries, which will now contain a single class called CountriesConnector. This file will be the base for all requests to this API. It acts as a single place to define any configurations or authentications required to connect to this API. By default, the file looks like this:

<?php

namespace App\Http\Integrations\Countries;

use Sammyjo20\Saloon\Http\SaloonConnector;
use Sammyjo20\Saloon\Traits\Plugins\AcceptsJson;

class CountriesConnector extends SaloonConnector
{
    use AcceptsJson;

    public function defineBaseUrl(): string
    {
        return '';
    }

    public function defaultHeaders(): array
    {
        return [];
    }

    public function defaultConfig(): array
    {
        return [];
    }
}

We want to change the defineBaseUrl method to now return the base URL for our API:

    public function defineBaseUrl(): string
    {
        return 'https://restcountries.com/v3.1/';
    }

For this example, we do not need to do anything more, but in the real world, this would most likely be the file where you can add authentication for the external API.

Making requests

Now that we have our connector set up, we can make our first request class. Each request will act as a different endpoint for the API. In this case, we will make use of the 'All' endpoint on the Rest Countries API. The full URL is as follows:

https://restcountries.com/v3.1/all

To get started building a request class, we can once again use the provided artisan commands:

php artisan saloon:request Countries ListAllCountriesRequest

This will generate a new file in app/Http/Integrations/Countries/Requests called ListAllCountriesRequest. By default, the file looks like this:

<?php

namespace App\Http\Integrations\Countries\Requests;

use Sammyjo20\Saloon\Constants\Saloon;
use Sammyjo20\Saloon\Http\SaloonRequest;

class ListAllCountriesRequest extends SaloonRequest
{
    protected ?string $connector = null;

    protected ?string $method = Saloon::GET;

    public function defineEndpoint(): string
    {
        return '/api/v1/user';
    }
}

This file works as an independent class for each endpoint provided by the API. In our case, the endpoint will be all, so we need to update the defineEndpoint method:

    public function defineEndpoint(): string
    {
        return 'all';
    }

For this request class to know which connection to make the request from, we need to update the $connector to reflect the connector we built in the previous step:

protected ?string $connector = CountriesConnector::class;

Don't forget to import your class by adding use App\Http\Integrations\Countries\CountriesConnector; at the top of the file

Now that we have our first connector and our first request ready, we can make our very first API call.

Making API calls using the connector and request

To do an initial test to see if the API is working as expected, let's alter our routes/web.php file to simply return the response to us when we load up the application.

<?php

use App\Http\Integrations\Countries\Requests\ListAllCountriesRequest;
use Illuminate\Support\Facades\Route;

Route::get('/', function () {
    $request = new ListAllCountriesRequest();
    return $request->send()->json();
});

We can now see a JSON dump from the API! It’s as easy as that!

JSON Dump

What we have learned so far

So far, we can see that our application can make an API call to https://restcountries.com/v3.1/all and display the results given using two PHP classes. What is really great about this is that the structure of our app remains "The Laravel Way" and keeps each type of API call separate, which allows us to separate concerns in our application. Our codebase is simple, with the following structure:

app
-- Http
--- Integrations
---- Requests
----- ListAllCountriesRequest.php
---- CountriesConnector.php

Adding more requests to the same API is a matter of creating a new Request class, which makes the whole developer experience a breeze. However, we could still take this further.

Making use of data transfer objects

When making API requests, a common problem developers encounter is that you will be given data that are not formatted or easily usable within an application. This unstructured data makes it fairly difficult to work with in our application. To combat this problem, we can make use of something called a data transfer object (DTO). Doing this will allow us to map the response of our API call to a PHP object that can stand alone. It will prevent us from having to write code that looks like this:

$name = $response->json()[0]['name'];

Instead, it will allow us to write code that looks like this, which is a lot cleaner and easier to work with:

$name = $response->name;

Let's dive in and make our response as clean as a whistle. To do this, we will create a new API call on the CountriesConnector to find a country given a name.

php artisan saloon:request Countries GetCountryByNameRequest

Following the steps outlined above, my class will now look like this:

<?php

namespace App\Http\Integrations\Countries\Requests;

use App\Http\Integrations\Countries\CountriesConnector;
use Sammyjo20\Saloon\Constants\Saloon;
use Sammyjo20\Saloon\Http\SaloonRequest;

class GetCountryByNameRequest extends SaloonRequest
{
    public function __construct(public string $name)
    {
    }

    protected ?string $connector = CountriesConnector::class;

    protected ?string $method = Saloon::GET;

    public function defineEndpoint(): string
    {
        return 'name/' . $this->name;
    }
}

Making use of this request is easy, and it can be done simply like this:

$request = new GetCountryByNameRequest('peru');
return $request->send()->json();

The full body of this response is quite a large JSON object, and the full object can be seen below:

JSON Dump

Therefore, in our next step, let's take the data above and map it to a DTO to keep things clean.

Casting to DTOs

The first thing we need is a DTO class. To make one of these, simply create a new Class in your preferred location. For me, I like to keep them in app/Data, but you are free to put them wherever works best for your project.

<?php

namespace App\Data;

class Country
{
    public function __construct(
        public string $name,
        public string $officalName,
        public string $mapsLink,

    ){}
}

For this example, we will map these three items to our DTO.

  • Name
  • Official Name
  • Maps Link for Google maps

All of these items are present within our JSON Response. Now that we have our base DTO available and ready to use, we can begin by using a trait and a method on our request. Our final request class will look like this:

<?php

namespace App\Http\Integrations\Countries\Requests;

use App\Data\Country;
use App\Http\Integrations\Countries\CountriesConnector;
use Sammyjo20\Saloon\Constants\Saloon;
use Sammyjo20\Saloon\Http\SaloonRequest;
use Sammyjo20\Saloon\Http\SaloonResponse;
use Sammyjo20\Saloon\Traits\Plugins\CastsToDto;

class GetCountryByNameRequest extends SaloonRequest
{
    use CastsToDto;

    public function __construct(public string $name)
    {
    }

    protected ?string $connector = CountriesConnector::class;

    protected ?string $method = Saloon::GET;

    public function defineEndpoint(): string
    {
        return 'name/' . $this->name;
    }

    protected function castToDto(SaloonResponse $response): object
    {
        return Country::fromSaloon($response);
    }
}

From here, we need to make the mapping on our DTO class, so we can add the fromSaloon method onto the DTO itself like this:

<?php

namespace App\Data;

use Sammyjo20\Saloon\Http\SaloonResponse;

class Country
{
    public function __construct(
        public string $name,
        public string $officalName,
        public string $mapsLink,

    )
    {}

    public static function fromSaloon(SaloonResponse $response): self
    {
        $data = $response->json();

        return new static(
            name: $data[0]['name']['common'],
            officalName: $data[0]['name']['official'],
            mapsLink: $data[0]['maps']['googleMaps']
        );
    }
}

Now, when we want to make use of our API, we will know what the data will look like when it is returned. In our case:

$request = new GetCountryByNameRequest('peru');
$response = $request->send();
$country = $response->dto();
return new JsonResponse($country);

Will return the following JSON object:

{
  "name": "Peru",
  "officalName": "Republic of Peru",
  "mapsLink": "https://goo.gl/maps/uDWEUaXNcZTng1fP6"
}

This is a lot cleaner and easier to work with than the original multi-nested object. This method has a ton of use cases that can be applied directly onto it. The DTO classes can house multiple methods that allow you to interact with the data all in one place. Simply add more methods to this class, and you have all of your logic in one place. For more information on this topic, Laravel Saloon has written a full integration guide on DTOs, which can be found here.

API testing using mocked classes

The last point that I would like to cover is probably one of the most painful points for developers. That is, "How to test an API?". When testing APIs, it is normally a good practice to ensure that you do not make an actual HTTP request in the test suite, as this can cause all kinds of errors. Instead, what you want to do is use a 'mock' class to pretend that the API was sent.

Let's take a look based on our above example. Using Laravel Saloons built-in testing helpers, we can do a full range of tests by adding the following in our test cases:

use Sammyjo20\SaloonLaravel\Facades\Saloon;
use Sammyjo20\Saloon\Http\MockResponse;
$fakeData = [
            [
                'name' => [
                    'common' => 'peru',
                    'official' => 'Republic of Peru'
                ], 
                'maps' => [
                    'googleMaps' => 'https://example.com'
                ]
            ]
        ];
Saloon::fake([
  GetCountryByNameRequest::class => MockResponse::make($fakeData, 200)
]);
(new GetCountryByNameRequest('peru'))->send()

With the above code, we have made a "Mock" of our Request. This means that any time we call the request, regardless of what data are provided, Saloon will always return the response with the fake data. This helps us to know that our requests are working as expected without having to make real API calls to live environments.

With this approach, you can test both failed responses and responses where the data may not be available. This will ensure you have covered all areas of your codebase. For more information on how to do testing, check out the testing documentation for Laravel. For a more in-depth guide on testing with Laravel Saloon, have a look at their extensive documentation.

Conclusion

Given the scope of how difficult it is to integrate multiple APIs, I truly hope that this article will provide a deeper understanding of how this can be done in the most simple form. Laravel has so many amazing packages that make the lives of their developers easier, and Laravel Saloon is one of them. Integrating APIs in a clean and scalable way has never been easier.

What to do next:
  1. Try Honeybadger for FREE
    Honeybadger helps you find and fix errors before your users can even report them. Get set up in minutes and check monitoring off your to-do list.
    Start free trial
    Easy 5-minute setup — No credit card required
  2. Get the Honeybadger newsletter
    Each month we share news, best practices, and stories from the DevOps & monitoring community—exclusively for developers like you.
    author photo

    Devin Gray

    Laravel Enthusiast… Part Time Human Being

    More articles by Devin Gray
    Stop wasting time manually checking logs for errors!

    Try the only application health monitoring tool that allows you to track application errors, uptime, and cron jobs in one simple platform.

    • Know when critical errors occur, and which customers are affected.
    • Respond instantly when your systems go down.
    • Improve the health of your systems over time.
    • Fix problems before your customers can report them!

    As developers ourselves, we hated wasting time tracking down errors—so we built the system we always wanted.

    Honeybadger tracks everything you need and nothing you don't, creating one simple solution to keep your application running and error free so you can do what you do best—release new code. Try it free and see for yourself.

    Start free trial
    Simple 5-minute setup — No credit card required

    Learn more

    "We've looked at a lot of error management systems. Honeybadger is head and shoulders above the rest and somehow gets better with every new release."
    — Michael Smith, Cofounder & CTO of YvesBlue

    Honeybadger is trusted by top companies like:

    “Everyone is in love with Honeybadger ... the UI is spot on.”
    Molly Struve, Sr. Site Reliability Engineer, Netflix
    Start free trial
    Are you using Sentry, Rollbar, Bugsnag, or Airbrake for your monitoring? Honeybadger includes error tracking with a whole suite of amazing monitoring tools — all for probably less than you're paying now. Discover why so many companies are switching to Honeybadger here.
    Start free trial
    Stop digging through chat logs to find the bug-fix someone mentioned last month. Honeybadger's built-in issue tracker keeps discussion central to each error, so that if it pops up again you'll be able to pick up right where you left off.
    Start free trial
    “Wow — Customers are blown away that I email them so quickly after an error.”
    Chris Patton, Founder of Punchpass.com
    Start free trial