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:
//... 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:
$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.
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.
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.
Professional developers choose Server Side Up to ship quality applications without surrendering control. Explore our tools and resources or work directly with us.

We're a community of 3,000+ members help each other level up our development skills.
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.
Be the first to know about our latest releases and product updates.