mateusjunges/laravel-trackable-jobs
Track Laravel queued jobs in the database: see when a job is queued, running, finished, or failed, monitor progress and output, and query job status from your app. A simple trait-based package for adding job tracking to any queueable job.
Install the package and publish its assets, then migrate the tracking table:
composer require mateusjunges/laravel-trackable-jobs
php artisan vendor:publish --provider="MateusJunges\TrackableJobs\TrackableJobsServiceProvider"
php artisan migrate
If the provider command publishes more than you need, run php artisan vendor:publish interactively and publish only the package’s config/migration tags. First place to look after install:
config/trackable-jobs.php if published — check the tracked job model, table, and connection options.tracked_jobs schema and indexes.MateusJunges\TrackableJobs\Models\TrackedJob — this is the model you will query for dashboards and APIs.MateusJunges\TrackableJobs\Traits\Trackable — this is the trait you add to jobs.Add the trait to a queued job:
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use MateusJunges\TrackableJobs\Traits\Trackable;
class GenerateReport implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels, Trackable;
public function __construct(public int $reportId)
{
}
public function handle(): void
{
// Generate the report...
}
}
Your first practical use case should be a user-triggered long-running job, such as a report export or CSV import. Dispatch it normally, capture the tracking identifier, and expose a small status endpoint:
$job = new GenerateReport($report->id);
dispatch($job);
return response()->json([
'tracking_id' => $job->trackedJob->uuid,
]);
Then poll the tracking record:
use MateusJunges\TrackableJobs\Models\TrackedJob;
$tracked = TrackedJob::where('uuid', $request->tracking_id)->firstOrFail();
return response()->json([
'status' => $tracked->status,
'started_at' => $tracked->started_at,
'finished_at' => $tracked->finished_at,
'output' => $tracked->output,
]);
The package is not a replacement for Horizon or Sentry. Treat it as a lightweight job-run ledger: it tells you what happened to a specific job, not why the queue is slow or why an exception occurred.
Status polling API for user-facing jobs. Store the tracking UUID against your domain record, e.g. reports.tracking_uuid, rather than making the UI remember it. That gives you authorization checks and lets users revisit the status page later.
$report->update([
'tracking_uuid' => $job->trackedJob->uuid,
]);
Admin job dashboard. Query the tracking model directly for operations screens:
$jobs = TrackedJob::query()
->when(request('status'), fn ($q, $status) => $q->where('status', $status))
->when(request('job'), fn ($q, $job) => $q->where('name', 'like', "%{$job}%"))
->latest()
->paginate(50);
Useful filters: status, job class/name, started_at, finished_at, and failed jobs older than N minutes. Add a “stuck” view for records that have been running longer than expected.
Correlate jobs with domain entities. The package tracks the job, not your business object. Keep the relationship in your own table:
// reports table
tracking_uuid nullable uuid
status string
completed_at nullable timestamp
Then a model observer or job completion check can reconcile the tracked job with the report. This avoids coupling your domain schema to a third-party package’s table.
Write progress/output deliberately. For jobs that produce a result path, summary, or error detail, update the tracked record from the job or from a service it calls. If your package version exposes a helper, use it; otherwise update the TrackedJob row by UUID. Keep output short and non-sensitive. Do not store entire exception traces or personally identifiable payloads in output.
Failure alerting. A scheduled command is usually enough:
TrackedJob::where('status', 'failed')
->where('finished_at', '>=', now()->subHour())
->each(fn ($job) => Notification::route('slack', config('services.slack.jobs'))
->notify(new JobFailed($job)));
If you need “alert once” behavior, add your own notified_at column or cache key. Do not rely on the package to de-duplicate notifications.
Retry-aware monitoring. For jobs with tries and backoff, treat running as a transient state. Alert on a combination of status and elapsed time rather than status alone. A job that has been running for 45 minutes may be retrying, stuck, or killed mid-run.
Testing. Use RefreshDatabase and explicitly test the lifecycle you care about. Queue::fake() often prevents the real handle() path, so pair dispatch assertions with a separate test that runs the job synchronously or invokes handle() directly. Assert the tracking row exists, has the expected status, and is linked to your domain record.
Pruning. Tracking tables grow quickly. Schedule cleanup:
TrackedJob::where('finished_at', '<', now()->subDays(30))->delete();
Keep failed jobs longer than successful ones if you use the table for incident review.
Trackable will not appear. If a dashboard looks empty, confirm the job class actually uses the trait and that the queue worker was restarted after deploy.php artisan queue:restart after deploying tracking changes, otherwise old workers may keep running untracked jobs.queued for a long time on a busy queue. Use started_at for execution latency and created_at/queue time for backlog analysis.sync, a job can move from queued to finished almost instantly, so a “running” UI state may never be visible.running records. A fatal PHP error, OOM kill, or worker restart can prevent the package from writing the final status. Build a stale-job detector that flags records running longer than a threshold.tenant_id to your domain tracking table or extend the package model/table carefully, then scope every dashboard query.php artisan config:clear locally and ensure production deploys rebuild caches.job_runs table keyed by the tracked job UUID. That is cleaner than adding many columns to the package’s table and keeps future upgrades simpler.How can I help you explore Laravel packages today?