Working with third party services in laravel
Published on by Steve McDougall
So a little over two years ago, I wrote a tutorial on how you should work with third-party services in Laravel. To this day, it is the most visited page on my website. However, things have changed over the last two years, and I decided to approach this topic again.
So I have been working with third-party services for so long that I cannot remember when I wasn't. As a Junior Developer, I integrated API into other platforms like Joomla, Magento, and WordPress. Now it mainly integrates into my Laravel applications to extend business logic by leaning on other services.
This tutorial will describe how I typically approach integrating with an API today. If you have read my previous tutorial, keep reading as a few things have changed - for what I consider good reasons.
Let's start with an API. We need an API to integrate with. My original tutorial was integrating with PingPing, an excellent uptime monitoring solution from the Laravel community. However, I want to try a different API this time.
For this tutorial, we will use the Planetscale API. Planetscale is an incredible database service I use to get my read-and-write operations closer to my users in the day job.
What will our integration do? Imagine we have an application that allows us to manage our infrastructure. Our servers run through Laravel Forge, and our database is over on Planetscale. There is no clean way to manage this workflow, so we created our own. For this, we need an integration or two.
Initially, I used to keep my integrations under app/Services
; however, as my applications have gotten more extensive and complicated, I have needed to use the Services
namespace for internal services, leading to a polluted namespace. I have moved my integrations to app/Http/Integrations
. This makes sense and is a trick I picked up from Saloon by Sam Carrè.
Now I could use Saloon for my API integration, but I wanted to explain how I do it without a package. If you need an API integration in 2023, I highly recommend using Saloon. It is beyond amazing!
So, let's start by creating a directory for our integration. You can use the following bash command:
mkdir app/Http/Integrations/Planetscale
Once we have the Planetscale directory, we need to create a way to connect to it. Another naming convention I picked up off of the Saloon library is to look at these base classes as connectors - as their purpose is to allow you to connect to a specific API or third party.
Create a new class called PlanetscaleConnector
in the app/Http/Integrations/Planetscale
directory, and we can flesh out what this class needs, which will be a lot of fun.
So we must register this class with our container to resolve it or build a facade around it. We could register this to "long" way in a Service Provider - but my latest approach is to have these Connectors register themselves - kind of ...
declare(strict_types=1); namespace App\Http\Integrations\Planetscale; use Illuminate\Contracts\Foundation\Application;use Illuminate\Http\Client\PendingRequest;use Illuminate\Support\Facades\Http; final readonly class PlanetscaleConnector{ public function __construct( private PendingRequest $request, ) {} public static function register(Application $app): void { $app->bind( abstract: PlanetscaleConnector::class, concrete: fn () => new PlanetscaleConnector( request: Http::baseUrl( url: '', )->timeout( seconds: 15, )->withHeaders( headers: [], )->asJson()->acceptJson(), ), ); }}
So the idea here is that all the information about how this class is registered into the container lives within the class itself. All the service provider needs to do is call the static register method on the class! This has saved me so much time when integrating with many APIs because I don't have to hunt for the provider and find the correct binding, amongst many others. I go to the class in question, which is all in front of me.
You will notice that currently, we have nothing being passed to the token or base url methods in the request. Let's fix that next. You can get these in your Planetscale account.
Create the following records in your .env
file.
PLANETSCALE_SERVICE_ID="your-service-id-goes-here"PLANETSCALE_SERVICE_TOKEN="your-token-goes-here"PLANETSCALE_URL="https://api.planetscale.com/v1"
Next, these need to be pulled into the application's configuration. These all belong in config/services.php
as this is where third-party services are typically configured.
return [ // the rest of your services config 'planetscale' => [ 'id' => env('PLANETSCALE_SERVICE_ID'), 'token' => env('PLANETSCALE_SERVICE_TOKEN'), 'url' => env('PLANETSCALE_URL'), ],];
Now we can use these in our PlanetscaleConnector
under the register method.
declare(strict_types=1); namespace App\Http\Integrations\Planetscale; use Illuminate\Contracts\Foundation\Application;use Illuminate\Http\Client\PendingRequest;use Illuminate\Support\Facades\Http; final readonly class PlanetscaleConnector{ public function __construct( private PendingRequest $request, ) {} public static function register(Application $app): void { $app->bind( abstract: PlanetscaleConnector::class, concrete: fn () => new PlanetscaleConnector( request: Http::baseUrl( url: config('services.planetscale.url'), )->timeout( seconds: 15, )->withHeaders( headers: [ 'Authorization' => config('services.planetscale.id') . ':' . config('services.planetscale.token'), ], )->asJson()->acceptJson(), ), ); }}
You need to send tokens over to Planetscale in the following format: service-id:service-token
, so we cannot use the default withToken
method as it doesn't allow us to customize it how we need to.
Now that we have a basic class created, we can start to think about the extent of our integration. We must do this when creating our service token to add the correct permissions. In our application, we want to be able to do the following: List databases. List database regions. List database backups. Create database backup. Delete database backup.
So, we can look at grouping these into two categories: Databases. Backups.
Let's add two new methods to our connector to create what we need:
declare(strict_types=1); namespace App\Http\Integrations\Planetscale; use App\Http\Integrations\Planetscale\Resources\BackupResource;use App\Http\Integrations\Planetscale\Resources\DatabaseResource;use Illuminate\Contracts\Foundation\Application;use Illuminate\Http\Client\PendingRequest;use Illuminate\Support\Facades\Http; final readonly class PlanetscaleConnector{ public function __construct( private PendingRequest $request, ) {} public function databases(): DatabaseResource { return new DatabaseResource( connector: $this, ); } public function backups(): BackupResource { return new BackupResource( connector: $this, ); } public static function register(Application $app): void { $app->bind( abstract: PlanetscaleConnector::class, concrete: fn () => new PlanetscaleConnector( request: Http::baseUrl( url: config('services.planetscale.url'), )->timeout( seconds: 15, )->withHeaders( headers: [ 'Authorization' => config('services.planetscale.id') . ':' . config('services.planetscale.token'), ], )->asJson()->acceptJson(), ), ); }}
As you can see, we created two new methods, databases
and backups
. These will return new resource classes, passing through the connector. The logic can now be implemented in the resource classes, but we must add another method to our connector later.
<?php declare(strict_types=1); namespace App\Http\Integrations\Planetscale\Resources; use App\Http\Integrations\Planetscale\PlanetscaleConnector; final readonly class DatabaseResource{ public function __construct( private PlanetscaleConnector $connector, ) {} public function list() { // } public function regions() { // }}
This is our DatabaseResource
; we have now stubbed out the methods we want to implement. You can do the same thing for the BackupResource
. It will look somewhat similar.
So the results can be paginated on the listing of databases. However, I will not deal with this here - I would lean on Saloon for this, as its implementation for paginated results is fantastic. In this example, we aren't going to worry about pagination. Before we fill out the DatabaseResource
, we need to add one more method to the PlanetscaleConnector
to send the requests nicely. For this, I am using my package called juststeveking/http-helpers
, which has an enum for all the typical HTTP methods I use.
public function send(Method $method, string $uri, array $options = []): Response{ return $this->request->send( method: $method->value, url: $uri, options: $options, )->throw();}
Now we can go back to our DatabaseResource
and start filling in the logic for the list method.
declare(strict_types=1); namespace App\Http\Integrations\Planetscale\Resources; use App\Http\Integrations\Planetscale\PlanetscaleConnector;use Illuminate\Support\Collection;use JustSteveKing\HttpHelpers\Enums\Method;use Throwable; final readonly class DatabaseResource{ public function __construct( private PlanetscaleConnector $connector, ) {} public function list(string $organization): Collection { try { $response = $this->connector->send( method: Method::GET, uri: "/organizations/{$organization}/databases" ); } catch (Throwable $exception) { throw $exception; } return $response->collect('data'); } public function regions() { // }}
Our list method accepts the parameter organization
to pass through the organization to list databases. We then use this to send a request to a specific URL through the connector. Wrapping this in a try-catch statement allows us to catch potential exceptions from the connectors' send method. Finally, we can return a collection from the method to work with it in our application.
We can go into more detail with this request, as we can start mapping the data from arrays to something more contextually useful using DTOs. I wrote about this here, so I won't repeat the same thing here.
Let's quickly look at the BackupResource
to look at more than just a get request.
declare(strict_types=1); namespace App\Http\Integrations\Planetscale\Resources; use App\Http\Integrations\Planetscale\Entities\CreateBackup;use App\Http\Integrations\Planetscale\PlanetscaleConnector;use JustSteveKing\HttpHelpers\Enums\Method;use Throwable; final readonly class BackupResource{ public function __construct( private PlanetscaleConnector $connector, ) {} public function create(CreateBackup $entity): array { try { $response = $this->connector->send( method: Method::POST, uri: "/organizations/{$entity->organization}/databases/{$entity->database}/branches/{$entity->branch}", options: $entity->toRequestBody(), ); } catch (Throwable $exception) { throw $exception; } return $response->json('data'); }}
Our create method accepts an entity class, which I use to pass data through the application where needed. This is useful when the URL needs a set of parameters and we need to send a request body through.
I haven't covered testing here, but I did write a tutorial on how to test JSON:API endpoints using PestPHP here, which will have similar concepts for testing an integration like this.
I can create reliable and extendible integrations with third parties using this approach. It is split into logical parts, so I can handle the amount of logic. Typically I would have more integrations, so some of this logic can be shared and extracted into traits to inherit behavior between integrations.
Technical writer at Laravel News, Developer Advocate at Treblle. API specialist, veteran PHP/Laravel engineer. YouTube livestreamer.