Skip to main content
Laravel, shipping fast.

Statamic rate limits its API. When you are over the limit it answers 429, usually with a Retry-After header. Laravel’s HTTP client can retry for you, and it lets you say exactly when and for how long:

// app/Services/License/StatamicDriver.php
private function http(): PendingRequest
{
    return Http::statamic($this->token)->retry(
        times: 3,
        sleepMilliseconds: $this->backoff(...),
        when: $this->wasThrottled(...),
        throw: false,
    );
}
private function wasThrottled(Exception $e): bool
{
    return $e instanceof RequestException
        && $e->response->tooManyRequests();
}

private function backoff(int $attempt, Exception $e): int
{
    $retryAfter = $e instanceof RequestException
        ? $e->response->header('Retry-After')
        : null;

    $delay = is_numeric($retryAfter)
        ? (int) $retryAfter * 1000
        : 500 * 2 ** ($attempt - 1);

    return min($delay, 5000);
}

It retries only on 429. A 429 means the provider refused the request before doing any work, so replaying it is safe whatever the method. That reasoning does not extend to a 500 or a timeout, where you have no idea how far the other side got. Those are not retried.

The backoff is clamped. A provider that returns Retry-After: 3600 is asking your worker to sleep for an hour inside a request that will be killed long before that. Honor the hint, but never past your own timeout.

And throw: false means the client hands back the last response instead of throwing after the final attempt. The driver decides what a failure means, which is how delete can treat a 404 as success. (Laravel documentation: HTTP Client › Retries.)

One Name for the Application to Call

The driver exists. The controller needs a way to reach it without naming it. Two small pieces do that.

First, tell the container which implementation stands behind the contract:

// app/Providers/AppServiceProvider.php
public function register(): void
{
    $this->app->singleton(
        LicenseContract::class,
        fn () => new StatamicDriver(
            config('services.statamic.token'),
        ),
    );
}

Then give it a facade:

// app/Facades/Licenses.php
/**
 * @method static Collection<int, License> all()
 * @method static License create(string $name, string $domain)
 * @method static void delete(string $key)
 */
class Licenses extends Facade
{
    protected static function getFacadeAccessor(): string
    {
        return LicenseContract::class;
    }
}

Licenses::all() asks the container for whatever is bound to LicenseContract and calls all() on it. One hop, and the controller’s one line reaches Statamic.

A facade is named after the thing it gives you access to, the way Cache and Queue are. You don’t have to use one. Type-hinting LicenseContract in a controller method works just as well, and some teams prefer it. This book uses the facade because it reads the way the rest of Laravel reads. (Laravel documentation: Facades.)

In a test, replace what the facade points at:

$this->mock(LicenseContract::class)
    ->shouldReceive('all')
    ->once()
    ->andReturn(collect());

$this->mock() puts a test double in the container under the contract’s name, and Licenses::all() now reaches the double. Laravel’s own facades are usually replaced with Cache::shouldReceive(), and Licenses::shouldReceive() would work today, given a token in the test environment. It stops working in Chapter 4. To learn which class to imitate, shouldReceive() builds the real driver first, and from Chapter 4 on the real driver can’t be built without a consumer and its credentials. (Laravel documentation: Mocking › Mocking Objects.)

The configuration is ordinary:

// config/services.php
'statamic' => [
    'url' => env('STATAMIC_API_URL'),
    'token' => env('STATAMIC_API_TOKEN'),
],

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