Stream Terminal Output To Browser

Dan Pastori

December 16th, 2025

Building off of our last blog post, Sending Server Sent Events (SSE) with Laravel, let's explore one of coolest features you can add with SSE, streaming terminal output to the browser. This is really the heart and soul of Benchkit. All benchmark commands run through the terminal, but as a user, you can see the output right in your web browser. No websockets required!

To make this work, we rely heavily on the \Symfony\Component\Process\Process class. If you are using Laravel, this ships automatically with the framework. This class allows you to control shell command line from PHP and gather the output. With that output, we can stream it back to the web browser using an SSE.

How does this work? Within our streamed response function, we need to build out the following chunks of code.

First, we record our heartbeat and build our output callback function:

Set up heartbeat and callback function

//... inside stream function
$lastHeartbeat = time();
$outputCallback = function ($data) {
    echo "data: " . json_encode($data) . "\n\n";
    @ob_flush(); flush();
};

The heartbeat will be sent every 30 seconds because some of these commands take awhile to run. The output callback simply converts the output from the terminal to JSON and echos it out which we can handle on the javascript side.

Next, we build out our command. This is the exact code we use in Benchkit to run our PHP benchmarks:

Build terminal command

$bin = base_path('vendor/bin/phpbench');
$command = sprintf(
    'script -q /dev/null -c %s',
    escapeshellarg(sprintf('%s run --report=comparison --output=csv > results/phpbench_results.csv', $bin))
);

$process = \Symfony\Component\Process\Process::fromShellCommandline(
    $command, 
    base_path(), 
    null, 
    null, 
    null
);

It's important that we escape the shell arguments when building the command. We absolutely do not want malicious code being passed in. Especially when we allow the user to set parameters.

Next, we start the process! The command is now running.

What's cool about the Symfony Process class, is you can literally just loop through while it's running, grab the new output, and send it back. One thing to note, is we also sleep the CPU for a little bit. We are building a "non-constrained" loop and we absolutely do not want it running wild.

Iterate over process while running and send back output

while ($process->isRunning()) {
    // Check for process output
    $output = $process->getIncrementalOutput();
    $errorOutput = $process->getIncrementalErrorOutput();

    // Small sleep to prevent CPU spinning
    usleep(100000); // 0.1 seconds
}

That's exactly what we do! We do 3 checks. If there's new output, send it. If there's error output, send it, and if we need to send a heartbeat, we send that as well.

Below is the full chunk of code that shows how we receive and send output. There's a few extra pieces in there as well that strip out terminal characters to make things look better.

Full example of iterating over output and sending an SSE

return response()->stream(function(){
    // ... clear output buffers, set up command, etc.

    $lastHeartbeat = time();
    $outputCallback = function ($data) {
        echo "data: " . json_encode($data) . "\n\n";
        @ob_flush(); flush();
    };

    $bin = base_path('vendor/bin/phpbench');
    $command = sprintf(
        'script -q /dev/null -c %s',
        escapeshellarg(sprintf('%s run --report=comparison --output=csv > results/phpbench_results.csv', $bin))
    );

    $process = \Symfony\Component\Process\Process::fromShellCommandline(
        $command, 
        base_path(), 
        null, 
        null, 
        null
    );

    // Loop while process is running, sending heartbeats every 30 seconds
    while ($process->isRunning()) {
        // Check for process output
        $output = $process->getIncrementalOutput();
        $errorOutput = $process->getIncrementalErrorOutput();
        
        if ($output !== '') {
            $lines = explode("\n", trim($output));
            foreach ($lines as $line) {
                 // Remove ANSI escape sequences
                $text = preg_replace('/\x1b\[[0-9;]*[a-zA-Z]/', '', $line);
                // Remove other control characters except newlines and tabs
                $text = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $text);

                if (trim($line) !== '') {
                    $outputCallback([
                        'timestamp' => date('Y-m-d H:i:s'),
                        'type' => 'out',
                        'output' => $text,
                    ]);
                }
            }
        }

        if ($errorOutput !== '') {
            $lines = explode("\n", trim($errorOutput));
            foreach ($lines as $line) {
                if (trim($line) !== '') {
                    $outputCallback([
                        'timestamp' => date('Y-m-d H:i:s'),
                        'type' => 'err',
                        'output' => $line,
                    ]);
                }
            }
        }
        
        // Send heartbeat every 30 seconds
        if (time() - $lastHeartbeat >= 30) {
            echo "data: " . json_encode([
                'timestamp' => date('Y-m-d H:i:s'),
                'type' => 'heartbeat',
                'output' => 'Connection alive',
            ]) . "\n\n";
            @ob_flush(); flush();
            $lastHeartbeat = time();
        }
        
        // Small sleep to prevent CPU spinning
        usleep(100000); // 0.1 seconds
    }
}, 200, [
    'Content-Type' => 'text/event-stream',
    'Cache-Control' => 'no-cache',
    'Connection' => 'keep-alive',
    'X-Accel-Buffering' => 'no',
    'Access-Control-Allow-Origin' => '*',
]);

If you want to see the full code for how we do this, Benchkit is fully open source. I can see quite a few use cases for this, even in education where you could have a course interact with a terminal from the web. Obviously you want to pay heavy attention to security, but it'd be pretty cool! You could also implement this we web sockets as well, just send it through the web socket server instead of the server sent event.

Want to work together?

Professional developers choose Server Side Up to ship quality applications without surrendering control. Explore our tools and resources or work directly with us.

Join our community

We're a community of 3,000+ members help each other level up our development skills.

Platinum Sponsors

Active Discord Members

We help each other through the challenges and share our knowledge when we learn something cool.

Stars on GitHub

Our community is active and growing.

Newsletter Subscribers

We send periodic updates what we're learning and what new tools are available. No spam. No BS.

Sign up for our newsletter

Be the first to know about our latest releases and product updates.

    Privacy first. No spam. No sharing. Just updates.