Skip to content
All posts
LaravelPythonPersonal

When PHP Is Not Enough: Offloading Tasks from Laravel to Python, Go and Shell Scripts

March 1, 2026·Read on Medium·
laravel.com

There is a moment in every Laravel project where you hit a wall. Not a framework wall. Laravel itself is excellent. It is more like a language wall. You need to process 500,000 CSV rows without running out of memory. You need to detect whether an uploaded image is blurry. You need to clean up 3GB of old temp files on a schedule. PHP can technically do some of these things but it is not the right tool.

The good news? Laravel does not have to do everything. It just has to coordinate everything. Starting from Laravel 9, the Process facade gives you a clean, testable way to invoke external processes (shell scripts, Python scripts, compiled Go binaries) and get their output back into your application.

This article shows you how to use it in production, with real examples for each language.

The Foundation: Laravel’s Process Facade

Laravel’s Process facade wraps the Symfony Process component. The simplest usage looks like this:

use Illuminate\Support\Facades\Process;

$result = Process::run('ls -la');
echo $result->output(); // stdout
echo $result->errorOutput(); // stderr
echo $result->exitCode(); // 0 = success

There are two ways to run a process:

  • Process::run(): synchronous. Laravel waits until the command finishes before moving on.
  • Process::start(): asynchronous. Laravel fires the command and continues executing. You check back later.

The returned ProcessResult gives you output(), errorOutput(), exitCode(), successful() and failed().

One important note before diving in: never interpolate user input directly into a command string. Always use PHP’s escapeshellarg() to sanitise any value that comes from outside your application. We will apply this throughout every example.

How Data Flows Between Laravel and a Script

Before looking at each language, it helps to understand the two main patterns for passing data in and getting results back.

Pattern 1: Command-line arguments. For simple values like a file path or a single flag, pass them as escaped arguments directly in the command string. The script reads them via argv. This works fine for short, safe values.

Pattern 2: Standard input (stdin). For structured data like a JSON payload, use Process::input() to write to the script's stdin. The script reads the full payload, processes it and writes a JSON response to stdout. Laravel then reads output() and decodes it. This is the cleaner approach for anything beyond a single string.

The convention used throughout this article: input via stdin as JSON, output via stdout as JSON. This keeps the interface consistent and easy to test regardless of which language the script is written in.

Example 1: Shell Script for Cleaning Up Old Storage Files

The problem: Your users upload files that get processed and moved. The original temp uploads in storage/app/temp are no longer needed after 48 hours. You want to delete them on a schedule without tying up a PHP worker.

Why a shell script? This is pure filesystem work. A shell script is the simplest tool for the job. No reason to bring PHP into it.

Save this at scripts/cleanup_temp.sh:

#!/bin/bash

# Usage: bash cleanup_temp.sh /absolute/path/to/temp/dir 48
# Deletes files older than N hours in the given directory.
TARGET_DIR=$1
HOURS_OLD=$2
if [ -z "$TARGET_DIR" ] || [ -z "$HOURS_OLD" ]; then
echo "Error: missing arguments" >&2
exit 1
fi
if [ ! -d "$TARGET_DIR" ]; then
echo "Error: directory not found: $TARGET_DIR" >&2
exit 1
fi
DELETED=0
while IFS= read -r -d '' file; do
rm -f "$file"
DELETED=$((DELETED + 1))
done < <(find "$TARGET_DIR" -maxdepth 1 -type f -mmin +$((HOURS_OLD * 60)) -print0)
echo "{\"deleted\": $DELETED}"

Make it executable:

chmod +x scripts/cleanup_temp.sh

Call this from an Artisan command or a scheduled job:

use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Log;

$tempDir = storage_path('app/temp');
$hoursOld = 48;
$result = Process::timeout(120)
->run(sprintf(
'bash %s %s %d',
escapeshellarg(base_path('scripts/cleanup_temp.sh')),
escapeshellarg($tempDir),
$hoursOld
));
if ($result->failed()) {
Log::error('Temp cleanup failed', [
'error' => $result->errorOutput(),
'code' => $result->exitCode(),
]);
return;
}
$data = json_decode($result->output(), true);
Log::info("Temp cleanup complete. Deleted {$data['deleted']} files.");

Register it in your scheduler inside routes/console.php:

Schedule::command('app:cleanup-temp')->daily();

What is happening here:

escapeshellarg() wraps both the script path and the target directory, preventing shell injection even if storage paths contain unexpected characters. The script writes a JSON response to stdout so Laravel can parse a structured result rather than scraping plain text. Any errors go to stderr via >&2 and surface through errorOutput().

The find command uses -print0 combined with read -d '' to handle filenames that contain spaces correctly. The simpler for f in $(find ...) pattern breaks on those.

Example 2: Python Script for Detecting Blurry Images

The problem: Users upload profile photos and documents. Some uploads are blurry: phone cameras taken mid-motion, scanned pages out of focus. You want to reject those automatically before saving them.

Why Python? OpenCV’s Laplacian variance method is the standard technique for blur detection. OpenCV has a Python binding that is widely documented and production-proven. There is no comparable native PHP solution.

Install the dependency on your server:

pip install opencv-python-headless

Use opencv-python-headless on servers. It skips GUI dependencies that are not needed in a web environment and keeps your install smaller.

Save this at scripts/detect_blur.py:

#!/usr/bin/env python3
"""
Reads a JSON payload from stdin:
{"image_path": "/absolute/path/to/image.jpg"}

Writes a JSON result to stdout:
{"is_blurry": true, "variance": 45.2, "threshold": 100.0}
Exit code 0 = success. A blurry result is still exit code 0.
Exit code 1 = the script itself failed (file not found, bad input, etc.).
"""

import sys
import json
import cv2
def main():
try:
payload = json.loads(sys.stdin.read())
image_path = payload.get("image_path")
if not image_path:
raise ValueError("Missing 'image_path' in input payload")
image = cv2.imread(image_path)
if image is None:
raise FileNotFoundError(f"Could not open image: {image_path}")
# Convert to grayscale and compute the Laplacian variance.
# High variance = sharp edges = not blurry.
# Low variance = soft / uniform regions = blurry.
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
variance = cv2.Laplacian(gray, cv2.CV_64F).var()
threshold = 100.0
result = {
"is_blurry": bool(variance < threshold),
"variance": round(float(variance), 2),
"threshold": threshold,
}
print(json.dumps(result))
sys.exit(0)
except Exception as e:
print(json.dumps({"error": str(e)}), file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()

Call it from your Controller after the upload:

use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;

public function store(Request $request)
{
$request->validate(['photo' => 'required|image|max:5120']);
$path = $request->file('photo')->store('temp');
$absolutePath = storage_path('app/' . $path);
$payload = json_encode(['image_path' => $absolutePath]);
$result = Process::timeout(30)
->input($payload)
->run('python3 ' . escapeshellarg(base_path('scripts/detect_blur.py')));
if ($result->failed()) {
Log::error('Blur detection script failed', [
'error' => $result->errorOutput(),
]);
// Fail open: if the script itself crashes, let the upload through
// and investigate via logs rather than blocking the user.
} else {
$data = json_decode($result->output(), true);
if ($data['is_blurry'] ?? false) {
Storage::delete($path);
return back()->withErrors([
'photo' => "The image appears blurry (score: {$data['variance']}). Please upload a clearer photo.",
]);
}
}
Storage::move($path, 'photos/' . basename($path));
return redirect()->route('profile')->with('success', 'Photo uploaded.');
}

What is happening here:

Process::input($payload) pipes the JSON string directly to the Python script's stdin. The script reads it with sys.stdin.read().

Notice that a blurry image returns exit code 0. It is a valid business result. Only a script crash (file not found, unreadable image, missing dependency) produces exit code 1. This distinction matters because it keeps your error handling clean: exit code signals whether the script ran successfully, not what it found.

Example 3: Go Binary for Processing a Large CSV Export

The problem: Your reporting module needs to aggregate a CSV export that can reach 200,000 to 500,000 rows of sales transactions or log entries. Doing this in PHP is slow and memory-hungry. A Go binary can process the same file in a fraction of the time with constant memory usage via streaming.

Why Go? Go is compiled and has excellent CSV support in its standard library. Once compiled, the binary has zero runtime dependencies. You only need Go installed at build time, not on the production server.

Save this at scripts/csv_aggregator/main.go:

package main

import (
"encoding/csv"
"encoding/json"
"fmt"
"io"
"os"
"strconv"
)

// Input via stdin:
// {"file_path": "/absolute/path/to/export.csv", "amount_column": 3}
//
// Output via stdout:
// {"total_rows": 423100, "total_amount": 9823451.23, "error_rows": 12}
go
type Input struct {
FilePath string `json:"file_path"`
AmountColumn int `json:"amount_column"`
}

type Output struct {
TotalRows int `json:"total_rows"`
TotalAmount float64 `json:"total_amount"`
ErrorRows int `json:"error_rows"`
}

func main() {
var input Input

decoder := json.NewDecoder(os.Stdin)
if err := decoder.Decode(&input); err != nil {
fmt.Fprintf(os.Stderr, `{"error": "invalid JSON input: %s"}`, err.Error())
os.Exit(1)
}

file, err := os.Open(input.FilePath)
if err != nil {
fmt.Fprintf(os.Stderr, `{"error": "cannot open file: %s"}`, err.Error())
os.Exit(1)
}
defer file.Close()

reader := csv.NewReader(file)
reader.Read() // skip header row

var output Output

for {
record, err := reader.Read()
if err == io.EOF {
break
}
if err != nil {
output.ErrorRows++
continue
}

output.TotalRows++

if input.AmountColumn < len(record) {
amount, err := strconv.ParseFloat(record[input.AmountColumn], 64)
if err == nil {
output.TotalAmount += amount
} else {
output.ErrorRows++
}
}
}

result, _ := json.Marshal(output)
fmt.Println(string(result))
os.Exit(0)
}

Compile it once on your server:

cd scripts/csv_aggregator
go build -o ../csv_aggregator main.go

This produces a single binary at scripts/csv_aggregator with no external dependencies.

Call it from a Queue Job so the HTTP request returns immediately:

use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Log;
use App\Models\Report;

public function handle(Report $report): void
{
$payload = json_encode([
'file_path' => storage_path('app/exports/' . $report->filename),
'amount_column' => 3, // zero-indexed column position
]);
$binary = base_path('scripts/csv_aggregator');
$result = Process::timeout(300) // 5 minutes for large files
->input($payload)
->run(escapeshellarg($binary));
if ($result->failed()) {
Log::error('CSV aggregator failed', [
'report_id' => $report->id,
'error' => $result->errorOutput(),
'exit_code' => $result->exitCode(),
]);
$report->update(['status' => 'failed']);
return;
}
$data = json_decode($result->output(), true);
$report->update([
'status' => 'complete',
'total_rows' => $data['total_rows'],
'total_amount' => $data['total_amount'],
'error_rows' => $data['error_rows'],
]);
}

What is happening here:

The binary is called directly. No bash or interpreter prefix is needed since it is a compiled executable. Process::timeout(300) is set explicitly because the default is 60 seconds, which is too short for large files. This runs inside a Queue Job so the HTTP layer is never blocked.

When to Use Async: Fire and Monitor

All three examples above use Process::run(), which blocks until the script finishes. Inside a Queue Job that is perfectly fine. But if you need to kick off a process and stream its output in real time, use Process::start():

$process = Process::timeout(300)->start(
escapeshellarg($binary),
function (string $type, string $output) {
// $type is 'out' for stdout or 'err' for stderr
Log::info("[$type] $output");
}
);

// Other work can go here...
$result = $process->wait(); // blocks until done, returns ProcessResult
if ($result->failed()) {
// handle failure
}

Handling Failures the Right Way

Every external process can fail. The script may not exist, the binary may lack execute permission or the input may be malformed. Handle this consistently:

$result = Process::timeout(60)
->input($payload)
->run('python3 ' . escapeshellarg(base_path('scripts/detect_blur.py')));


// Option 1: throw an exception automatically if exit code > 0
$result->throw();
// Option 2: manual check with structured logging
if ($result->failed()) {
Log::error('Script failed', [
'exit_code' => $result->exitCode(),
'stderr' => $result->errorOutput(),
]);
throw new \RuntimeException('External script failed. See logs.');
}
// Always decode safely
$data = json_decode($result->output(), true);
if (json_last_error() !== JSON_ERROR_NONE) {
Log::error('Script returned invalid JSON', ['output' => $result->output()]);
throw new \RuntimeException('Script returned unparseable output.');
}

The three things always worth logging when a script fails: the exit code, stderr and the raw stdout output in case the script wrote partial data before dying.

Security: Rules Before You Ship

1. Always use escapeshellarg() on any external value in a command string.

// Wrong: shell injection risk if $path contains ; rm -rf /
Process::run("python3 scripts/blur.py $path");

// Right: path is escaped
Process::run('python3 ' . escapeshellarg(base_path('scripts/blur.py')));
// Best: pass structured data via stdin rather than args
Process::input(json_encode(['path' => $path]))
->run('python3 ' . escapeshellarg(base_path('scripts/blur.py')));

2. Pass secrets via environment variables, not arguments.

Command arguments are visible in process listings (ps aux). Use ->env() for anything sensitive:

Process::env(['DB_PASSWORD' => config('database.connections.mysql.password')])
->run(escapeshellarg(base_path('scripts/export.sh')));

In your shell script read it as $DB_PASSWORD. In Python use os.environ.get('DB_PASSWORD').

3. Never let user input control which script runs.

// Never do this
$script = $request->input('script');
Process::run("bash scripts/$script.sh");

Script paths should always be hardcoded in your application logic.

Testing with Process::fake()

The Process facade is fully fakeable so you can write proper feature tests without touching a real script:

use Illuminate\Support\Facades\Process;


it('rejects blurry images', function () {
Process::fake([
'python3 *detect_blur.py*' => Process::result(
output: json_encode([
'is_blurry' => true,
'variance' => 45.2,
'threshold' => 100.0,
]),
exitCode: 0
),
]);
$file = UploadedFile::fake()->image('photo.jpg');
$response = $this->post('/profile/photo', ['photo' => $file]);
$response->assertSessionHasErrors('photo');
Process::assertRan(fn ($process) => str_contains($process->command, 'detect_blur.py'));
});
it('allows sharp images', function () {
Process::fake([
'python3 *detect_blur.py*' => Process::result(
output: json_encode([
'is_blurry' => false,
'variance' => 312.7,
'threshold' => 100.0,
]),
exitCode: 0
),
]);
$file = UploadedFile::fake()->image('photo.jpg');
$response = $this->post('/profile/photo', ['photo' => $file]);
$response->assertRedirect('/profile');
});

Process::fake() supports * wildcards for partial command matching. assertRan() confirms the command was actually invoked during the test run.

The Right Mental Model

Think of Laravel as the manager, not the worker. Laravel handles HTTP routing, authentication, database operations, queues and business rules. When a task needs a different tool, Laravel hands it off via Process, waits for a structured response and acts on the result.

The contract between Laravel and any external script is always the same: JSON in via stdin, JSON out via stdout, exit code signals success or failure. Keep that contract consistent across all your scripts and this pattern stays easy to reason about, easy to test and easy to debug.

Found this helpful?

If this article saved you time or solved a problem, consider supporting — it helps keep the writing going.

Originally published on Medium.

View on Medium
When PHP Is Not Enough: Offloading Tasks from Laravel to Python, Go and Shell Scripts — Hafiq Iqmal — Hafiq Iqmal