Siete técnicas de rendimiento que marcan la diferencia en Laravel

Una aplicación Laravel puede ser muy rápida o desesperantemente lenta dependiendo de unas pocas decisiones técnicas. El framework en sí no tiene la culpa: la mayoría de los problemas de rendimiento vienen de queries mal planteadas, configuración por defecto que vale para desarrollo pero no para producción, o código que funciona bien con diez registros y se ahoga con diez mil.

Estas siete técnicas cubren los cuellos de botella más habituales. No son teoría: son los cambios que más veces hacen que una aplicación pase de ir a trompicones a responder con fluidez.

1. Eager loading: acaba con el problema N+1

El problema N+1 es el más clásico de Laravel y también el más fácil de pasar por alto. Ocurre cuando cargas una colección de modelos y luego accedes a una relación dentro de un bucle, lo que dispara una query adicional por cada elemento.

// MAL: una query para los posts, una más por cada post para el usuario
$posts = Post::all();
foreach ($posts as $post) {
    echo $post->user->name; // query adicional aquí
}

Con cien posts tienes ciento un queries. Con mil posts tienes mil una. La solución es with(), que carga la relación en una sola query usando un JOIN o un WHERE IN:

// BIEN: dos queries en total, independientemente del número de posts
$posts = Post::with('user')->get();

La diferencia real que puedes esperar: pasar de 101 queries a 2 queries para cargar cien posts con sus autores. En tiempo de respuesta, eso puede significar bajar de 800ms a menos de 50ms dependiendo de la carga del servidor de base de datos.

Si necesitas cargar relaciones anidadas, encadénalas: Post::with('user.profile', 'comments.author')->get(). Y si solo necesitas algunos campos, usa with('user:id,name') para no traer columnas innecesarias.

2. Caché de configuración, rutas y vistas

Laravel lee y procesa los archivos de configuración, rutas y vistas en cada petición... a menos que le digas que no lo haga. En producción, estos tres comandos son casi obligatorios:

php artisan config:cache
php artisan route:cache
php artisan view:cache

config:cache fusiona todos los archivos de config/ en un único fichero serializado. Sin esta caché, Laravel lee y parsea cada archivo de configuración en cada request. Con ella, carga un solo fichero. La mejora en tiempo de arranque suele estar entre un 20% y un 40%.

route:cache serializa todas las rutas en un fichero compilado. Con aplicaciones que tienen cientos de rutas, el tiempo de resolución puede reducirse a la mitad. Ojo importante: no funciona si tienes closures en tus archivos de rutas. Si usas Route::get('/', function() { ... }) en lugar de controladores, el comando fallará. La solución es mover esas rutas a un controlador.

view:cache compila todas las plantillas Blade por adelantado. Sin esto, Laravel compila cada vista la primera vez que se solicita. Con la caché, ya están compiladas y listas.

Recuerda limpiar estas cachés al desplegar (php artisan optimize:clear o php artisan cache:clear según el caso) o tendrás la configuración antigua activa.

3. Caché de datos con Redis

Algunas queries son caras por naturaleza: agregados sobre tablas grandes, joins complejos, llamadas a APIs externas. Ejecutarlas en cada request no tiene sentido si el resultado no cambia cada segundo.

$stats = Cache::remember('dashboard_stats', 3600, function () {
    return DB::table('orders')
        ->selectRaw('COUNT(*) as total, SUM(amount) as revenue')
        ->whereYear('created_at', now()->year)
        ->first();
});

Cache::remember() comprueba si la clave existe en caché. Si existe, la devuelve directamente. Si no, ejecuta el closure, guarda el resultado y lo devuelve. El segundo parámetro es el tiempo de vida en segundos (3600 = una hora).

Tags de caché para invalidación por grupo

Cuando necesitas invalidar varias entradas de caché a la vez, los tags son la herramienta adecuada. Por ejemplo, si cacheas varios datos relacionados con productos:

// Guardar con tags
Cache::tags(['productos', 'catalogo'])->remember('productos_destacados', 1800, function () {
    return Producto::where('destacado', true)->get();
});

// Invalidar todo el grupo cuando un producto cambia
Cache::tags(['productos'])->flush();

Los tags solo están disponibles con drivers que los soporten: Redis y Memcached. Con el driver file o database no funcionan.

Buenos candidatos para cachear: conteos y agregados, resultados de búsquedas frecuentes, respuestas de APIs de terceros, listados que cambian poco (categorías, configuración dinámica).

4. Queues para trabajo en segundo plano

Cualquier tarea que no necesita completarse antes de devolver la respuesta al usuario debería ir a una cola. El envío de emails, la generación de PDFs, el procesamiento de imágenes o las llamadas a APIs externas son los casos más habituales.

// En lugar de hacer esto en el controlador (bloquea la respuesta):
Mail::to($user)->send(new WelcomeMail($user));

// Despachar un job a la cola (la respuesta vuelve inmediatamente):
SendWelcomeMail::dispatch($user)->onQueue('emails');

Para pedidos con distintas prioridades, puedes tener varias colas y procesarlas en orden:

ProcessOrder::dispatch($order)->onQueue('high');
GenerateMonthlyReport::dispatch()->onQueue('low');

Y arrancar el worker dando prioridad a la cola high:

php artisan queue:work --queue=high,default,low

Para el driver de colas, Redis es la opción más sólida en producción por velocidad y soporte de features. En desarrollo, el driver sync ejecuta los jobs de forma síncrona (sin worker, sin Redis), lo que simplifica mucho el proceso de depuración.

Configúralo en .env: QUEUE_CONNECTION=redis en producción, QUEUE_CONNECTION=sync en local.

5. Laravel Octane: un salto de ciclo de vida

Con PHP-FPM, cada petición arranca la aplicación desde cero: carga el autoloader, instancia el contenedor, registra los providers, resuelve las rutas... y al terminar, todo eso se destruye. En la siguiente petición, empieza de nuevo.

Laravel Octane cambia eso. Con Swoole o FrankenPHP como servidor, los workers de PHP arrancan una vez y se quedan en memoria entre peticiones. La inicialización ocurre una sola vez por worker, no una vez por request.

composer require laravel/octane
php artisan octane:install
php artisan octane:start --server=swoole --workers=4

Las mejoras de throughput que se ven en benchmarks están entre 3x y 10x para APIs sin estado. Para aplicaciones con mucha lógica de base de datos la ganancia es más modesta, pero sigue siendo significativa.

La trampa del estado estático

El principal riesgo de Octane es que los singletons y el estado estático persisten entre requests. Si guardas datos del usuario actual en una propiedad estática de una clase, el siguiente usuario podría ver esos datos.

Octane tiene una lista de bindings que reinicia entre peticiones (octane.php), pero cualquier estado que guardes fuera del contenedor es tu responsabilidad. Prueba bien tu aplicación antes de ir a producción con Octane, especialmente si tienes singletons con estado.

6. Índices de base de datos

Una query que filtra por una columna sin índice hace un full table scan: lee cada fila de la tabla hasta encontrar las que cumplen la condición. En tablas con millones de filas, eso es letal.

La forma más directa de saber si una query usa índices es con EXPLAIN:

EXPLAIN SELECT * FROM orders WHERE user_id = 123 AND created_at > '2026-01-01';

Si la columna type del resultado es ALL, está haciendo full scan. Si es ref o range, está usando un índice.

Errores comunes

El más frecuente es filtrar por columnas sin índice. Otro muy habitual es usar LIKE '%texto%' con un comodín al principio: MySQL no puede usar el índice normal para eso. Si necesitas búsqueda de texto, usa FULLTEXT.

Para añadir índices desde migraciones de Laravel:

// Índice simple
$table->index('user_id');

// Índice compuesto (para queries que filtran por las dos columnas juntas)
$table->index(['user_id', 'created_at']);

Los índices compuestos son más útiles cuando tienes queries que filtran por varias columnas a la vez, como el ejemplo de arriba con user_id y created_at. Pon primero la columna de mayor cardinalidad (la que tiene más valores distintos). Los índices simples son suficientes cuando solo filtras por una columna.

Ojo: los índices aceleran las lecturas pero ralentizan las escrituras, porque MySQL tiene que actualizar el índice cada vez que insertas o modificas una fila. No pongas índices en todas las columnas, solo donde los necesitas.

7. Lazy collections para datasets grandes

Si cargas cien mil registros con User::all(), Laravel trae los cien mil objetos a memoria de golpe. Con registros grandes o mucha concurrencia, eso provoca picos de consumo de memoria que pueden tumbar el proceso.

User::lazy() usa cursores por debajo: va trayendo los registros de uno en uno (o en pequeños lotes internos) sin cargarlos todos en memoria a la vez:

// MAL para datasets grandes: carga todo en memoria
$users = User::all();

// BIEN: va procesando sin saturar la memoria
User::lazy()->each(function ($user) {
    // procesar usuario
});

Si necesitas más control, puedes construir una LazyCollection con un generador propio:

$collection = LazyCollection::make(function () {
    $handle = fopen('datos_grandes.csv', 'r');
    while ($line = fgetcsv($handle)) {
        yield $line;
    }
    fclose($handle);
});

Para procesar en lotes y hacer operaciones por bloques (por ejemplo, insertar con insert() en lugar de uno a uno), combínalo con chunk():

User::lazy()->chunk(1000)->each(function ($chunk) {
    // procesar lote de 1000 usuarios
    $chunk->each(fn($user) => ProcessUser::dispatch($user));
});

La diferencia en consumo de memoria puede ser enorme. Un proceso que procesa quinientos mil registros con all() puede necesitar 2GB de RAM, y con lazy() quedarse en menos de 50MB.

Mide antes de optimizar

Ninguna de estas técnicas vale de nada si no sabes si ha funcionado. Antes de tocar nada, establece una línea base: tiempo de respuesta actual, número de queries por request, uso de memoria. Después aplica el cambio y compara.

Las herramientas más útiles para esto son Laravel Telescope (en desarrollo, para ver queries, jobs y requests), Laravel Debugbar (para ver el número de queries y el tiempo por query directamente en el navegador), EXPLAIN en MySQL para validar el uso de índices, y Apache Bench (ab -n 1000 -c 10 https://tuapp.com/ruta) para medir throughput antes y después de cambios grandes como Octane.

Si aplicas eager loading y el número de queries baja de 300 a 4, eso es una mejora real. Si el tiempo de respuesta no cambia, hay otro cuello de botella. Sin métricas, estás optimizando a ciegas.

Para profundizar en cómo escala PHP cuando el volumen de peticiones crece de verdad, te puede interesar leer sobre rendimiento de PHP a escala en producción real. Y si estás pensando en cómo organizar la estructura de tu proyecto para que aguante bien el crecimiento, hay una guía completa sobre arquitectura escalable en Laravel que cubre los patrones más habituales.

Imagen: Pexels / Markus Spiske

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP