Ejecutar Comandos de Terminal (CLI) y Scripts de Python en Laravel

- Andrés Cruz - EN In english

Video thumbnail

Laravel ofrece la capacidad de ejecutar comandos directamente en la consola o terminal del sistema operativo a través de la clase Process. Esta herramienta permite invocar comandos y programas externos, lo que facilita integrar el ecosistema de Laravel con otros entornos, como Python.

Esta integración permite combinar la estructura de Laravel con las herramientas especializadas de Python en áreas como el análisis de datos, el procesamiento de imágenes o el trabajo con modelos de lenguaje (LLM). De esta manera, Python puede actuar como un servicio o API encargado del procesamiento pesado, devolviendo los resultados a Laravel para su posterior uso.

1. El Script de Python para Análisis de Imágenes

Para este ejemplo, se utiliza un script en Python que analiza una imagen y devuelve los metadatos formateados como un diccionario (el equivalente a un arreglo asociativo). El script retorna una estructura con los siguientes datos:

  • status: Estado del procesamiento.
  • path: Nombre y ubicación del archivo.
  • size: Tamaño de la imagen en kilobytes.
  • width y height: Dimensiones en píxeles.
  • absolute_path: Ruta absoluta del archivo en el sistema.

app/Python/analyzer.py

"""Analiza un archivo local y devuelve sus metadatos en formato JSON.

Solo usa la librería estándar de Python, por lo que no requiere instalar
Pillow, pytesseract ni ninguna otra dependencia externa.

Uso desde Laravel:

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

import json
import struct
import sys
from pathlib import Path

# Marcadores JPEG "Start Of Frame" que contienen ancho y alto.
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  # Se salta el marcador inicial SOI (0xFFD8).

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

        marker = data[index + 1]

        # Marcadores que no llevan tamaño de bloque.
        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):
    """Devuelve (ancho, alto) leyendo solo la cabecera del archivo."""
    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': 'El archivo no existe: {}'.format(path)}

    if not path.is_file():
        return {'error': 'La ruta no es un archivo: {}'.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': 'No se proporciono la ruta del archivo'}))
        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. Encapsulamiento de la Lógica en un Action

Para mantener la lógica de negocio centralizada, el llamado al proceso de Python se define dentro de una clase tipo Action. La estructura del flujo incluye las siguientes etapas:

  • Configuración del ejecutable: Se define el comando base (por ejemplo, python3 o la ruta específica del intérprete configurado en el sistema).
  • Construcción del comando: Se pasa como argumento la ruta absoluta del script (analyzer.py) y la ruta de la imagen que se desea procesar.
  • Manejo de tiempos de espera (Timeout): Se establece un tiempo límite de ejecución para evitar que el proceso bloquee la aplicación si se genera un retraso prolongado.
  • Decodificación de la salida: Se recibe el resultado del script (en formato JSON), se valida la respuesta y se transforma en un arreglo manipulable por Laravel. Si la salida es inválida o el proceso falla, se gestiona la excepción correspondiente.

app/Actions/AnalyzeFileWithPython.php

<?php

namespace App\Actions;

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

/**
 * Ejecuta el script de Python app/Python/analyzer.py y devuelve su salida JSON.
 */
class AnalyzeFileWithPython
{
    /**
     * Analiza un archivo local pasando su ruta absoluta como argumento a Python.
     *
     * @return array<string, mixed>
     *
     * @throws RuntimeException Si el proceso falla o no devuelve JSON válido.
     */
    public function handle(string $absolutePath): array
    {
        $script = base_path('app/Python/analyzer.py');

        // El array de argumentos evita el shell: no hay que escapar comillas ni
        // quedan expuestos los comandos encadenados de la petición del usuario.
        // Utiliza la fachada Process (introducida en Laravel 10) para correr el archivo analyzer.py pasándole como argumento la ruta de un archivo ($absolutePath).
        $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 : 'El script de Python falló.');
        }

        if (! is_array($decoded)) {
            throw new RuntimeException('El script de Python no devolvió un JSON válido.');
        }

        return $decoded;
    }
}

Puedes crear una configuración para personalizarlo, en este ejemplo, usando la variable de entorno PYTHON_BINARY:

config/services.php

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

3. Implementación del Controlador de Acción Única (Invokable)

Para exponer esta funcionalidad mediante un punto de acceso HTTP, se utiliza un controlador de acción única (Single Action Controller) mediante el método mágico __invoke.

A diferencia de un controlador convencional con múltiples métodos, un controlador invokable permite definir una única responsabilidad por clase. Al registrar la ruta, se hace referencia directamente a la clase del controlador sin necesidad de especificar un método en un arreglo.

Dentro del controlador se realiza lo siguiente:

  1. Se recibe la solicitud e inyectan las dependencias necesarias.
  2. Se verifica la existencia y validez de la imagen almacenada (por ejemplo, en el directorio storage/app/public).
  3. Se invoca el método handle del Action pasando la ruta absoluta de la imagen.
  4. Se retorna la respuesta con los metadatos obtenidos en formato JSON.

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
{
    /**
     * Analiza un archivo de storage/app/public con el script de Python.
     *
     * GET /python/analyze?file=sample.jpeg
     */
    public function __invoke(Request $request, AnalyzeFileWithPython $analyze): JsonResponse
    {
        $file = $request->query('file', 'sample.png');

        // Solo se permiten nombres de archivo relativos dentro de storage/app/public:
        // cualquier ../intentaría leer fuera del directorio.
        if (! is_string($file) || $file === '' || str_contains($file, '/') || str_contains($file, '\\')) {
            return response()->json([
                'laravel_status' => 'ERROR',
                'message' => 'Indica un nombre de archivo válido, por ejemplo: ?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. Verificación y Resultados

Al ejecutar la ruta con diferentes archivos de prueba (como imágenes en formato JPG o PNG almacenadas en el sistema de archivos), la aplicación ejecuta el proceso en segundo plano y retorna una respuesta con la información detallada de la imagen:

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"

El uso de la clase Process en Laravel permite extender las capacidades de la aplicación más allá del ecosistema de PHP. Siempre que se cuente con los permisos adecuados en el servidor o entorno local, es posible ejecutar cualquier utilidad de la línea de comandos para delegar tareas complejas a herramientas externas especializadas.

Mediante la clase Process de Laravel, puedes interactuar con la Terminal de tu SO y ejecutar cualquier cosa.


Ú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:

Acepto recibir anuncios de interes sobre este Blog.