Tricks for Running Commands with Laravel Process

waiting for a long process to complete
Image by Annie Ruygt

Fly.io is a great place to run both apps and workers! Deploy now on Fly.io, and get your Laravel app running globally in a few commands!

Laravel 10 has released the Laravel’s Process facade, which make running external commands super easily.

$result = Process::run("php -v");
echo $result->output();

This uses Symfony Process under the hood, and adds a TON of quality-of-life improvements.

What might you run commands for? I’ve personally used it to run docker commands on an early version of Chipper CI, and recently have seen it used to run ffmpeg commands to edit media files. There’s a lot of times when it makes sense to reach out beyond PHP!

Let’s see how to run commands (processes), and learn some tricks along the way.

The Process Component

Some basics first! Let’s run the ls -lah command to list out files in the current directory:

$result = Process::run('ls -lah')

The $result is an instance of the Illuminate\Contracts\Process\ProcessResult interface.

If you check that out, we’ll see a few handy methods available to us:

# See if it was successful
$result->successful(); // zero exit code
$result->failed(); // non-zero exit code

$result->exitCode(); // the actual exit code

$result->output(); // stdout
$result->errorOutput(); // stderr

Stdout and stderr

Output from a command will come in 2 flavors: stderr and stdout. They’re sort of like different channels that a command can use to report information, errors, or important data.

Did you know that stderr isn’t just error messages? There’s some important stuff to know!

In general, stderr output will be human-readable output meant to inform users about errors or just general information.

The stdout output is generally meant to be something either human-readable or machine readbable. It might be something you’d ask some code to parse.

Let’s see that in action.

If we run fly apps list -j to get a JSON format list of apps and their information, we may see this:

flyctl output showing information message and json output

The informational message about the version of flyctl and how to update is output to stderr. The real content (the list of apps) is sent to stdout.

The stdout output is what we care about. Luckily, if I wanted to pipe the JSON output to jq for some extra parsing, I could! Even though the command outputs an informational message, sending output through a pipe will only pass along the stdout content.

# Find the status of each app
# The pipe `|` only get stdout content
fly apps list -j | jq '.[].Status'

That lists the status of each Fly.io app listed.

Parsing Output in PHP

We can use this knowledge to parse command output in PHP.

// Get our list of apps as JSON and parse it in PHP
$result = Process::run("fly apps list -j");

if ($result->successful()) {
    $apps = json_decode($result->output(), true);

    var_dump($apps);
}

Here’s a fun issue with the above code: The flyctl output has some debug information sent through stdout, so we can’t directly parse the JSON outout!

flyctl output mixing debug information with stdout

It turns out that when running the above in a tinker session, the Laravel environment has LOG_LEVEL=debug set. While that is a Laravel-specific environment variable coming from the .env file, flyctl happens to use it also!

Why is the fly command seeing that? This brings us to our next topic!

Environment Variables

Processes typically inherit their parent-processes environment variables. Since our PHP tinker session has environment variable LOG_LEVEL set, that’s getting passed to the fly command we ran. The fly command just so happens to use that environment variable, and so we get some debug information output (to stdout in this case).

Luckily, we can unset that environment variable by setting it to false:

# Unset the LOG_LEVEL env var when 
# running the process:
$result = Process::env(['LOG_LEVEL' => false])
    ->run("fly apps list -j");

if ($result->successful()) {
    $apps = json_decode($result, true);

    // todo: Ensure JSON was parsed
    foreach($apps as $app) {
        echo $app['Name']."\n";
    }
}

Now our output doesn’t contain the debug statements in stdout and we can parse the JSON in PHP!

We’re not limited to unsetting environment variables. We can pass additional ones (or overwrite others) by passing more things to that array:

# If you want to see a TON of debug
# output from the `fly` command:
$result = Process::env([
        'LOG_LEVEL' => 'debug',
        'DEV' => '1',
    ])->run("fly apps list -j");

Fly.io ❤️ Laravel

Fly your servers close to your users—and keep your apps speedy. Deploy globally on Fly in minutes!

Deploy your Laravel app!

Streaming Output

What if we run a command that does a bunch of stuff over time, like deploying a Fly app?

We can actually get a command’s output in “real time”. We just pass the run method a closure:

$result = Process::env('LOG_LEVEL' => false,)
    ->path("/path/to/dir/containing/a/fly.toml-file")
    ->run("fly deploy", function(string $type, string $output) {
        // We'll ignore stderr for now
        if ($type == 'stdout') {
            doSomethingWithThisChunkOfOutput($output);
        }
     });

I’ve used this to stream output to users watching in the browser (with the help of Pusher and websockets).

Security

I’m not going to talk about sanitizing user input and the dangers of allowing user input to define what commands you might run (yeah, probably don’t do that).

Instead, I have something specific to Fly.io and Laravel! If you ran fly launch to generate a Dockerfile, we have something to tweak here.

By default, the open_basedir setting is set in the php-fpm configuration. This limits what directories PHP can see, which prohibits the ability for PHP to see (and run) commands in /usr/local/bin or similar.

You change that, you can either remove that configuration or append a directory containing a command you’ll be using.

In file .fly/fpm/poold/www.conf, I can append directory /usr/local/bin:

; @fly settings:
; Security measures
php_admin_value[open_basedir] = /var/www/html:/dev/stdout:/tmp:/usr/local/bin
php_admin_flag[session.cookie_secure] = true

That change should get pulled in in your next deployment.

There’s More

There’s a bunch more you can do! Check out the docs and examples here!