Executing Terminal Commands (CLI) and Python Scripts in Laravel

- Andrés Cruz - ES En español

Video thumbnail

Laravel offers the ability to execute commands directly in the operating system console or terminal through the Process class. This tool allows invoking external commands and programs, making it easy to integrate the Laravel ecosystem with other environments, such as Python.

This integration allows combining Laravel's structure with Python's specialized tools in areas such as data analysis, image processing, or working with Large Language Models (LLMs). In this way, Python can act as a service or API in charge of heavy processing, returning the results to Laravel for subsequent use.

1. The Python Script for Image Analysis

For this example, a Python script is used that analyzes an image and returns the metadata formatted as a dictionary (the equivalent of an associative array). The script returns a structure with the following data:

  • status: Processing status.
  • path: File name and location.
  • size: Image size in kilobytes.
  • width and height: Dimensions in pixels.
  • absolute_path: Absolute path of the file on the system.

app/Python/analyzer.py

"""Analyses a local file and returns its metadata in JSON format.

It only uses Python's standard library, so it does not require installing
Pillow, pytesseract, or any other external dependency.

Usage from Laravel:

    Process::run(['python3', base_path('app/Python/analyzer.py'), $imagePath]);
"""

import json
import struct
import sys
from pathlib import Path

# JPEG "Start Of Frame" markers containing width and height.
JPEG_SOF_MARKERS = {
    0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7,
    0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF,
}


def _png_size(data: bytes):
    if len(data) >= 24 and data[12:16] == b'IHDR':
        width, height = struct.unpack('>II', data[16:24])
        return width, height
    return None


def _gif_size(data: bytes):
    if len(data) >= 10:
        width, height = struct.unpack('<HH', data[6:10])
        return width, height
    return None


def _bmp_size(data: bytes):
    if len(data) >= 26:
        width, height = struct.unpack('<ii', data[18:26])
        return width, abs(height)
    return None


def _jpeg_size(data: bytes):
    index = 2  # Skips initial SOI marker (0xFFD8).

    while index + 9 < len(data):
        if data[index] != 0xFF:
            index += 1
            continue

        marker = data[index + 1]

        # Markers that do not carry block size.
        if marker in (0xD8, 0xD9) or 0xD0 <= marker <= 0xD7:
            index += 2
            continue

        block_size = struct.unpack('>H', data[index + 2:index + 4])[0]

        if marker in JPEG_SOF_MARKERS:
            height, width = struct.unpack('>HH', data[index + 5:index + 9])
            return width, height

        index += 2 + block_size

    return None


def image_dimensions(file_path: Path):
    """Returns (width, height) reading only the file header."""
    readers = {
        '.png': _png_size,
        '.gif': _gif_size,
        '.bmp': _bmp_size,
        '.jpg': _jpeg_size,
        '.jpeg': _jpeg_size,
    }

    reader = readers.get(file_path.suffix.lower())

    if reader is None:
        return None

    with file_path.open('rb') as handle:
        header = handle.read(65536)

    return reader(header)


def analyze_file(file_path: str) -> dict:
    path = Path(file_path).expanduser()

    if not path.exists():
        return {'error': 'File does not exist: {}'.format(path)}

    if not path.is_file():
        return {'error': 'Path is not a file: {}'.format(path)}

    try:
        size = image_dimensions(path)
    except OSError:
        size = None

    return {
        'status': 'success',
        'file_name': path.name,
        'extension': path.suffix.lower(),
        'size_bytes': path.stat().st_size,
        'size_kb': round(path.stat().st_size / 1024, 2),
        'width': size[0] if size else None,
        'height': size[1] if size else None,
        'absolute_path': str(path.resolve()),
    }


def main(argv) -> int:
    if len(argv) < 2:
        print(json.dumps({'error': 'File path was not provided'}))
        return 1

    result = analyze_file(argv[1])

    print(json.dumps(result))

    return 0 if 'error' not in result else 1


if __name__ == '__main__':
    sys.exit(main(sys.argv))

2. Encapsulating Logic in an Action

To keep the business logic centralized, the call to the Python process is defined inside an Action class. The workflow structure includes the following stages:

  • Executable Configuration: The base command is defined (for example, python3 or the specific path of the interpreter configured in the system).
  • Command Construction: The absolute path of the script (analyzer.py) and the path of the image to be processed are passed as arguments.
  • Timeout Handling: An execution time limit is established to prevent the process from blocking the application if a prolonged delay occurs.
  • Output Decoding: The script's output (in JSON format) is received, validated, and transformed into a array manageable by Laravel. If the output is invalid or the process fails, the corresponding exception is managed.

app/Actions/AnalyzeFileWithPython.php

<?php

namespace App\Actions;

use Illuminate\Support\Facades\Process;
use RuntimeException;

/**
 * Executes the Python script app/Python/analyzer.py and returns its JSON output.
 */
class AnalyzeFileWithPython
{
    /**
     * Analyzes a local file passing its absolute path as an argument to Python.
     *
     * @return array<string, mixed>
     *
     * @throws RuntimeException If the process fails or does not return valid JSON.
     */
    public function handle(string $absolutePath): array
    {
        $script = base_path('app/Python/analyzer.py');

        // The argument array avoids the shell: no quotes need to be escaped nor
        // are chained commands from the user request exposed.
        // Uses the Process facade (introduced in Laravel 10) to run analyzer.py passing the file path ($absolutePath) as an argument.
        $result = Process::path(dirname($script))
            ->timeout(30)
            ->run([
                config('services.python.binary', 'python3'),
                $script,
                $absolutePath,
            ]);

        $decoded = json_decode($result->output(), true);

        if ($result->failed()) {
            $message = $decoded['error'] ?? trim($result->errorOutput());

            throw new RuntimeException($message !== '' ? $message : 'The Python script failed.');
        }

        if (! is_array($decoded)) {
            throw new RuntimeException('The Python script did not return valid JSON.');
        }

        return $decoded;
    }
}

You can create a configuration to customize it, in this example, using the environment variable PYTHON_BINARY:

config/services.php

    'python' => [
        'binary' => env('PYTHON_BINARY', 'python3'),
    ],

3. Implementation of Single Action Controller (Invokable)

To expose this functionality through an HTTP endpoint, a Single Action Controller is used via the magic method __invoke.

Unlike a conventional controller with multiple methods, an invokable controller allows defining a single responsibility per class. When registering the route, direct reference is made to the controller class without needing to specify a method in an array.

Inside the controller, the following is performed:

  1. The request is received and the necessary dependencies are injected.
  2. The existence and validity of the stored image (for example, in the storage/app/public directory) are verified.
  3. The handle method of the Action is invoked by passing the absolute path of the image.
  4. The response is returned with the metadata obtained in JSON format.

routes/web.php

Route::get('/python/analyze', PythonAnalyzerController::class)->name('python.analyze');

app/Http/Controllers/PythonAnalyzerController.php

<?php

namespace App\Http\Controllers;

use App\Actions\AnalyzeFileWithPython;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use RuntimeException;
use Symfony\Component\HttpFoundation\Response;

class PythonAnalyzerController extends Controller
{
    /**
     * Analyzes a file from storage/app/public using the Python script.
     *
     * GET /python/analyze?file=sample.jpeg
     */
    public function __invoke(Request $request, AnalyzeFileWithPython $analyze): JsonResponse
    {
        $file = $request->query('file', 'sample.png');

        // Only relative file names inside storage/app/public are allowed:
        // any ../ would attempt to read outside the directory.
        if (! is_string($file) || $file === '' || str_contains($file, '/') || str_contains($file, '\\')) {
            return response()->json([
                'laravel_status' => 'ERROR',
                'message' => 'Provide a valid file name, for example: ?file=sample.png',
            ], Response::HTTP_UNPROCESSABLE_ENTITY);
        }

        try {
            $metadata = $analyze->handle(storage_path("app/public/{$file}"));
        } catch (RuntimeException $exception) {
            return response()->json([
                'laravel_status' => 'ERROR',
                'message' => $exception->getMessage(),
            ], Response::HTTP_UNPROCESSABLE_ENTITY);
        }

        return response()->json([
            'laravel_status' => 'OK',
            'python_response' => $metadata,
        ]);
    }
}

4. Verification and Results

When executing the route with different test files (such as images in JPG or PNG format stored in the file system), the application runs the process in the background and returns a response with detailed image information:

python_response	
status	"success"
file_name	"sample.png"
extension	".png"
size_bytes	1639
size_kb	1.6
width	640
height	400
absolute_path	"/Users/andrescruz/Herd/larafirststep/storage/app/public/sample.png"

Using the Process class in Laravel allows extending the capabilities of the application beyond the PHP ecosystem. As long as adequate permissions are available on the server or local environment, it is possible to run any command-line utility to delegate complex tasks to specialized external tools.

Using Laravel's Process class, you can interact with your operating system's terminal and execute anything.


Únete a la comunidad de desarrolladores que han decidido dejar de picar código y empezar a construir productos reales. Recibe mis mejores trucos de arquitectura cada semana:

I agree to receive announcements of interest about this Blog.