PHPackages                             jsdevart/laravel-managed-jobs - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. jsdevart/laravel-managed-jobs

ActiveLibrary

jsdevart/laravel-managed-jobs
=============================

Framework plug-and-play para background jobs con lifecycle tracking, progreso en tiempo real y manejo de archivos en Laravel.

07↑2900%PHP

Since Mar 28Pushed 1mo agoCompare

[ Source](https://github.com/JSDevArt/laravel-managed-jobs)[ Packagist](https://packagist.org/packages/jsdevart/laravel-managed-jobs)[ RSS](/packages/jsdevart-laravel-managed-jobs/feed)WikiDiscussions master Synced 1mo ago

READMEChangelogDependenciesVersions (1)Used By (0)

Laravel Managed Jobs
====================

[](#laravel-managed-jobs)

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

---

What it does
------------

[](#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
------------

[](#requirements)

PHP^8.2Laravel^11.0 | ^12.0---

Installation
------------

[](#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
----------------------

[](#minimal-implementation)

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

### 1. Tell the package who owns a job

[](#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

[](#2-define-the-jobs-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

[](#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

[](#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
-------

[](#job-api)

Methods available inside `handle()`:

MethodDescription`$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](#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
---------

[](#lifecycle)

The middleware in `BaseJob` manages status transitions automatically:

```
PENDING  →  RUNNING  →  COMPLETED
                      ↘  FAILED     (can be retried)
                      ↘  STOPPED    (can be retried)

```

StatusWhen`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 earlyWhen 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
--------------

[](#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
----------------------

[](#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`WhenPayload`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
---------------

[](#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
---------------

[](#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
-------------

[](#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

[](#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

[](#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

[](#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
---------------

[](#database-schema)

### `managed_jobs`

[](#managed_jobs)

ColumnType`job_id`BIGINTPrimary key, auto-increment`type`VARCHARFQCN of the job class`status`VARCHAR`pending` / `running` / `completed` / `failed` / `stopped``payload`JSONSerialized input parameters`state`JSONCheckpoint for fault-tolerant retries`progress_percentage`TINYINT0–100`progress_message`VARCHARCurrent step description`owner_user_id`BIGINTWho owns the job`owner_tenant_id`BIGINTTenant scope (nullable)`triggered_by_user_id`BIGINTAdmin acting on behalf (nullable)`started_at`TIMESTAMPWorker pick-up time`finished_at`TIMESTAMPCompletion / failure time`failed_reason`TEXTException message on failure### `managed_job_files`

[](#managed_job_files)

ColumnType`job_file_id`BIGINTPrimary key, auto-increment`job_id`BIGINTFK → `managed_jobs``filename`VARCHARDisplay name for downloads`path`VARCHARStorage path`mime_type`VARCHAR`size_bytes`BIGINT`expires_at`TIMESTAMP

###  Health Score

21

—

LowBetter than 19% of packages

Maintenance60

Regular maintenance activity

Popularity6

Limited adoption so far

Community8

Small or concentrated contributor base

Maturity11

Early-stage or recently created project

 Bus Factor1

Top contributor holds 50% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

### Community

Maintainers

![](https://www.gravatar.com/avatar/9ea9ba821d35e93223522b1a6cd9441fc8dfdd234df7850c552c5d8f6c6f2b4d?d=identicon)[jackpump](/maintainers/jackpump)

---

Top Contributors

[![jsarturo](https://avatars.githubusercontent.com/u/109831522?v=4)](https://github.com/jsarturo "jsarturo (1 commits)")[![JSDevart-acc](https://avatars.githubusercontent.com/u/79765160?v=4)](https://github.com/JSDevart-acc "JSDevart-acc (1 commits)")

### Embed Badge

![Health badge](/badges/jsdevart-laravel-managed-jobs/health.svg)

```
[![Health](https://phpackages.com/badges/jsdevart-laravel-managed-jobs/health.svg)](https://phpackages.com/packages/jsdevart-laravel-managed-jobs)
```

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
