Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Long Running Tasks Laravel Package

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

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  1. The published config/long-running-tasks.php — specifically queue, default_check_frequency_in_seconds, keep_checking_for_in_seconds.
  2. Spatie\LongRunningTasks\Models\LongRunningTaskLogItem — this is the table you'll query from your UI, dashboards, and tests.
  3. 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.

Implementation Patterns

Meta for data, constructor for identity

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

Choose a check strategy deliberately

Default is fixed-interval at check_frequency_in_seconds. For real-world external services that's usually wrong:

  • LinearBackoffCheckStrategy — good for slow-starting jobs (video renders, ML inference). First check fast, then progressively back off.
  • ExponentialBackoffCheckStrategy — good for "might take 30 minutes or 30 seconds" cases.
  • StandardBackoffCheckStrategy — the package's preset sequence 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.

Use the onFail hook to distinguish transient vs terminal errors

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

Own the log model when you need columns

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.

Build a query interface from the log model

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.

Reuse the queue

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.

Wrap start() in your own domain method

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.

Gotchas and Tips

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

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
robertmarney/lara-hierarchial-collections
simonschaufi/rule-doc-generator
emissaoja/emissaoja
blackbricksoftware/gravityforms-civicrm-form-processor
al-rahabi/fancy-crud
jinas/dhivatar-component
mltstephane/laravel-quality
bernskiold/laravel-talentlms
bernskioldmedia/laravel-form-architect
youbar/easy-crud
c-point-labs/laravel-hostyourai
dbravoan/dbcms-admin-theme-dbcms
dbravoan/dbcms-theme-db-cyber
dbravoan/dbcms-blocks-basic
dbravoan/dbcms-module-content-audit
dbravoan/dbcms-module-ai-assistant
paygate-to/paygate-php
padosoft/askmydocs-connector-base
defstudio/supercharged-carbon
bernskioldmedia/laravel-matomo