Creating a Loading Spinner in the Terminal Using PHP

In the browser it's easy enough to show a spinner while content is loading. How can we do the same thing in the terminal?

Joe Tannenbaum

A couple of weeks ago, I came across this tweet from Francisco Madeira:

As I'm currently building out a CLI tool, it got me thinking: How hard is it to build an indeterminate progress spinner for longer running process in the terminal using PHP? So I gave it a whirl.

First Attempt

The first barrier to entry are that the processes need to be asynchronous: The long-running task needs to run in background and then notify the "spinner" process when it has finished.

Async PHP has come a long way in the past couple of years, so off to Google I went. I came across spatie/async and it seemed like the perfect tool for the job.

Within 30 minutes I had a working prototype:

Houston, we have a spinner
Houston, we have a spinner

To accomplish this, I created an Artisan console macro via a mixin. Here's what the code looked like:

1<?php
2 
3namespace App\Mixins;
4 
5use Spatie\Async\Pool;
6 
7class Console
8{
9 public function withSpinner()
10 {
11 return function (
12 string $title,
13 callable $task,
14 string|callable $successDisplay = null,
15 array $longProcessMessages = []
16 ) {
17 $this->hideCursor();
18 
19 $running = true;
20 $animation = collect(mb_str_split('⢿⣻⣽⣾⣷⣯⣟⡿'));
21 
22 $index = 0;
23 
24 $this->output->write($title . ': ' . $animation->get($index));
25 
26 $pool = Pool::create();
27 
28 $process = $pool->add($task);
29 
30 $process->then(function ($output) use (
31 &$running,
32 $title,
33 $successDisplay,
34 ) {
35 $running = false;
36 
37 if (is_callable($successDisplay)) {
38 $message = $successDisplay($output);
39 } else if (is_string($successDisplay)) {
40 $message = $successDisplay;
41 } else if (is_string($output)) {
42 $message = $output;
43 } else {
44 $message = '✓';
45 }
46 
47 $this->overwriteLine(
48 $title . ': <info>' . $message . '</info>',
49 true,
50 );
51 })->catch(function ($exception) use (
52 &$running,
53 $title,
54 ) {
55 $running = false;
56 
57 $this->overwriteLine(
58 $title . ': <error>' . $exception->getMessage() . '</error>',
59 true,
60 );
61 
62 throw $exception;
63 });
64 
65 // If they quit, wrap things up nicely
66 $this->trap([SIGTERM, SIGQUIT], function () use ($process) {
67 $this->showCursor();
68 $process->stop();
69 });
70 
71 $reversedLongProcessMessages = collect($longProcessMessages)
72 ->reverse()
73 ->map(fn ($v) => ' ' . $v);
74 
75 while ($running) {
76 $runningTime = floor($process->getCurrentExecutionTime());
77 
78 $longProcessMessage = $reversedLongProcessMessages->first(
79 fn ($v, $k) => $runningTime >= $k
80 ) ?? '';
81 
82 $index = ($index === $animation->count() - 1) ? 0 : $index + 1;
83 
84 $this->overwriteLine(
85 $title . ': <comment>' . $animation->get($index) . $longProcessMessage . '</comment>'
86 );
87 
88 usleep(200000);
89 }
90 
91 $this->showCursor();
92 
93 return $process->getOutput();
94 };
95 }
96 
97 public function overwriteLine()
98 {
99 return function (string $message, bool $newLine = false) {
100 // Move the cursor to the beginning of the line
101 $this->output->write("\x0D");
102 
103 // Erase the line
104 $this->output->write("\x1B[2K");
105 
106 $this->output->write($message);
107 
108 if ($newLine) {
109 $this->newLine();
110 }
111 };
112 }
113 
114 public function hideCursor()
115 {
116 return function () {
117 $this->output->write("\e[?25l");
118 };
119 }
120 
121 public function showCursor()
122 {
123 return function () {
124 $this->output->write("\e[?25h");
125 };
126 }
127}

Here's how I registered it (in AppServerProvider.php):

1<?php
2 
3namespace App\Providers;
4 
5use App\Mixins\Console as MixinsConsole;
6use Illuminate\Console\Command;
7use Illuminate\Support\ServiceProvider;
8 
9class AppServiceProvider extends ServiceProvider
10{
11 ...
12 public function boot()
13 {
14 //
15 }
16 
17 public function register()
18 {
19 Command::mixin(new MixinsConsole);
20 }
21}

And here's how it was called within the command:

1<?php
2 
3$phpVersion = $this->withSpinner(
4 title: 'Determining PHP Version',
5 task: function () {
6 sleep(2);
7 
8 return '8.2';
9 }
10);
11 
12$site = $this->withSpinner(
13 title: 'Creating Site',
14 task: function () {
15 sleep(3);
16 
17 return [
18 'id' => 12345,
19 'name' => 'Bellows Test',
20 ];
21 },
22 successDisplay: fn ($site) => $site['name'],
23);
24 
25$this->withSpinner(
26 title: 'Installing Repo',
27 task: function () {
28 sleep(10);
29 },
30 longProcessMessages: [
31 3 => 'Almost there...',
32 5 => 'One moment...',
33 7 => 'Wrapping Up...',
34 ],
35);

Of Note

  • The method always returns the value of the task callback, but you can customize the successful display of the result via the successDisplay param (either a string or a callback utilizing the result of the task callback)

  • If we know the process will run really long we can add some optional comforting messages starting at specific intervals

  • We're hiding the cursor in the terminal during the process to prevent continuous frantic blinking since we're constantly overwriting the same line

We did it! Let's use it! Wait.

Scene: Joe, sitting at his computer, satisfied.

"Dang, I cranked this whole thing out in under 30 minutes. Nice job, Joe."

[pats self on back]

"Time to use this in some real code, none of this sleep stuff."

[absolute explosion of errors]

"Erm."

So, spatie/async is great for running async code! But, as they specified right in their docs, that code has no context of the rest of your application unless you bootstrap the whole thing when you spin up the async process. That wasn't ideal for my needs, so back to the drawing board I went.

Put a Fork in it

Luckily, Spatie has another package called spatie/fork which did exactly what I was looking for. This package allows you to fork the current PHP process, run arbitrary code concurrently, then return the result when all of the processes are done. Perfect.

So I could fork two processes, the task and the spinner. When the task was done, notify the spinner task and tell it to wrap up. And herein lies our next challenge.

Let's check in with our code after utilizing the Fork package, looking pretty good:

1<?php
2 
3namespace App\Mixins;
4 
5use Spatie\Fork\Connection;
6use Spatie\Fork\Fork;
7 
8class Console
9{
10 public function withSpinner()
11 {
12 return function (
13 string $title,
14 callable $task,
15 string|callable $successDisplay = null,
16 array $longProcessMessages = []
17 ) { ...
18 $this->hideCursor();
19 
20 // If they quit, wrap things up nicely
21 $this->trap([SIGTERM, SIGQUIT], function () {
22 $this->showCursor();
23 });
24 
25 $result = Fork::new()
26 ->run(
27 function () use ($task, $successDisplay, $title) {
28 $output = $task();
29 
30 if (is_callable($successDisplay)) {
31 $display = $successDisplay($output);
32 } else if (is_string($successDisplay)) {
33 $display = $successDisplay;
34 } else if (is_string($output)) {
35 $display = $output;
36 } else {
37 $display = '✓';
38 }
39 
40 $this->overwriteLine(
41 "<info>{$title}:</info> <comment>{$display}</comment>",
42 true,
43 );
44 
45 return $output;
46 },
47 function () use ($longProcessMessages, $title) {
48 $animation = collect(mb_str_split('⢿⣻⣽⣾⣷⣯⣟⡿'));
49 $startTime = time();
50 
51 $index = 0;
52 
53 $reversedLongProcessMessages = collect($longProcessMessages)
54 ->reverse()
55 ->map(fn ($v) => ' ' . $v);
56 
57 while (true) {
58 $runningTime = 0;
59 $runningTime = time() - $startTime;
60 
61 $longProcessMessage = $reversedLongProcessMessages->first(
62 fn ($v, $k) => $runningTime >= $k
63 ) ?? '';
64 
65 $index = ($index === $animation->count() - 1) ? 0 : $index + 1;
66 
67 $this->overwriteLine(
68 "<info>{$title}:</info> <comment>{$animation->get($index)}{$longProcessMessage}</comment>"
69 );
70 
71 usleep(200_000);
72 }
73 }
74 ); ...
75 
76 $this->showCursor();
77 
78 return $result[0];
79 };
80 }
81 ...
82 
83 public function overwriteLine()
84 {
85 return function (string $message, bool $newLine = false) {
86 // Move the cursor to the beginning of the line
87 $this->output->write("\x0D");
88 
89 // Erase the line
90 $this->output->write("\x1B[2K");
91 
92 $this->output->write($message);
93 
94 if ($newLine) {
95 $this->newLine();
96 }
97 };
98 }
99 
100 public function hideCursor()
101 {
102 return function () {
103 $this->output->write("\e[?25l");
104 };
105 }
106 
107 public function showCursor()
108 {
109 return function () {
110 $this->output->write("\e[?25h");
111 };
112 }
113}

Eagle-eyed viewers will notice the while (true) though. We should probably handle that so this script doesn't hang the whole system.

This caused me some trouble for a bit, I was having trouble communicating between the two child processes themselves. What did these two processes share that they both had access to? Then, aha! The filesystem!

My first attempt at a solution was to simply write a file at the beginning of the process, and wait for that file to be removed as a signal to stop the spinner. Here it is:

1<?php
2 
3namespace App\Mixins;
4 
5use Spatie\Fork\Fork;
6 
7class Console
8{
9 public function withSpinner()
10 {
11 return function (
12 string $title,
13 callable $task,
14 string|callable $successDisplay = null,
15 array $longProcessMessages = []
16 ) {
17 $cachePath = storage_path('app/' . time());
18 
19 file_put_contents($cachePath, '1');
20 ...
21 $this->hideCursor();
22 
23 // If they quit, wrap things up nicely
24 $this->trap([SIGTERM, SIGQUIT], function () use ($cachePath) {
25 $this->showCursor();
26 unlink($cachePath);
27 });
28 
29 $result = Fork::new()
30 ->run(
31 function () use ($task, $successDisplay, $title, $cachePath) {
32 $output = $task();
33 
34 unlink($cachePath);
35 
36 if (is_callable($successDisplay)) {
37 $display = $successDisplay($output);
38 } else if (is_string($successDisplay)) {
39 $display = $successDisplay;
40 } else if (is_string($output)) {
41 $display = $output;
42 } else {
43 $display = '✓';
44 }
45 
46 $this->overwriteLine(
47 "<info>{$title}: {$display}</info>",
48 true,
49 );
50 
51 return $output;
52 },
53 function () use ($longProcessMessages, $title, $cachePath) {
54 $animation = collect(mb_str_split('⢿⣻⣽⣾⣷⣯⣟⡿'));
55 $startTime = time();
56 
57 $index = 0;
58 
59 // $this->output->write($title . ': ' . $animation->get($index));
60 
61 $reversedLongProcessMessages = collect($longProcessMessages)
62 ->reverse()
63 ->map(fn ($v) => ' ' . $v);
64 
65 while (file_exists($cachePath)) {
66 $runningTime = 0;
67 $runningTime = time() - $startTime;
68 
69 $longProcessMessage = $reversedLongProcessMessages->first(
70 fn ($v, $k) => $runningTime >= $k
71 ) ?? '';
72 
73 $index = ($index === $animation->count() - 1) ? 0 : $index + 1;
74 
75 $this->overwriteLine(
76 "<info>{$title}:</info> <comment>{$animation->get($index)}{$longProcessMessage}</comment>"
77 );
78 
79 usleep(200_000);
80 }
81 }
82 ); ...
83 
84 $this->showCursor();
85 
86 return $result[0];
87 };
88 }
89 ...
90 public function overwriteLine()
91 {
92 return function (string $message, bool $newLine = false) {
93 // Move the cursor to the beginning of the line
94 $this->output->write("\x0D");
95 
96 // Erase the line
97 $this->output->write("\x1B[2K");
98 
99 $this->output->write($message);
100 
101 if ($newLine) {
102 $this->newLine();
103 }
104 };
105 }
106 
107 public function hideCursor()
108 {
109 return function () {
110 $this->output->write("\e[?25l");
111 };
112 }
113 
114 public function showCursor()
115 {
116 return function () {
117 $this->output->write("\e[?25h");
118 };
119 }
120}

It worked, but holy cow is this ugly. It didn't feel good, it didn't sit well with me, it felt kind of gross. So I went to bed and slept on it.

Socket to Me

The next morning, I did a deep dive into the Fork package's source code (which, surprisingly, is pretty lean) and watched a video on the package from Freek Van der Herten, and there it was. Right there in the package itself. They were using the socket_create_pair to communicate between the parent and child tasks, why couldn't I do the same? Well, I could. And I came up with a solution I was satisfied with:

1<?php
2 
3namespace App\Mixins;
4 
5use Spatie\Fork\Connection;
6use Spatie\Fork\Fork;
7 
8class Console
9{
10 public function withSpinner()
11 {
12 return function (
13 string $title,
14 callable $task,
15 string|callable $successDisplay = null,
16 array $longProcessMessages = []
17 ) { ...
18 $this->hideCursor();
19 
20 // If they quit, wrap things up nicely
21 $this->trap([SIGTERM, SIGQUIT], function () {
22 $this->showCursor();
23 });
24 
25 // Create a pair of socket connections so the two tasks can communicate
26 [$socketToTask, $socketToSpinner] = Connection::createPair();
27 
28 $result = Fork::new()
29 ->run(
30 function () use ($task, $successDisplay, $title, $socketToSpinner) {
31 $output = $task();
32 
33 $socketToSpinner->write(1);
34 
35 // Wait for the next cycle of the spinner so that it stops
36 usleep(200_000);
37 
38 if (is_callable($successDisplay)) {
39 $display = $successDisplay($output);
40 } else if (is_string($successDisplay)) {
41 $display = $successDisplay;
42 } else if (is_string($output)) {
43 $display = $output;
44 } else {
45 $display = '✓';
46 }
47 
48 $this->overwriteLine(
49 "<info>{$title}:</info> <comment>{$display}</comment>",
50 true,
51 );
52 
53 return $output;
54 },
55 function () use ($longProcessMessages, $title, $socketToTask) {
56 $animation = collect(mb_str_split('⢿⣻⣽⣾⣷⣯⣟⡿'));
57 $startTime = time();
58 
59 $index = 0;
60 
61 $reversedLongProcessMessages = collect($longProcessMessages)
62 ->reverse()
63 ->map(fn ($v) => ' ' . $v);
64 
65 $socketResults = '';
66 
67 while (!$socketResults) {
68 foreach ($socketToTask->read() as $output) {
69 $socketResults .= $output;
70 }
71 
72 $runningTime = 0;
73 $runningTime = time() - $startTime;
74 
75 $longProcessMessage = $reversedLongProcessMessages->first(
76 fn ($v, $k) => $runningTime >= $k
77 ) ?? '';
78 
79 $index = ($index === $animation->count() - 1) ? 0 : $index + 1;
80 
81 $this->overwriteLine(
82 "<info>{$title}:</info> <comment>{$animation->get($index)}{$longProcessMessage}</comment>"
83 );
84 
85 usleep(200_000);
86 }
87 }
88 ); ...
89 
90 $this->showCursor();
91 
92 $socketToSpinner->close();
93 $socketToTask->close();
94 
95 return $result[0];
96 };
97 }
98 ...
99 public function overwriteLine()
100 {
101 return function (string $message, bool $newLine = false) {
102 // Move the cursor to the beginning of the line
103 $this->output->write("\x0D");
104 
105 // Erase the line
106 $this->output->write("\x1B[2K");
107 
108 $this->output->write($message);
109 
110 if ($newLine) {
111 $this->newLine();
112 }
113 };
114 }
115 
116 public function hideCursor()
117 {
118 return function () {
119 $this->output->write("\e[?25l");
120 };
121 }
122 
123 public function showCursor()
124 {
125 return function () {
126 $this->output->write("\e[?25h");
127 };
128 }
129}

One Gotcha

I was satisfied with the result and it worked consistently. The only gotcha here is that you can't create any side effects within the task callback, the task should run an operation in relative isolation and then return an optional value.

Meaning: You can't create something like an HTTP client macro within the task callback and expect that to be available to the rest of the application afterwards. But that ultimately made sense to me, so it was a tradeoff that I accepted.

And there you have it, a loading spinner for the terminal. Let's see it in action on last time:

Some lovely user feedback right in the terminal
Some lovely user feedback right in the terminal

Thanks for Reading! I'm Building a CLI Tool.

I'm building a CLI tool called Bellows. It's an intelligent command line utility that works with your Laravel Forge account to get your site up and running with all of your third party integrations configured at the speed of light.

Bellows reads your local project's .env, config files, and installed packages to intelligently automate your deployment process, asking you questions along the way to confirm its guesses.

Check it out (and subscribe to be notified when we launch): https://bellows.dev

(Excellent) syntax highlighting provided by Torchlight