laravel-managed-jobs maintained by jsdevart
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
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 |
| 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 — 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 |