« 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.

Laravel Concepts

So each external API has its own namespace with DTOs, Value Objects, or Services if needed. This is how I implement this idea:

Laravel Concepts

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":

Laravel Concepts

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:

Laravel Concepts

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:

Laravel Concepts

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 servers
  • server($serverId) returns one server
  • createServer 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 the GumroadService

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:

Laravel Concepts