setono/job-status-bundle
A Symfony bundle for tracking the status of asynchronous jobs and background tasks. Mark jobs as pending, running, completed, or failed, then query progress and results from anywhere in your app.
Read this first: it is a Symfony bundle, not a Laravel package. There is no JobStatusServiceProvider, no auto-discovery, and no config/job-status.php. composer require setono/job-status-bundle into a Laravel app will install code that never boots. Treat this repository as a reference design for modelling async job progress, not as a dependency you require.
Where to look first, in order:
README.md — the usage snippet shows the intended lifecycle (create a status, hand its identifier to the worker, update it, read it back).src/ — find the core model (typically a JobStatus entity/value object with fields like status, progress, message, timestamps) and the factory/manager service that creates and mutates it. Those two things are the whole idea.src/Command/ — the bundle ships console commands (this is what v0.2.5 touched). Worth reading as a blueprint for the pruning/retention command you will otherwise write yourself.src/DependencyInjection/ and Resources/config/services.yaml — confirms how tightly it is coupled to Symfony DI and Doctrine. Usually it is coupled enough that porting the ~2–3 core classes is cheaper than wiring the bundle.The first use case to reproduce: a long-running queued export. The HTTP request dispatches a job and immediately returns a job_status id. The worker updates progress as it churns. The frontend polls GET /jobs/{id} (or listens on a broadcast channel) and renders a progress bar. That is the entire value proposition — the bundle just standardises the record shape and the state transitions.
Minimal port to Laravel:
// database/migrations/..._create_job_statuses_table.php
$table->uuid('id')->primary();
$table->string('status')->default('pending'); // pending|running|finished|failed
$table->unsignedTinyInteger('progress')->default(0);
$table->string('message')->nullable();
$table->string('job_class')->nullable();
$table->json('context')->nullable();
$table->timestamps();
class JobStatus extends Model
{
protected $guarded = [];
public $incrementing = false;
protected $keyType = 'string';
public function advance(int $to, ?string $message = null): void
{
$this->update([
'status' => $to >= 100 ? 'finished' : 'running',
'progress' => min(100, $to),
'message' => $message ?? $this->message,
]);
}
}
That is roughly the bundle's core, minus the Symfony ceremony.
Attach the status record at dispatch time, not inside the job. Generate the JobStatus in the controller, pass the id into the job constructor, and return the id in the response. If you create it inside handle(), the client has no id to poll until the worker picks the job up — which can be seconds or minutes later under load.
Use job middleware for the lifecycle boilerplate. Laravel's JobMiddleware / handle wrapper is the closest analogue to the bundle's manager service:
class TracksProgress
{
public function handle($job, Closure $next)
{
$status = JobStatus::findOrFail($job->jobStatusId);
$status->update(['status' => 'running', 'message' => 'Started']);
try {
return $next($job);
} catch (\Throwable $e) {
$status->update(['status' => 'failed', 'message' => $e->getMessage()]);
throw $e;
}
}
}
Report progress in meaningful chunks. Inside the job, call $status->advance($i / $total * 100) once per batch (e.g. every 100 rows), never per row. Each call is a DB write; per-row updates will cost you more than the work itself.
Prefer a polling endpoint for v1, broadcasting for v2. A tiny JobStatusController@show returning a JobStatusResource is 20 lines and works everywhere. If you need push, make the model implement ShouldBroadcast on a private channel keyed by user id — but note the bundle predates any of that; you are building it yourself.
For indeterminate work, use the message field, not a fake percentage. progress = 50 when you genuinely don't know is worse than progress = null, message = "Indexing 12,431 of ?". The bundle's status field is the honest signal; percentage is decoration.
If you actually are on Symfony, the pattern is: autowire the factory, inject it into a Messenger handler, call create() before dispatch(), hold the id, and update from the handler or a Messenger middleware. Configure under config/packages/setono_job_status.yaml. The bundle's Doctrine storage means the status is queryable from any process, which is the point. Pin ^0.2.5 if you go this route — it is the only release in years and it fixes memory growth in the bundle's commands.
Bulk operations need a parent job. For "export 50k rows," create one parent status plus child statuses, and derive the parent percentage from finished_children / total_children. Rolling your own aggregation is trivial; the bundle does not do it for you.
Retention. A job_statuses table that never gets pruned is a slow-motion incident. Add a scheduled JobStatus::where('created_at', '<', now()->subDays(7))->delete() from day one — and if you want a reference implementation, the bundle's command is worth a skim (see the memory caveat below).
Zero stars, zero dependents, effectively a dormant personal bundle. v0.2.5 is a single-issue patch — "fixed memory issues with commands" — after a long gap with no releases. It contains no feature work, no Laravel relevance, and no stated PHP 8.3+/Symfony 7 compatibility guarantee. Do not read the version bump as a sign of renewed maintenance. Fork-and-extract, not require.
No Laravel auto-discovery. Even if the core classes are framework-agnostic, the service definitions, Doctrine mapping, and Symfony config loader are not. You will spend more time stripping those out than writing the 60 lines yourself.
Doctrine vs Eloquent. The bundle's model is a Doctrine entity with annotations/attributes. Porting means rewriting the mapping, the repository calls, and the timestamp handling. There is no adapter.
If you copy the bundle's console command, copy the fix's lesson too. The v0.2.5 change exists because long-running command loops accumulated state — the classic Doctrine/ORM pattern of an ever-growing unit of work holding every entity it touches. The Laravel analogue is a chunked JobStatus::query()->chunkById(...) prune where you leak models into a collection or get(). Use chunkById, keep the batch small, and let the objects go out of scope. Memory bugs in a nightly retention job surface as an OOM kill weeks later, not on deploy.
Status writes inside the job's DB transaction will be rolled back on failure. If your job wraps work in DB::transaction() and updates the status inside it, a rollback silently erases the "failed" message. Write status updates outside the transaction, or on a separate connection.
Concurrent workers can clobber the same row. Two chunks finishing simultaneously will each write progress; last-write-wins can move the bar backwards. Use increment-style atomic updates or a SELECT ... FOR UPDATE on the status row, and never let a lower value overwrite a higher one:
JobStatus::where('id', $id)->where('progress', '<', $new)->update([...]);
Timestamp drift / timezone. The bundle stores naive datetimes; when porting, keep everything in UTC and convert at the resource layer. Otherwise "started 3 hours ago" renders wrong for anyone outside the server's timezone.
Don't make the status table a foreign-key target. Jobs get retried, deleted, and re-queued; keep job_status_id denormalised on the job and don't cascade. The status should outlive the job so users can still see why it failed.
Debugging: log every transition (pending → running → failed) with the status id and the exception, and expose the message field in the polling response. In practice, 90% of "the progress bar is stuck" reports are a job that threw before its first advance() call — which the failed state catches only if you wrap $next($job) as above.
Extension points worth copying: a JobStatusFactory you can swap, a status enum you can widen (cancelled, retrying), and a manager you can decorate for logging. If you're porting, keep those seams — they're the only real design value in a 0-star bundle.
How can I help you explore Laravel packages today?