Skip to main content
Laravel, shipping fast.

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

The audio could not be loaded. Try again in a moment.