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
Requires (Dev)
- orchestra/testbench: ^9.0|^10.0|^11.0
- phpunit/phpunit: ^10.5|^11.0|^12.0
This package is auto-updated.
Last update: 2026-07-23 03:21:51 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 jsdevart/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 wiring the package into your app — one decision (who owns a job) and three pieces of code (payload, job, dispatch).
1. Decide who owns a job
Nothing to implement. A job's owner is any Eloquent model — a User, a
Team, a Tenant, a Service — stored polymorphically (owner_type /
owner_id). You just pass the model to JobRunner::dispatch() (step 4) and read
it back with $job->owner.
Optionally register a morph map
so the database stores short, stable aliases instead of full class names (this
also keeps broadcast channel names tidy — jobs.user.5 instead of
jobs.app_models_user.5):
// AppServiceProvider::boot() use Illuminate\Database\Eloquent\Relations\Relation; Relation::enforceMorphMap([ 'user' => \App\Models\User::class, 'tenant' => \App\Models\Tenant::class, 'service' => \App\Models\Service::class, ]);
Upgrading from v1? The old
JobOwnerinterface (getManagedJobOwnerId()/getManagedJobTenantId()) is no longer required — see UPGRADE.md.
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::ownedBy($request->user()) ->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) ->ownedBy($request->user()) ->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) ->ownedBy($request->user()) ->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) ->ownedBy($request->user()) ->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) ->ownedBy($request->user()) ->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
By default every event broadcasts on a single channel scoped to the job's owner:
jobs.{ownerType}.{ownerId}— e.g.jobs.user.5,jobs.tenant.9,jobs.service.12
The owner type is part of the channel name, so owners of different types no
longer collide even when their ids match (a user 7 job and a tenant 7 job
resolve to jobs.user.7 and jobs.tenant.7). ownerType is the morph-map alias
when you register one, otherwise a snake_case of the class name — so
registering a morph map is recommended to give
every type a distinct, stable alias (two classes sharing a basename would
otherwise collapse to the same segment).
Need to broadcast somewhere else — a tenant-wide channel, a team channel, several
channels at once? That is application policy, so the package hands it to you: see
Custom channel policy below. To keep the flat v1-style
name (jobs.{id}), set broadcasting.include_owner_type to false — only safe
when every job is owned by a single owner type.
| 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.user.${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 [ // 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 policy. Swap for your own JobChannelResolver to control // exactly which channels events broadcast on (tenant, team, service…). 'resolver' => \YourVendor\ManagedJobs\Support\DefaultJobChannelResolver::class, 'channel_prefix' => 'jobs', // → jobs.{ownerType}.{ownerId} 'include_owner_type' => true, // false → v1-style jobs.{id} (single owner type only) '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 — matches the default jobs.{ownerType}.{ownerId} name Broadcast::channel('jobs.user.{userId}', function ($user, $userId) { return (int) $user->id === (int) $userId; });
Custom channel policy
Where events broadcast is application policy, so the package lets you own it.
Implement JobChannelResolver and point the config at your class — this is the
supported way to broadcast to a tenant, a team, several channels at once, or with
any naming you like, without patching the package:
namespace App\ManagedJobs; use Illuminate\Broadcasting\PrivateChannel; use YourVendor\ManagedJobs\Contracts\JobChannelResolver; use YourVendor\ManagedJobs\Models\ManagedJob; class TenantChannelResolver implements JobChannelResolver { public function channelsFor(ManagedJob $job): array { if (! config('managed-jobs.broadcasting.enabled', true)) { return []; } $channels = [new PrivateChannel("jobs.user.{$job->owner_id}")]; // Broadcast to a tenant-wide channel too, derived from the owner. if ($tenantId = $job->owner?->tenant_id) { $channels[] = new PrivateChannel("jobs.tenant.{$tenantId}"); } return $channels; } }
// config/managed-jobs.php 'broadcasting' => [ 'resolver' => \App\ManagedJobs\TenantChannelResolver::class, ],
Authorize each channel your resolver emits in routes/channels.php.
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_type |
VARCHAR | Owner model class / morph alias |
owner_id |
VARCHAR | Owner key (int or ULID/UUID) |
triggered_by_type |
VARCHAR | Actor model class / morph alias (nullable) |
triggered_by_id |
VARCHAR | Actor key (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 |