Looking to hire Laravel developers? Try LaraJobs

laravel-managed-jobs maintained by jsdevart

Description
Framework plug-and-play para background jobs con lifecycle tracking, progreso en tiempo real y manejo de archivos en Laravel.
Last update
2026/04/09 20:06 (dev-master)
License
Links
Downloads
18

Comments
comments powered by Disqus

Laravel Managed Jobs

A Laravel package for managing background jobs with lifecycle tracking, real-time progress broadcasting, and file management.


What it does

You dispatch a job. The package:

  • Creates a ManagedJob record that tracks its full lifecycle (PENDING → RUNNING → COMPLETED / FAILED / STOPPED)
  • Broadcasts real-time progress events via WebSockets so your frontend can show a progress bar
  • Stores files generated by the job with automatic expiration
  • Fires lifecycle events (JobCompleted, JobFailed, etc.) your app can listen to

Requirements

PHP ^8.2
Laravel ^11.0 | ^12.0

Installation

composer require your-vendor/laravel-managed-jobs
php artisan migrate

Publish the config if you need to customise it:

php artisan vendor:publish --tag=managed-jobs-config

Minimal implementation

This section walks through the four things you need to write in your app.

1. Tell the package who owns a job

Implement JobOwner on your User model. The package uses this to scope jobs per user (and optionally per tenant).

use YourVendor\ManagedJobs\Contracts\JobOwner;

class User extends Authenticatable implements JobOwner
{
    public function getManagedJobOwnerId(): int|string
    {
        return $this->id;
    }

    public function getManagedJobTenantId(): int|string|null
    {
        return null; // return $this->tenant_id for multi-tenant apps
    }
}

2. Define the job's input

Implement JobPayload on any class that has a toArray() method.

use YourVendor\ManagedJobs\Contracts\JobPayload;

class GenerateReportPayload implements JobPayload
{
    public function __construct(
        public readonly string $dateFrom,
        public readonly string $dateTo,
    ) {}

    public function toArray(): array
    {
        return [
            'date_from' => $this->dateFrom,
            'date_to'   => $this->dateTo,
        ];
    }
}

Any class that already has a toArray() method — DTOs, Form Requests, Eloquent models — satisfies JobPayload without modification. Just add implements JobPayload.

3. Write the job

Extend BaseJob and implement handle().

use YourVendor\ManagedJobs\Jobs\BaseJob;

class GenerateReportJob extends BaseJob
{
    public function handle(): void
    {
        // Deserialize the stored payload back into your DTO
        ['date_from' => $from, 'date_to' => $to] = $this->jobExecution->payload;

        $rows  = Report::whereBetween('date', [$from, $to])->get();
        $total = $rows->count();

        foreach ($rows as $i => $row) {
            if ($this->isStopped()) {
                return; // user requested stop — exit cleanly
            }

            // ... your processing logic ...

            $this->updateProgress(
                percent: (int) (($i + 1) / $total * 100),
                message: "Processing row " . ($i + 1) . " of {$total}",
            );
        }
    }
}

4. Dispatch it

use YourVendor\ManagedJobs\Support\JobRunner;

$job = JobRunner::dispatch(
    job:     GenerateReportJob::class,
    payload: new GenerateReportPayload('2024-01-01', '2024-12-31'),
    owner:   $request->user(),
);

return response()->json(['job_id' => $job->job_id]);

That's it. The job record is created, the job is queued, and the lifecycle is tracked automatically.


Job API

Methods available inside handle():

Method Description
$this->updateProgress(int $percent, string $message = '') Save progress and broadcast job.progress
$this->isStopped(): bool Check whether the user requested a stop — refreshes from DB
$this->saveState(array $state): void Persist a checkpoint for fault-tolerant retries
$this->getState(): ?array Retrieve the last saved checkpoint
$this->addFile(...) Register a file generated by this job (see File management)
$this->jobExecution The ManagedJob Eloquent model

failed(Throwable $e) is called automatically by Laravel when the job exhausts its retry attempts. It sets status = FAILED, stores the error message, and fires JobFailed.


Lifecycle

The middleware in BaseJob manages status transitions automatically:

PENDING  →  RUNNING  →  COMPLETED
                      ↘  FAILED     (can be retried)
                      ↘  STOPPED    (can be retried)
Status When
PENDING Job dispatched, waiting for a worker
RUNNING Worker picked it up
COMPLETED handle() returned without errors
FAILED Unhandled exception, retries exhausted
STOPPED Externally flagged — job must check isStopped() and return early

When a job completes, the package fires a JobCompleted event. When a job fails, it fires a JobFailed event. What happens next is entirely up to your app — listen to those events and react however you need.


HTTP endpoints

The package does not register routes. Add them yourself based on what your app needs:

// routes/api.php  or  routes/web.php
Route::middleware('auth')->prefix('jobs')->group(function () {

    // List the authenticated user's jobs
    Route::get('/', function (Request $request) {
        return ManagedJob::where('owner_user_id', $request->user()->getManagedJobOwnerId())
            ->latest()
            ->paginate();
    });

    // Dispatch a new job
    Route::post('/', function (Request $request) {
        $validated = $request->validate([
            'date_from' => 'required|date',
            'date_to'   => 'required|date',
        ]);

        $job = JobRunner::dispatch(
            job:     GenerateReportJob::class,
            payload: new GenerateReportPayload($validated['date_from'], $validated['date_to']),
            owner:   $request->user(),
        );
        return response()->json(['job_id' => $job->job_id], 202);
    });

    // Stop a running/pending job
    Route::delete('/{jobId}', function (Request $request, string $jobId) {
        $job = ManagedJob::where('job_id', $jobId)
            ->where('owner_user_id', $request->user()->getManagedJobOwnerId())
            ->firstOrFail();

        $job->update(['status' => JobStatusEnum::STOPPED]);
        event(new JobStopped($job));
    });

    // Retry a failed or stopped job
    Route::post('/{jobId}/retry', function (Request $request, string $jobId) {
        $job = ManagedJob::where('job_id', $jobId)
            ->where('owner_user_id', $request->user()->getManagedJobOwnerId())
            ->firstOrFail();

        $job->update([
            'status'              => JobStatusEnum::PENDING,
            'progress_percentage' => 0,
            'progress_message'    => null,
            'failed_reason'       => null,
            'started_at'          => null,
            'finished_at'         => null,
        ]);

        DB::afterCommit(fn () => $job->type::dispatch($job));
    });

    // List non-expired files for a job
    Route::get('/{jobId}/files', function (Request $request, string $jobId) {
        $job = ManagedJob::where('job_id', $jobId)
            ->where('owner_user_id', $request->user()->getManagedJobOwnerId())
            ->firstOrFail();

        return $job->files()->where('expires_at', '>', now())->get();
    });

    // Download a file
    Route::get('/{jobId}/files/{fileId}/download', function (Request $request, string $jobId, string $fileId) {
        $job  = ManagedJob::where('job_id', $jobId)
            ->where('owner_user_id', $request->user()->getManagedJobOwnerId())
            ->firstOrFail();

        $file = $job->files()
            ->where('job_file_id', $fileId)
            ->where('expires_at', '>', now())
            ->firstOrFail();

        return Storage::download($file->path, $file->filename, [
            'Content-Type' => $file->mime_type,
        ]);
    });
});

In a real app you would extract this into a controller class. The inline closures above are for readability.


Real-time broadcasting

All events broadcast via Laravel's broadcasting system to two channels:

  • jobs.{owner_user_id} — always
  • jobs.{owner_tenant_id} — only when getManagedJobTenantId() returns a non-null value
Event broadcastAs When Payload
JobStarted job.started Worker picks up the job (status → RUNNING) job_id, type, status
JobProgressUpdated job.progress updateProgress() called inside handle() job_id, progress (0–100), progress_message
JobCompleted job.completed handle() returned without errors job_id, status
JobStopped job.stopped Your app updates status to STOPPED and fires this event manually job_id
JobFailed job.failed Unhandled exception, retries exhausted job_id, failed_reason

Frontend example (Laravel Echo):

Echo.channel(`jobs.${userId}`)
    .listen('.job.progress',  (e) => updateProgressBar(e.progress, e.progress_message))
    .listen('.job.completed', (e) => showDownloadButton(e.job_id))
    .listen('.job.failed',    (e) => showError(e.failed_reason));

File management

Register files produced by the job so users can download them later:

public function handle(): void
{
    // ... generate a CSV ...
    $path = "exports/{$this->jobExecution->getKey()}/report.csv";
    Storage::put($path, $csv);

    $this->addFile(
        path:      $path,
        filename:  'report.csv',
        mimeType:  'text/csv',
        sizeBytes: Storage::size($path),
        // expiresAt: Carbon instance — defaults to now() + config('managed-jobs.file_expiry_days')
    );
}

The managed-jobs:expire-files command deletes physical files whose expires_at has passed and soft-deletes their database records. It runs automatically every day at the configured time.

Run it manually:

php artisan managed-jobs:expire-files

Fault tolerance

Use saveState() to write a checkpoint after each unit of work. On retry, read it back with getState() to skip already-processed items:

public function handle(): void
{
    $lastId = $this->getState()['last_id'] ?? 0;

    Item::where('id', '>', $lastId)->lazyById()->each(function (Item $item) {
        if ($this->isStopped()) {
            return false;
        }

        // ... process ...

        $this->saveState(['last_id' => $item->id]);
    });
}

Configuration

Full reference after publishing with php artisan vendor:publish --tag=managed-jobs-config:

return [
    // Your User model — must implement JobOwner
    'user_model'       => \App\Models\User::class,
    'user_primary_key' => 'id',

    // Days before job-generated files expire (default: 3)
    'file_expiry_days' => 3,

    // Optional prefix for table names: 'bg_' → bg_managed_jobs, bg_managed_job_files
    // Must also be applied in your published migrations.
    'table_prefix' => '',

    // Broadcasting
    'broadcasting' => [
        'enabled'        => true,
        'channel_prefix' => 'jobs',   // → jobs.{userId}
        'channel_type'   => 'public', // 'public' | 'private' | 'presence'
    ],

    // Queue settings applied to all managed jobs
    'queue' => [
        'connection' => null, // null = Laravel default
        'name'       => null, // null = connection default
    ],

    // Filesystem disk used for job file operations
    'storage' => [
        'disk' => null, // null = Laravel default
    ],

    // Scheduler for the expire-files command
    'schedule' => [
        'enabled'             => true,
        'expire_files_at'     => '22:00',
        'without_overlapping' => 5,    // minutes, or false to disable
        'on_one_server'       => true, // requires atomic-lock cache driver (Redis)
        'run_in_background'   => true,
    ],
];

Reacting to lifecycle events

The package fires a plain Laravel event at every status transition. Listen to them in your AppServiceProvider or EventServiceProvider and do whatever your app needs:

use YourVendor\ManagedJobs\Events\JobCompleted;
use YourVendor\ManagedJobs\Events\JobFailed;

// Send an email
Event::listen(JobCompleted::class, function (JobCompleted $event) {
    $event->jobRecord->owner?->notify(new YourJobCompletedNotification($event->jobRecord));
});

// Log the failure, alert on Slack, trigger a webhook — anything
Event::listen(JobFailed::class, function (JobFailed $event) {
    Log::error("Job failed: {$event->jobRecord->failed_reason}");
});

All five events (JobStarted, JobProgressUpdated, JobCompleted, JobStopped, JobFailed) expose $event->jobRecord — the ManagedJob model with full state.

Using private broadcast channels

Set channel_type to 'private' and define the authorization rule in routes/channels.php:

// config/managed-jobs.php
'broadcasting' => ['channel_type' => 'private'],

// routes/channels.php
Broadcast::channel('jobs.{userId}', function ($user, $userId) {
    return (int) $user->id === (int) $userId;
});

Per-dispatch queue override

JobRunner::dispatch(
    job:        HeavyJob::class,
    payload:    $payload,
    owner:      $user,
    queue:      'heavy',       // overrides config queue.name for this dispatch only
    connection: 'sqs',         // overrides config queue.connection for this dispatch only
);

Database schema

managed_jobs

Column Type
job_id BIGINT Primary key, auto-increment
type VARCHAR FQCN of the job class
status VARCHAR pending / running / completed / failed / stopped
payload JSON Serialized input parameters
state JSON Checkpoint for fault-tolerant retries
progress_percentage TINYINT 0–100
progress_message VARCHAR Current step description
owner_user_id BIGINT Who owns the job
owner_tenant_id BIGINT Tenant scope (nullable)
triggered_by_user_id BIGINT Admin acting on behalf (nullable)
started_at TIMESTAMP Worker pick-up time
finished_at TIMESTAMP Completion / failure time
failed_reason TEXT Exception message on failure

managed_job_files

Column Type
job_file_id BIGINT Primary key, auto-increment
job_id BIGINT FK → managed_jobs
filename VARCHAR Display name for downloads
path VARCHAR Storage path
mime_type VARCHAR
size_bytes BIGINT
expires_at TIMESTAMP