glhd/conveyor-belt
Conveyor Belt is a Laravel package for composing queued jobs into pipelines. Define ordered stages, run independent jobs in parallel, and orchestrate multi-step background workflows while relying on Laravel’s queue for retries and scaling.
composer require glhd/conveyor-belt — no service provider registration needed on Laravel 9+ (package discovery), and no migrations or published assets are required to get a first belt running.src/Belts/ and the trait that mixes runConveyorBelt() into your command. Those two files are where 90% of the behaviour you'll rely on lives; the rest of the package is glue.ProgressBar boilerplate.--limit/where id < 100) so you can see the bar complete; then point it at production-scale data.dd() inside the processing callback — the belt is thin enough to trace with ray() or Xdebug without stepping through framework internals.One belt per command. Keep the source (query/iterable/Countable) and the row handler as separate small methods. That split makes the command testable and lets you swap the source for a fake in tests.
Countable totals (new in 2.4.0). The belt now accepts a Countable where it previously wanted an iterable, so the progress bar's max is derived from count() instead of being indeterminate or manually set. The idiomatic pattern is a small value object that is both iterable and countable:
class PendingOrders implements IteratorAggregate, Countable
{
public function getIterator(): Traversable { return Order::wherePending()->lazyById(); }
public function count(): int { return Order::wherePending()->count(); }
}
This is the cleanest way to get an accurate ETA on a query you don't want to hydrate up front.
Countable for non-query sources. Any domain collection that knows its own size (a Collection of file paths, an array of tenant IDs, a paginated API response with a total) can be passed straight in. Previously you had to count() separately and hand the number to the bar; now it flows through the belt.
Jobs instead of inline work. For work that must be retried, keep the belt as the driver and dispatch jobs from the row handler. The belt stays fast (it's just iterating and rendering), and the progress bar's cadence reflects enqueues rather than job completion — decide which you actually want to display.
Compose with existing Artisan affordances. Keep --dry-run, --chunk=, and --limit= as real options and thread them into the source, not the handler. The belt's bar will then show the honest total for the run you asked for.
Testing. Point the source at an in-memory collection and assert on side effects, not on output. If you need to assert the bar, Artisan::call() and check Artisan::output() contains the expected step count.
Scheduling. Wrapping the command in Schedule::command(...) works, but see the non-interactive gotcha below before you do.
count() and the iterator must agree. The bar's max is resolved once, from count(). If rows are inserted/deleted mid-run, or your count() applies different constraints than your iterator, the bar will finish early or blow past 100%. Keep both behind the same scope/builder method so they can't drift.count() with a cheap query. A Countable whose count() does a select count(*) over a huge table is usually fine, but one that hydrates a collection just to call ->count() defeats the point. Use ->toBase()->count().get() the rows. Keep lazyById()/cursor() inside getIterator(); the new feature only affects the total, not buffering.--quiet, and prefer $this->output->isDecorated() / $this->option('no-interaction') checks rather than hardcoding --no-ansi.cursor() skips records. Use lazyById() or chunk by a stable key, and prefer soft deletes during the run.Process::run on another Artisan command) rather than nesting belts.continues when you want best-effort semantics, and let it bubble when you don't — the belt won't make that decision for you.configureProgressBar()-style hook (or override the belt's formatting method) rather than re-implementing the loop.Countable and relied on the belt ignoring it; the bar's total will now come from count() rather than being indeterminate.How can I help you explore Laravel packages today?