mrshanebarron/progress
A Laravel package for adding and displaying progress tracking in your applications. It provides a clean API for defining progress steps and helpers for rendering progress indicators, making it easy to visualize completion status in dashboards, workflows, and multi-step processes.
Install and publish
composer require mrshanebarron/progress
php artisan vendor:publish --tag=sb-progress-config
The v1.0.1 patch renamed the published config to the sb- prefix (config/sb-progress.php). If you are coming from v1.0.0, re-publish (--force) and diff before doing anything else — see Gotchas.
Where to look first
config/sb-progress.php — storage driver, key/prefix, TTL, and any UI defaults. This tells you whether progress is kept in cache, session, or DB.src/*ServiceProvider.php) — confirms the config filename passed to mergeConfigFrom, the binding/alias for the tracker, and whether Blade components are auto-registered.src/ for the tracker class and its public methods (start/advance/finish/isComplete style API — confirm the exact names here before writing code around them).resources/views/ for any publishable Blade component, if you intend to use the UI layer rather than the tracker directly.First use case: a queued import
Start with the smallest realistic flow — one queued job, one tracker, one Blade progress bar:
// app/Jobs/ImportContactsJob.php
public function handle(ProgressTracker $progress): void
{
$rows = $this->readCsv($this->path);
$progress->start($this->runId, total: $rows->count());
foreach ($rows as $row) {
$this->import($row);
$progress->advance($this->runId);
}
$progress->finish($this->runId);
}
Then render it: dispatch the job, redirect, and have the view read the same run id from the tracker. If that loop works end to end, you have validated storage, persistence across the queue boundary, and the read path — the three things that break most often.
Key every run by an id, never by "the current job"
Progress packages in this class are almost always keyed storage. Always namespace by something stable and unique per run — the job's UUID, an import record's id, or a ULID you generate at dispatch time and pass into the job. Sharing a single global key means two concurrent imports overwrite each other's counters and both report nonsense. Pass the id through the constructor and back to the front end in the redirect/session flash.
Write in the job, read in the controller
Keep the write side inside the worker and the read side inside a thin controller or Livewire action:
Route::get('/imports/{run}/progress', fn (string $run, ProgressTracker $progress) => [
'done' => $progress->completed($run),
'total' => $progress->total($run),
'finished' => $progress->isComplete($run),
]);
Returning a small JSON payload keeps polling cheap and lets you swap the front end (Alpine poll, Livewire wire:poll, or a few seconds of setInterval) without touching the job.
Chunk your updates
Don't advance() once per row on a million-row import if the underlying store is a database table or Redis — you will spend more time writing progress than doing work. Advance every N items (100–1000) or by a computed delta (advance($run, $count)), and always do a final finish() so the bar lands on 100% rather than 99.7%.
Treat failure as an explicit state
The tracker tracks progress, not outcome. Wrap the job body and mark failure yourself so the UI can stop polling:
try {
// ... work
$progress->finish($runId);
} catch (\Throwable $e) {
$progress->fail($runId, $e->getMessage()); // or set a status field
throw $e;
}
Also handle the retry path: a retried job should reset or resume deterministically, not double-count. Decide up front whether your run id is stable across retries (resume) or regenerated (restart), and make the first line of handle() enforce it.
Gate multi-step flows on the completion check
The isComplete() check is the natural guard for chained workflows — don't advance to step 2 until step 1 reports done. Prefer checking the tracker over inferring from your own domain tables; it keeps the "is this still running?" question in one place and makes the UI and the orchestration agree.
Wrap it behind your own small service
Bind the package tracker behind an interface in your app and resolve your wrapper via the container. Two payoffs: tests can substitute an in-memory implementation, and if the package's API shifts (it's at v1.0.x, so it will), you change one adapter instead of grepping forty call sites.
Test the states, not the clock
In feature tests, drive the tracker directly to a mid-progress state and assert your controller's JSON shape and your Blade output. Assert the terminal states explicitly: 0 of N, N of N, and the "failed" branch. Those three are where UI regressions hide.
The v1.0.1 config rename is the sharpest edge
Renaming the published config file is a silent breaking change for anyone who published v1.0.0 assets. mergeConfigFrom() points at the new filename; the old published file is simply never loaded, so your overrides vanish and the package reverts to defaults — no exception, no warning. Symptoms are subtle: your configured cache store or key prefix stops applying and progress appears to reset or vanish.
Checklist when upgrading:
php artisan vendor:publish --force (or the provider tag), then diff the new config/sb-progress.php against your old file and port overrides forward.grep -r your app, tests, and deploy scripts for the old config filename.php artisan config:clear and confirm bootstrap/cache/config.php no longer holds the stale path (this bites hardest in deployments that cache config during build).composer.json rather than floating ^1.0, and read the changelog on each bump — a patch-level config rename sets the precedent.Cache-backed progress does not survive array in tests, and may not survive workers
If the default store is the cache, the array driver gives each process its own memory: your test asserts progress that the "job" wrote to a different array, and it passes for the wrong reason or fails inexplicably. Force a shared store (database, redis, file) for anything that crosses a process boundary, and set the test environment to something deterministic. Same trap in production with multiple queue workers and a per-process store.
Beware non-atomic increments
Read-modify-write counters lose updates under concurrency. If your store supports it, use the package's atomic path or Cache::increment()-style primitives; if it doesn't, serialize with a lock (Cache::lock) around the update or keep progress to a single writer. Two workers on one run id with a naive counter will drift low and never reach the total.
Divide-by-zero and the empty run
total: 0 makes percentage math explode or render NaN. Guard the percentage calculation in your view and decide what an empty run looks like in the UI (0/0 is "nothing to do", not "0%").
Polling: stop when done, expire eventually
A poll loop that never terminates keeps hitting your app after the job finishes. Have the endpoint return an explicit finished boolean and have the client clear its interval on it. Separately, set a sane TTL on stored progress so abandoned runs don't accumulate forever — check whether the package handles expiry or whether you need to clean up yourself.
Publish the UI assets deliberately
This is a UI component package, so expect a publishable Blade view and possibly a CSS dependency. If you publish the view, remember it's now yours to maintain across upgrades — vendor diffs on the next bump. If you have Tailwind configured with explicit content globs, ensure the package's view paths are included or the progress bar ships unstyled.
Debugging checklist
Progress "not updating"? In order: (1) is the same run id used on both sides — dump it in the job and the request; (2) is the config actually loaded — config('sb-progress.*') in tinker against what you expect; (3) same cache store in worker and web process — php artisan about / config('cache.default'); (4) is the write happening inside a DB transaction that later rolls back; (5) is the job even reaching the advance call — log at the start of handle(). Nine times in ten it's (1) or (3).
Extension points worth knowing
The config file is the intended seam: store driver, key prefix, and defaults are all meant to be set there rather than patched in vendor code. Beyond that, the container binding (or the facade's underlying class, if aliased) is the place to swap an implementation. Avoid subclassing the tracker directly — it's pre-2.0 and internal signatures aren't a contract; an adapter you own is the durable choice.
How can I help you explore Laravel packages today?