rahul900day/laravel-console-spinner
Show elegant, animated spinners in Laravel artisan console commands while long tasks run. Customize frames, messages, and completion states for polished, informative CLI output.
Install with Composer; the package is auto-discovered and ships no config file or migrations, so there is nothing to publish:
composer require rahul900day/laravel-console-spinner
Look first at vendor/rahul900day/laravel-console-spinner/src/Spinner.php. That single class is the whole package — confirm the exact factory/constructor signature against the tag you installed, because the API has shifted between minor releases. The README is accurate but terse; the source is the better reference.
The minimal first use case is wrapping one slow call inside a command. The spinner needs a Symfony OutputInterface, which every Artisan command already has as $this->output:
use Rahul900day\LaravelConsoleSpinner\Spinner;
public function handle()
{
$spinner = Spinner::create($this->output);
$spinner->start('Syncing customers...');
$count = $this->syncService->run();
$spinner->stop("Synced {$count} customers");
}
That is the 80% case: one message, one blocking call, one success line. Everything else in this assessment is about the other 20%.
Callback form for guarantee-clean terminal state. The most robust day-to-day pattern is to let the spinner own the lifecycle rather than sprinkling start()/stop() through your command:
$spinner = Spinner::create($this->output)->start('Importing...');
try {
$rows = $this->import();
$spinner->stop("Imported {$rows} rows");
} catch (\Throwable $e) {
$spinner->stop('<fg=red>Import failed</>');
throw $e;
}
A finally block around stop() is non-negotiable — an uncaught exception mid-spin leaves the cursor hidden and the line half-drawn in the user's terminal.
Drive long loops with advance()/message updates. For a loop you control, don't try to animate per-iteration (the spinner is time-based and will just look jumpy); instead use it as a heartbeat while the loop does real work, and push state into the label:
foreach ($chunks as $i => $chunk) {
$this->process($chunk);
$spinner->setMessage("Processed ".($i + 1)."/".count($chunks)." chunks");
}
If the loop body is CPU-bound and never yields, the spinner freezes on one frame — call advance() (or whatever the tick method is in your version) once per iteration to force a redraw. For I/O-bound work with sleeps or HTTP calls, the frame timer refreshes itself and you don't need to.
Keep console concerns out of your domain layer. The most valuable pattern is a single InteractsWithSpinner trait in app/Console/Concerns/ that wraps Spinner::create($this->output) and also handles the "should we spin at all?" check (see Gotchas). Your commands then call $this->spin('...'), and if you later migrate to Laravel Prompts' spin() you change one file instead of thirty commands.
Choose one progress affordance per task. Spinners and $this->progressBar() both own the current terminal line and will overwrite each other if used simultaneously. Use the progress bar when you know the total count; use the spinner when you don't. Never both in the same command.
Nesting and sub-commands. $this->call() invokes another command with the same output instance. If both parent and child spin, they collide. Have the child detect it's being called programmatically and skip its spinner, or capture the child into BufferedOutput and re-render its result under the parent's spinner.
Testing. Spinner frames are nondeterministic timestamps, so expectsOutput() assertions against a spinning command are inherently flaky. Either disable the spinner under $this->app->runningUnitTests() or assert only the stop() message. In practice, gate on $this->output->isDecorated() as well as the test env.
Non-TTY output destroys logs. Spinner frames are drawn with carriage returns, so php artisan import >> storage/logs/import.log produces a single multi-megabyte line full of ⠋⠙⠹⠸. Always guard:
$enabled = $this->output->isDecorated()
&& ! $this->output->isQuiet()
&& ! $this->option('no-interaction');
isDecorated() is false when piped, which covers CI runners and cron. Respecting -q matters too — an operator scripting your command does not want escape codes in captured output.
Buffered output and IDE terminals. PhpStorm's and VS Code's built-in terminals handle the ANSI overwrites reasonably, but Windows cmd.exe and some SSH clients render the spinner as a stack of lines. Since you can't detect all of these, keep messages short enough that a failed overwrite is still readable.
Don't write to output while spinning. Any $this->info(), $this->line(), or dump() between start() and stop() lands on top of the spinner line and corrupts it. Buffer the messages and emit them after stop(), or fold them into setMessage().
Version pinning is real. The package is small, actively released (last tag March 2025), and MIT — low risk to depend on — but the class API has been reworked across releases. Pin it in composer.json and read the changelog before composer update, or a minor bump can quietly break every command that used the old factory.
Laravel Prompts already does this. If you're on Laravel 11+, spin() from laravel/prompts covers the same ground with a cleaner callback API and first-class fallback behaviour for non-interactive terminals. Evaluate your Laravel version before adopting this package; if you're on 11+, Prompts is the lower-dependency-count answer, and this package is best justified on Laravel 9/10 or where you want direct control over frames and colours.
Clean up on SIGINT. If an operator hits Ctrl-C mid-spin, the cursor may stay hidden. Register a signal handler (pcntl_async_signals + pcntl_signal(SIGINT, ...)) or run the whole command inside a trap-style wrapper that always finalises the spinner.
Customisation is constructor-level, not config-level. There's no config/console-spinner.php. Frame sets, interval, and colours are passed in when you build the spinner — so if you want a house style, wrap construction in your own factory rather than repeating arguments in every command.
How can I help you explore Laravel packages today?