spatie/laravel-long-running-tasks
Monitor externally executed long-running tasks in Laravel by polling instead of webhooks. Extend LongRunningTask, implement check() returning TaskResult::ContinueChecking or StopChecking, and start with MyTask::make()->meta($data)->start(). Checks run on a configurable interval until the task...
Install and scaffold the package:
composer require spatie/laravel-long-running-tasks
php artisan vendor:publish --tag="long-running-tasks-migrations"
php artisan vendor:publish --tag="long-running-tasks-config"
php artisan migrate
Confirm a queue worker is actually running before you write any task code — this package does nothing without one. Every check re-dispatches RunLongRunningTaskJob onto the configured queue, so if QUEUE_CONNECTION=sync in your env, or the worker is down, tasks will appear to "start" (the log row is created) but never proceed past pending. For local dev this is the single most common first-day failure.
The first use case to reach for: an external API that returns a job/prediction/render ID and requires you to poll for status. AWS Rekognition, transcription services, video renders, OCR, ML inference endpoints, or even a slow internal batch job you want to observe from the web UI without a cron hack.
Skim, in this order:
config/long-running-tasks.php — specifically queue, default_check_frequency_in_seconds, keep_checking_for_in_seconds.Spatie\LongRunningTasks\Models\LongRunningTaskLogItem — this is the table you'll query from your UI, dashboards, and tests.Spatie\LongRunningTasks\Strategies\* — you'll likely want backoff for anything that isn't a fast poll.A minimal working task:
use Spatie\LongRunningTasks\LongRunningTask;
use Spatie\LongRunningTasks\Models\LongRunningTaskLogItem;
use Spatie\LongRunningTasks\Enums\TaskResult;
class PollRekognitionJob extends LongRunningTask
{
public function __construct(
public string $jobId,
public int $userId,
) {}
public function check(LongRunningTaskLogItem $logItem): TaskResult
{
$status = Aws::rekognition()->describeJob($this->jobId)->status();
if ($status === 'SUCCEEDED') {
$this->applyResults($logItem->meta['bucket'], $this->jobId);
return TaskResult::StopChecking;
}
if ($status === 'FAILED') {
throw new RuntimeException("Rekognition job {$this->jobId} failed");
}
return TaskResult::ContinueChecking;
}
}
Then PollRekognitionJob::make(jobId: $id, userId: $user->id)->start();
Note the constructor: make() accepts named args and reconstructs the task instance from serialized data on every subsequent check. That's how state survives between polls without stuffing everything into meta.
meta is a JSON column and is a good place for small configuration values you want visible/queryable per task row (e.g. a userId, a webhook_url, a bucket). But it's persisted to the DB and readable from the log table, so don't put secrets or large payloads there. Put the identity/parameters of your external job (job ID, model ID, token references) on the constructor; reconstruct them on each run via make(). Keep check() focused on the diff between "last polled" and "now".
Default is fixed-interval at check_frequency_in_seconds. For real-world external services that's usually wrong:
1, 6, 12, 30, 60 × your interval. With the 10s default this is 10s → 60s → 120s → 300s → 600s. In practice this is the most common one to pick for external APIs.Set the strategy per-task-class via public string $checkStrategy = ...; and per-instance via ->checkStrategy(...). Per-class is usually enough; per-instance is useful when the same task class handles both "cheap poll" and "expensive poll" endpoints.
onFail hook to distinguish transient vs terminal errorsExceptions in check() are caught and written to latest_exception (message + trace). By default, no further checks happen — the log row moves to failed. If you want retry-on-failure semantics, implement onFail:
public function onFail(LongRunningTaskLogItem $logItem, Exception $e): ?TaskResult
{
if ($e instanceof RateLimitException) {
return TaskResult::ContinueChecking;
}
if ($e instanceof GoneException) {
return null; // terminal — stop, keep status failed
}
Log::warning('Task check failed', ['task' => $logItem->task, 'meta' => $logItem->meta, 'e' => $e]);
return null;
}
Returning ContinueChecking reschedules; returning null (or nothing) stops. This is where you also want to alert/slack/notify — because the swallowed exception will not otherwise show up in your normal Laravel error reporting unless you re-log it.
The default table is generic. In practice you'll want at least a user_id and a label/display_name so your "My jobs" UI is usable. Subclass LongRunningTaskLogItem, point 'log_model' at it in config, and extract values from meta in booted():
static::creating(function ($logItem) {
$logItem->user_id = $logItem->meta['user_id'];
$logItem->label = $logItem->meta['label'] ?? class_basename($logItem->task);
unset($logItem->meta['user_id'], $logItem->meta['label']);
});
Do the same for updating if you need derived fields based on status transitions. If you do this, remember to re-add casts/fillable in your subclass.
The log table is the intended source of truth for UI. A typical pattern:
LongRunningTaskLogItem::query()
->where('status', Status::Running)
->where('user_id', auth()->id())
->latest()
->paginate();
Because status is an enum-backed column (see Spatie\LongRunningTasks\Enums\Status), use the enum in queries, not raw strings — it keeps you honest when the package adds new states.
Put polls on a dedicated queue ('queue' => 'polls') so a slow external API never starves your transactional jobs. Set per-task via public string $queue = 'polls'; and run a dedicated worker. With backoff strategies, most polls sit idle anyway — a single worker is often enough for hundreds of concurrent tasks.
Don't scatter MyTask::make(...)->start() calls. Wrap in a service or model method:
public function startAiAnalysis(): LongRunningTaskLogItem
{
return AnalyzeImageTask::make(imageId: $this->id)
->meta(['user_id' => $this->user_id, 'label' => 'Image analysis'])
->checkStrategy(ExponentialBackoffCheckStrategy::class)
->start();
}
This is your extension point — the place to add policies, quotas, and audit logging.
keep_checking_for_in_seconds is a hard wall, not a soft timeout. Default 300 seconds. If your external job regularly takes longer than 5 minutes, your task will be marked didNotComplete and never touched again. Set this per-task (public int $keepCheckingForInSeconds = 3600;) or per-instance. This is the most likely thing to bite you in production: tasks that "worked in dev" (fast) but silently die in prod (slow).
didNotComplete is terminal — there is no automatic resume. Once a task ages past the wall, stop_checking_at is enforced and the row remains as an audit record. If you need resumption, you start a new task with the same meta.
The check() exception is swallowed, not rethrown. It's written to latest_exception and the job completes successfully. Your usual Log::error / Sentry pipeline will not see it. Always funnel through onFail if you want observability.
latest_exception is stale on retry. The column holds the last exception, and the attempt counter tracks retries. If a task succeeds after 3 failures, latest_exception still shows the last error. Don't treat a non-null latest_exception as "failed" without also checking status.
The class-level property and the instance-level method share names for a reason but look identical in IDE hints. public int $checkFrequencyInSeconds = 20; (property) vs ->checkFrequencyInSeconds(30) (fluent setter) and public string $checkStrategy = ... vs ->checkStrategy(...). Both work; they are separate symbols. Just don't try to call the property as a method or vice versa in your own subclass overrides.
meta round-trips through JSON. Objects become arrays; Carbon dates become strings. If you need typed meta inside check(), cast manually or move typed state to constructor-promoted properties.
The Events section of the README is empty. There are no task lifecycle events exposed — no TaskStarted, TaskCompleted, TaskFailed. If you need them, hook via the model's updating event, or wrap start()/onFail() yourself. Don't hold your breath for the package to add them.
Queue jobs can double-check if you're using database queues with retries. A RunLongRunningTaskJob that times out mid-poll may be retried and re-invoke check(). Make check() idempotent against your external service: check status before submitting results, guard writes by a state token, etc.
Cleanup is your job. Nothing prunes long_running_task_log_items. On a modest app you'll accumulate thousands of rows within weeks. Add a scheduled command deleting completed/failed/didNotComplete rows older than N days.
Testing is straightforward with Bus::fake(). Task::make(...)->start() creates the log row and dispatches the job — assert against both. To test the state machine, dispatch the job synchronously (Bus::fake() off) and mock the external API in check(). The LongRunningTaskLogItem model is a regular Eloquent model, so factory-style creation via LogItem::factory()->create(['status' => Status::Running, ...]) is quick.
Low adoption (38 stars, 0 dependents) — pin the version and read the changelog on upgrades. The current release is recent, the API is thin enough that you can read the whole source in an afternoon, and there is no third-party ecosystem around it. If your use case outgrows it (e.g. you need per-attempt custom job payloads, or distributed locking across workers), you'll be forking or replacing it — so keep your task classes thin and put business logic in services you control, not in check().
composer require will pull illuminate/queue; ensure your horizon/worker config covers the queue value you set. A mismatch ('queue' => 'polls' but worker only listens on default) is a silent no-op: rows created, nothing ever runs, no error.
How can I help you explore Laravel packages today?