jsdevart / laravel-managed-jobs
Framework plug-and-play para background jobs con lifecycle tracking, progreso en tiempo real y manejo de archivos en Laravel.
Requires
- php: ^8.2
- illuminate/bus: ^11.0|^12.0|^13.0
- illuminate/console: ^11.0|^12.0|^13.0
- illuminate/contracts: ^11.0|^12.0|^13.0
- illuminate/database: ^11.0|^12.0|^13.0
- illuminate/events: ^11.0|^12.0|^13.0
- illuminate/filesystem: ^11.0|^12.0|^13.0
- illuminate/queue: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
This package is auto-updated.
Last update: 2026-04-09 18:07:08 UTC
README
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
ManagedJobrecord 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 (usa PHP ^8.3 si instalas Laravel 13) |
| Laravel | ^11.0 | ^12.0 | ^13.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 — satisfiesJobPayloadwithout modification. Just addimplements 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}— alwaysjobs.{owner_tenant_id}— only whengetManagedJobTenantId()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 |