« back published by @mmartin_joo on January 17, 2023
Working with 3rd Parties in Laravel
In this article, I'd like to focus on working with 3rd party services. In almost every project we need to integrate our application with some external APIs. It has become so common, but we often fail to come up with a standardized solution.
This is why I thought it'd be a fun project to show you. In this article, I'll write a Gumroad (e-commerce) SDK in three different ways:
- Using one service for the whole API. This is probably the common one.
- Having separate requests for each API endpoint.
- Using a package that solves some generic problems.
In a previous article, I already talked about APIs and used Gumroad API. However, this one will focus more on the structure of your project rather than the implementation of the SDK.
One Service
If there's only one takeaway from this article, it has to be this one: treat your 3rd parties as if they were mini-applications inside your application.
So each external API has its own namespace with DTOs, Value Objects, or Services if needed. This is how I implement this idea:
In the Services
folder, I create a new folder for each 3rd party and treat it like it was a mini-application inside my app. In a minute, I'll show you what the GumroadService
looks like, but first, let's take care of the configuration. There's a config/services.php
where we can configure the 3rd parties:
return [
'gumroad' => [
'access_token' => env('GUMROAD_ACCESS_TOKEN'),
'uri' => env('GUMROAD_URI'),
],
];
Usually, each service has its own service provider:
namespace App\Providers;
use App\Services\Gumroad\GumroadService;
use Illuminate\Support\ServiceProvider;
class GumroadServiceProvider extends ServiceProvider
{
public function register()
{
$this->app->singleton(
GumroadService::class,
fn () => new GumroadService(
config('services.gumroad.access_token'),
config('services.gumroad.uri'),
)
);
}
}
In this example, I use the singleton binding. This means Laravel will create one instance of the GumroadService
class with the values in the config files and every time a developer type-hints GumroadService
somewhere, the same singleton will be resolved from the container. It makes sense because there's no point in creating separate GumroadService
instances.
And finally, GumroadSerivce
uses these config values:
namespace App\Services\Gumroad;
class GumroadService
{
public function __construct(
private readonly string $accessToken,
private readonly string $uri,
) {}
}
Now, everything is ready to use this class:
class ProductController
{
public function index(GumroadService $gumroad)
{
$gumroad->products();
}
}
As I said earlier, the main focus will be the overall structure of our project, but let's quickly implement two endpoints.
Get all products:
namespace App\Services\Gumroad;
class GumroadService
{
/**
* @return Collection<ProductData>
*/
public function products(): Collection
{
$products = Http::get(
$this->url('products'),
$this->query(),
)->json('products');
return collect($products)
->map(fn (array $data) => ProductData::fromArray($data));
}
}
Get one product by ID:
namespace App\Services\Gumroad;
class GumroadService
{
public function product(string $id): ProductData
{
$product = Http::get(
$this->url("products/$id"),
$this->query(),
)->json('product');
return ProductData::fromArray($product);
}
}
They are pretty simple. In almost all cases, I use a query
and a url
helper function to make things a bit cleaner:
private function query(array $extra = []): array
{
return [
'access_token' => $this->accessToken,
...$extra,
];
}
private function url(string $path): string
{
return "{$this->uri}/$path";
}
They are quite straightforward. The last interesting thing about the GumroadService
is that it uses a ProductData
DTO:
namespace App\Services\Gumroad\DataTransferObjects;
use App\Services\Gumroad\ValueObjects\Price;
class ProductData
{
public function __construct(
public readonly string $name,
public readonly Price $price,
public readonly string $url,
) {}
public static function fromArray(array $data): self
{
return new self(
name: $data['name'],
price: Price::fromCents($data['price']),
url: $data['short_url'],
);
}
}
This is a plain PHP object, I don't use any packages. The interesting thing about Gumroad API (and a lot of other APIs) is that it returns the prices in cent value instead of the dollar. So if a product costs $19 it will be 1900. For that reason I use a value object called Price
:
namespace App\Services\Gumroad\ValueObjects;
class Price
{
public function __construct(
public readonly int $cents,
public readonly float $dollars,
public readonly string $formatted,
) {}
public static function fromCents(int $cents): self
{
return new self(
cents: $cents,
dollars: $cents / 100,
formatted: '$' . number_format($cents / 100, 2),
);
}
}
I can create a new Price
object from the cent value and the result will be:
$price = Price::fromCents(1900);
// Price object as an array
[
'cents': 1900,
'dollars': 19.00,
'formatted': '$19.00',
]
I find this approach very clean and high-level. And more importantly, it helps you avoid mistakes such as listing the cent value as it was in dollars, so a $19 product becomes $1900 on the frontend.
As you can see, all of these classes live inside the Gumroad
namespace. This is what I meant by "mini-application":
Separate Requests
The first approach works very well in most situations. The only problem is when you need to implement 20 API endpoints. in this scenario you can easily end up with 500+ lines long class, overgeneralized functions, and nasty if-else statements to handle strange edge cases. So it's easy to end up with a big, hard-to-maintain SDK class.
One approach to solve this problem is to treat 3rd party requests as if they were your own FormRequests
. When we are dealing with our own requests it's a standard to create a separate class for each one:
class ProductController
{
public function index(GetProductsRequest $request)
{
// ...
}
}
class GetProductsRequest extends FormRequest
{
// ...
}
Here's my question: why not do the same thing with 3rd party requests?
This is what the structure would look like:
In the Requests folder we have classes like:
- GetProductRequest
- GetProductsRequest
- GetSalesRequest
Just as they were standard form requests. Let's take a look at one of these requests:
namespace App\Services\Gumroad\Requests;
class GetProductsRequest extends Request
{
public function send(): Collection
{
$data = Http::get(
$this->url('products'),
$this->query(),
)->throw();
if (!$data['success']) {
throw GumroadRequestException::unknownError(
Arr::get($data, 'message', '')
);
}
return collect($data->json('products'))
->map(fn (array $data) => ProductData::fromArray($data));
}
}
This looks almost the same as the products
method earlier. The same is true for the GetProductRequest
:
namespace App\Services\Gumroad\Requests;
class GetProductRequest extends Request
{
public function send(string $id): ProductData
{
$data = Http::get(
$this->url("products/$id"),
$this->query(),
)->throw();
if (!$data['success']) {
throw GumroadRequestException::productNotFound($id);
}
return ProductData::fromArray($data->json('product'));
}
}
As you can see, I still use the url
and query
helpers, but now they are in the base Request
class:
namespace App\Services\Gumroad\Requests;
abstract class Request
{
public function __construct(
protected readonly string $accessToken,
protected readonly string $uri,
) {}
protected function query(array $extra = []): array
{
return [
'access_token' => $this->accessToken,
...$extra,
];
}
protected function url(string $path): string
{
return "{$this->uri}/$path";
}
}
Since Laravel already has more than one class called Request
you can easily call this one GumroadRequest
so it's not confusing. These requests can be used in two ways.
The "stand-alone" version:
$products = app(GetProductsRequest::class)->send();
The injected version:
class ProductController
{
public function index(GetProductsRequest $request)
{
$products = $request->send();
}
}
However, the first one looks a bit weird to me, while the second one is just confusing. We usually inject FormRequests
in methods. Now we have two kinds of requests. It's not optimal, in my opinion.
To solve these problems we can keep the GumroadService
class as an entry point to access these requests:
namespace App\Services\Gumroad;
class GumroadService
{
/**
* @return Collection<SaleData>
*/
public function sales(?Carbon $after = null): Collection
{
return app(GetSalesRequest::class)->send($after);
}
/**
* @return Collection<ProductData>
*/
public function products(): Collection
{
return app(GetProductsRequest::class)->send();
}
public function product(string $id): ProductData
{
return app(GetProductRequest::class)->send($id);
}
}
In this class, you can use either the app
function to resolve the requests, or you can inject them into the controller. I choose the first one because injecting 15 classes into the constructor looks weird.
This class can be used as usual:
class ProductController
{
public function index(GumroadService $gumroad)
{
$products = $gumroad->products();
}
}
There's one drawback of this solution. We don't need to bind a GumroadService
instance anymore in the service provider. Now the config values are consumed by request classes. I have a base Request
class that accepts the access token and base URL in the constructor. However, in GumroadService
I type-hint and inject the subclasses. For that reason, I need to bind these values for every subclass in the GumroadServiceProvider
:
namespace App\Providers;
class GumroadServiceProvider extends ServiceProvider
{
public function register()
{
$requests = [
GetProductsRequest::class,
GetProductRequest::class,
GetSalesRequest::class,
];
foreach ($requests as $requestClass) {
$this->app->singleton(
$requestClass,
fn () => new $requestClass(
config('services.gumroad.access_token'),
config('services.gumroad.uri')
)
);
}
}
}
So you need to add every request to the $requests
array. You can make it dynamic with some Reflection
magic, but it's still extra work.
Transporter by JustSteveKing
Using separate requests is a nice approach, in my opinion. However, there are some problems we ran into:
- Binding every request individually
- Using the
app
method to resolve the requests - Having a base
Request
class with generic helpers - Repeating the same
Http
calls inside requests
Fortunately, @JustSteveKing also ran into these problems and solved them with a package called laravel-transporter.
This is what a simple request looks like with transporter:
namespace App\Services\Gumroad\Requests;
class GetProductsRequest extends GumroadRequest
{
protected string $method = 'GET';
protected string $path = 'products';
}
Much less noise, right? And this is how it can be used from GumroadService
:
class GumroadService
{
/**
* @return Collection<ProductData>
*/
public function products(): Collection
{
return GetProductsRequest::build()
->send()
->throw()
->collect('products')
->map(fn (array $data) => ProductData::fromArray($data));
}
}
The send
method returns an Illuminate\Http\Client\Response
so we can use every Laravel helper, such as collect
.
As you can see the GetProductsRequest
extends the GumroadRequest
class. This is where I add the access token to every request:
namespace App\Services\Gumroad\Requests;
class GumroadRequest extends Request
{
public function __construct(HttpFactory $http)
{
parent::__construct($http);
parent::withQuery([
'access_token' => config('services.gumroad.access_token'),
]);
}
}
Earlier I used the query
method in every request to do the same. Now, I don't need to worry about it.
The only challenge with laravel-transporter is appending variables (such as an ID) to the URL. As far as I know, this is how you can do it:
namespace App\Services\Gumroad\Requests;
class GetProductRequest extends GumroadRequest
{
protected string $method = 'GET';
protected string $path = 'products/%s';
public function withProductId(string $productId): self
{
return $this->setPath(sprintf($this->path(), $productId));
}
}
And this is how you can use the withProductId
method when sending the request:
class GumroadService
{
public function product(string $id): ProductData
{
$data = GetProductRequest::build()
->withProductId($id)
->send()
->throw()
->json('product');
return ProductData::fromArray($data);
}
}
It could be a bit better, but this works just fine.
And the last piece of the puzzle is the configuration. Transporter only needs a base_uri
in order to know what URL to use when sending a request. We have a config/transporter.php
file:
return [
'base_uri' => env('GUMROAD_URI'),
];
By default, you can only have one base_uri
so it assumes that you have only one 3rd party in your application. However, it can be overwritten via the $baseUrl
variable in the base Request
class. For example, if you have to write an SDK for MailChimp you can do this:
class MailchimpRequest extends Request
{
public function __construct(HttpFactory $http)
{
parent::__construct($http);
$this->baseUrl = config('services.mailchimp.url');
}
}
Laravel Forge SDK
Another great example of separating your requests is Laravel Forge SDK. You can find the repo here: https://github.com/laravel/forge-sdk
If you don't know, Forge is a web-based service to manage servers and deployments. Forge SDK is a wrapper for the Forge API. If you take a look at the source code you'll find something like this:
It has 19 classes inside the Actions
namespace. Each of them takes care of one type of request, for example, ManagesServers
has methods like:
servers
returns all serversserver($serverId)
returns one servercreateServer
creates a new server
So it's not really one class for every request, but rather one class for one resource. It's also an excellent approach and you can handle big APIs with fewer classes, but still, your classes remain small.
However, these "actions" are in fact, traits not classes. There's a simple class called Forge
that uses all of these traits:
class Forge
{
use MakesHttpRequests,
Actions\ManagesJobs,
Actions\ManagesSites,
Actions\ManagesServers,
Actions\ManagesDaemons,
Actions\ManagesWorkers,
Actions\ManagesSSHKeys,
...;
}
Using this approach you still have a single entry-point class to access every request:
$forge = new Forge();
$servers = $forge->servers();
$server = $forge->server('abc-123');
$forge->createServer([...]);
This is very similar to what we did earlier. In fact, it's almost an identical approach:
- Each action is a request from the previous examples
- The
Forge
class is the equivalent of theGumroadService
By the way, if you like the idea of traits, you can do the same in your own SDKs.
I think writing separate requests for each API endpoint is a great approach, especially if you're writing an SDK that interacts with 10+ endpoints (or in general, it's big). By using a package like laravel-transporter, you can eliminate a lot of boilerplate and generic code.
This whole article comes from my new 250-page book Laravel Concepts. Check it out: