Before writing the code that calls Statamic, decide what the rest of the application is allowed to ask for:
// app/Services/License/LicenseContract.php
interface LicenseContract
{
/** @return Collection<int, License> */
public function all(): Collection;
public function create(
string $name,
string $domain,
): License;
public function delete(string $key): void;
}
Three operations, and every one of them speaks in the application’s types. Nothing in this interface mentions HTTP, Statamic, or an array. That is what makes it a boundary: the controller depends on this, and this depends on nothing.
A Client, Configured Once
Every call to the provider needs the same base URL, the same headers, and the same time limits. Laravel’s HTTP client lets you give that configuration a name. (Laravel documentation: HTTP Client › Macros.)
// app/Providers/AppServiceProvider.php
public function boot(): void
{
Http::macro('statamic', function (string $token) {
return Http::baseUrl(config('services.statamic.url'))
->withToken($token)
->acceptJson()
->connectTimeout(3)
->timeout(10);
});
}
From now on Http::statamic($token) is a client that already knows where Statamic is and how long to wait for it. The token is a parameter and not read from config inside the macro, for a reason Chapter 4 makes important: different callers will use different tokens.
The Driver
A driver is one implementation of the contract for one provider. It is the only class in the project that knows Statamic’s URLs:
// app/Services/License/StatamicDriver.php
class StatamicDriver implements LicenseContract
{
public function __construct(private string $token) {}
public function all(): Collection
{
$sites = $this->http()->get('/sites')
->throw()
->json('data');
if (! is_array($sites)) {
throw new MalformedProviderResponse(['data']);
}
return collect($sites)->map(
fn ($row) => License::fromProvider((array) $row),
);
}
}
throw() turns a failed response into an exception. A driver that returns an empty collection when the provider is down is lying to its caller.
The is_array check closes a less obvious version of the same mistake. A provider having a bad day sometimes answers 200 with an HTML error page. Without the check, that page has no data key, the list comes back empty, and a consumer concludes that it has no licenses. The (array) cast is the same care one level down: an element that isn’t an array becomes one that fails validation, so a malformed list ends as the application’s own exception and never as a type error.
Creating a license follows the same shape:
// app/Services/License/StatamicDriver.php
public function create(
string $name,
string $domain,
): License {
$data = $this->http()->post('/sites', [
'name' => $name,
'domain' => $domain,
])->throw()->json('data');
return License::fromProvider((array) $data);
}
delete has one more decision in it:
// app/Services/License/StatamicDriver.php
public function delete(string $key): void
{
$response = $this->http()
->withUrlParameters(['key' => $key])
->delete('/sites/{key}');
if ($response->notFound()) {
return;
}
$response->throw();
}
A 404 on delete is treated as success. The caller wanted the license gone, and it is gone. This matters as soon as a delete can be retried, which Chapter 10 makes it: the second attempt must not fail because the first one worked.
withUrlParameters() puts the key into the path safely. The key arrives from a URL that a consumer typed, and a value like abc?force=1 or ../admin concatenated into a path would send a request you never meant to send. The URL template encodes it.
It can’t help with a key that is nothing but .., which is a legal path segment and climbs one level. Close that at the route. Route::pattern('license', '[A-Za-z0-9_-]+') in AppServiceProvider::boot() means such a request never matches at all. (Laravel documentation: HTTP Client › URI Templates.)