Skip to main content
Laravel, shipping fast.

Some work genuinely can't be one job. Provisioning a site involves creating a repository, then, only once that repository exists, standing up hosting and generating content in parallel, then deploying, then confirming the deploy actually landed. Laravel's Bus::chain() runs steps strictly in order, and a chain step can itself be a Bus::batch() of jobs that run in parallel:

dispatch chain
create repository
batch (parallel): CDN setup · content generation · hosting setup
deploy
mark provisioning complete
// app/Jobs/SiteProvisionJob.php (condensed)
public function handle(): void
{
    self::markProvisionStarted($this->site, $this->webhook);

    Bus::chain([
        (new RepositoryCreateJob(site: $this->site, token: $this->token, webhook: $this->webhook))->onQueue('default'),
        Bus::batch([
            new CDNTenantCreateJob(site: $this->site, token: $this->token, webhook: $this->webhook),
            new AiContentCreateJob(site: $this->site, token: $this->token, webhook: $this->webhook),
            new ForgeSiteCreateJob(site: $this->site, token: $this->token, webhook: $this->webhook),
        ])->allowFailures(false),
        (new ForgeSiteDeployJob(site: $this->site, token: $this->token, webhook: $this->webhook))->onQueue('default'),
        function () {
            self::clearStage($this->site);
        },
    ])->catch(function (Throwable $exception) {
        self::failTerminally($this->site, $this->webhook, $exception);
    })->onQueue('default')->dispatch();
}

// Belt and suspenders: if the OUTER job fails before the chain is even
// dispatched, the chain's own ->catch() never runs at all.
public function failed(Throwable $exception): void
{
    self::failTerminally($this->site, $this->webhook, $exception);
}

allowFailures(false) on the batch means one failed job in that parallel group cancels the rest of the batch instead of letting siblings keep mutating external state around a doomed sibling. The chain's ->catch() is the one place that decides what "this multi-step process failed" means: it doesn't just log, it tears down whatever partial state got created. And failed() on the outer job exists because the chain's ->catch() only runs if the chain actually got dispatched; if the outer job itself throws before that (a serialization error, a queue connection blip), nothing else will ever call the cleanup path. Two different failure points, one shared terminal handler, made safe to call twice by the same Cache::add() claim from the idempotency section above.

Watchdogs for work that silently stalls

A job that throws is easy. A job that's polling an upstream API and just... keeps polling, forever, because the upstream never reaches a terminal state, is the harder failure. $tries won't save you here, because the job isn't failing, it's succeeding at each individual attempt and asking to be retried. You need a different stop condition: not "how many times," but "until when."

// app/Jobs/ForgeSiteProvisioningWaitJob.php (condensed)
class ForgeSiteProvisioningWaitJob implements ShouldBeEncrypted, ShouldQueue
{
    use Batchable, Queueable;

    public int $tries = 0; // Bounded by retryUntil() instead of a fixed attempt count.
    public int $timeout = 80;

    public function backoff(): array
    {
        return [1, 2, 3, 3, 3, 3, 4, 4, 5];
    }

    public function retryUntil(): \DateTimeInterface
    {
        return now()->addMinutes(3);
    }

    public function handle(): void
    {
        if ($this->batch()?->cancelled()) {
            return;
        }

        // ... resolve the bearer's Forge credentials, then poll.
        $api = ForgeNewAPI::boot($forgeToken);
        $api->existing($this->site);
        $this->site->refresh(); // read what Forge just reported, not stale metadata

        $status = $this->site->metadata['site']['status'] ?? '';

        if (in_array($status, ['creating', 'provisioning', 'installing', 'pending'], true)) {
            throw new PollingRetryException("Site provisioning in progress. Current status: {$status}");
        }

        if (in_array($status, ['created', 'installed'], true)) {
            return;
        }

        // Unknown/failed status is TERMINAL. With tries=0 + retryUntil(), a plain
        // throw would just keep polling a dead provision for the full window.
        // fail() bypasses retryUntil and ends the job now.
        $this->fail(new \Exception("Site provisioning failed or unexpected status: {$status}"));
    }
}

$tries = 0 paired with retryUntil() is a deliberate substitution: instead of "try nine times," it's "keep trying until three minutes from now, using whatever backoff schedule gets you there." PollingRetryException is a dedicated, ShouldntReport exception used only to mean "not done yet, ask again," so a normal polling cycle never shows up as noise in error tracking. And $this->fail() matters precisely because it's not a throw: a thrown exception under retryUntil() just gets retried again until the deadline, so a genuinely dead upstream status has to be failed explicitly, on purpose, right now, or it will poll a corpse for three more minutes.

retryUntil() handles the job that's still running but taking too long. It doesn't help with the job that never got picked up at all, or a multi-step chain that hung between two steps with nothing left throwing exceptions anywhere. For that you need something outside the job entirely: a scheduled command that looks for work stamped as started but never marked complete or failed, past a threshold, and force-fails it using the same terminal path a normal failure would use. That's the job of a watchdog: it doesn't wait to be told something is stuck, it goes looking.