Ir al contenido principal
Laravel, shipping fast.
Capitulo 6 · Rendimiento y optimizacion

La paginacion como decision de rendimiento

Julian Beaujardin

Devolver todas las filas está bien hasta que deja de estarlo, y el momento en que deja de estarlo llega en silencio. No es una caída. El tiempo de respuesta sube, el tamaño de la carga sube, y un día alguien nota que una lista que cargaba al instante tarda ahora cuatro segundos porque la tabla creció más allá de unos miles de filas.

// app/Livewire/Admin/Ops/Infrastructure/SitesPanel.php
private function buildQuery(): LengthAwarePaginator
{
    $query = Site::withTrashed()->orderByDesc('updated_at');

    // ... search and filter conditions

    return $query->paginate(10);
}

paginate(10): ejecuta un COUNT(*) junto a la consulta de página, así que los metadatos vienen gratis. Ese conteo no es gratis para la base de datos: es un coste real que eliges pagar a cambio de esos metadatos.

El tamaño de página está fijado en 10, elegido por la aplicación y no por el llamante. Esta es una herramienta interna tras su propia autorización, no un endpoint público, así que ninguna petición no confiable decide cuánto trabajo hace cada página. Es una simplificación deliberada, no un descuido: en cuanto una lista paginada es alcanzable por un cliente externo, el tamaño de página deja de ser decisión sólo de la aplicación. Si un endpoint público acepta alguna vez un tamaño de página del cliente, necesita un techo; si no, un cliente puede pedir todas las filas en una petición y habrás construido un endpoint sin paginar con pasos extra.

El tamaño de página que eliges es un compromiso entre viajes de ida y vuelta y coste por petición. Cuando no controlas quién pregunta, el techo que no pones es un vector de denegación de servicio que les has regalado.

Indices y las consultas que los necesitan

Un índice sólo ayuda a la consulta para la que se construyó. Añade uno especulativamente y habrás gastado sobrecarga de escritura en cada inserción y actualización a cambio de un escaneo de tabla que nunca pediste.

// app/Http/Controllers/DomainBuyController.php
private function boughtDomainCountForSite(Site $site): int
{
    return Domain::query()
        ->where('site_id', $site->id)
        ->whereNotNull('registrar')
        ->whereNotIn('status', [DomainStatus::Failed->value, DomainStatus::Disconnected->value])
        ->count();
}

Eso es site_id y status en la misma cláusula WHERE, en un camino caliente. La migración de domains ya tiene el índice que esa consulta necesita, porque se construyó junto a la consulta y no se adivinó después:

// database/migrations/2026_07_09_194711_create_domains_table.php
Schema::create('domains', function (Blueprint $table) {
    // ...
    $table->index(['site_id', 'status']);
    $table->index('hostname');
});

['site_id', 'status']: un índice compuesto, no dos separados. El orden de las columnas importa: este índice sirve a WHERE site_id = ? por sí solo y a WHERE site_id = ? AND status = ? juntos, porque ambas consultas usan las columnas más a la izquierda del índice en orden. No ayudaría a una consulta que filtre sólo por status.

Un índice existe para servir a una cláusula WHERE concreta que realmente escribes, en el orden de columnas por el que realmente filtras. Mira la consulta primero y luego diseña el índice para que encaje, nunca al revés. Un índice añadido porque «es una clave foránea, seguro que necesita uno» es una suposición con el mismo problema que una caché sin medir: no sabes si hace algo hasta que lo compruebas.