Curso de Laravel

Query Builder en Laravel: el constructor de consultas SQL

Por Víctor Peña · Publicado el

Hola, ¿cómo están? Continuando con el módulo de consultas, hoy veremos el Query Builder: la capa que hay debajo de Eloquent y que puedes usar directamente.

¡Empecemos!

Qué es y en qué se diferencia de Eloquent

El Query Builder construye consultas SQL con una interfaz fluida, sin necesidad de modelos. Trabaja directamente sobre las tablas.

use Illuminate\Support\Facades\DB;

// Eloquent: necesita el modelo, devuelve objetos Paciente
Paciente::where('ciudad', 'Cochabamba')->get();

// Query Builder: sin modelo, devuelve objetos genéricos
DB::table('pacientes')->where('ciudad', 'Cochabamba')->get();

De hecho, Eloquent usa el Query Builder por debajo. Cuando escribes Paciente::where(...), quien resuelve es el Query Builder.

La diferencia práctica: Eloquent devuelve modelos, con sus relaciones, casts y eventos. El Query Builder devuelve datos en crudo, y por eso es algo más rápido.

Consultas básicas

DB::table('pacientes')->get();
DB::table('pacientes')->where('activo', true)->get();
DB::table('pacientes')->find(1);
DB::table('pacientes')->first();
DB::table('pacientes')->count();
DB::table('pacientes')->pluck('nombre');

La sintaxis es prácticamente la misma que en Eloquent, así que lo que aprendiste en la lección anterior se aplica igual.

Insertar, actualizar y eliminar

DB::table('pacientes')->insert([
    'cedula' => '1234567',
    'nombre' => 'Ana',
    'created_at' => now(),
    'updated_at' => now(),
]);

$id = DB::table('pacientes')->insertGetId([...]);

DB::table('citas')
    ->where('fecha', '<', now())
    ->update(['estado' => 'cancelada']);

DB::table('citas')->where('id', 5)->delete();

Fíjate en created_at y updated_at: aquí hay que ponerlos a mano. El Query Builder no pasa por Eloquent, así que no gestiona las marcas de tiempo ni los casts.

Y para insertar muchos registros de una vez, que es donde brilla:

DB::table('pacientes')->insert($milesDeRegistros);

Joins

Aquí es donde el Query Builder se vuelve realmente útil:

$citas = DB::table('citas')
    ->join('pacientes', 'citas.paciente_id', '=', 'pacientes.id')
    ->join('doctores', 'citas.doctor_id', '=', 'doctores.id')
    ->join('especialidades', 'doctores.especialidad_id', '=', 'especialidades.id')
    ->select(
        'citas.fecha',
        'citas.hora',
        'pacientes.nombre as paciente',
        'doctores.nombre as doctor',
        'especialidades.nombre as especialidad',
        'citas.costo'
    )
    ->where('citas.estado', 'atendida')
    ->orderBy('citas.fecha', 'desc')
    ->get();

Es exactamente el JOIN que vimos en el curso de MySQL, escrito en PHP.

Y las variantes:

->leftJoin('citas', 'pacientes.id', '=', 'citas.paciente_id')
->rightJoin(...)
->crossJoin(...)

Agrupaciones y reportes

Este es el caso donde el Query Builder suele ganarle a Eloquent:

$reporte = DB::table('citas')
    ->join('doctores', 'citas.doctor_id', '=', 'doctores.id')
    ->join('especialidades', 'doctores.especialidad_id', '=', 'especialidades.id')
    ->select(
        'especialidades.nombre as especialidad',
        DB::raw('COUNT(citas.id) as total_citas'),
        DB::raw('SUM(citas.costo) as ingresos'),
        DB::raw('ROUND(AVG(citas.costo), 2) as promedio')
    )
    ->where('citas.estado', 'atendida')
    ->groupBy('especialidades.id', 'especialidades.nombre')
    ->having('total_citas', '>', 5)
    ->orderByDesc('ingresos')
    ->get();

Cuando la consulta devuelve columnas calculadas que no corresponden a ningún modelo, forzarla a través de Eloquent no aporta nada.

Cuidado con DB::raw

DB::raw() inserta SQL sin procesar. Es potente y es la única parte del Query Builder donde puedes crear una vulnerabilidad:

// NUNCA: inyección SQL
DB::raw("SUM(costo) as total WHERE ciudad = '{$request->ciudad}'")

// Correcto: los valores van como parámetros
DB::table('citas')
    ->selectRaw('SUM(costo) as total')
    ->whereRaw('ciudad = ?', [$request->ciudad])
    ->get();

La regla es la misma del curso de PHP: todo dato que venga de fuera va como parámetro, nunca concatenado.

Subconsultas

$citas = DB::table('citas')
    ->whereIn('doctor_id', function ($consulta) {
        $consulta->select('id')
            ->from('doctores')
            ->where('especialidad_id', 2);
    })
    ->get();

Y una subconsulta como columna:

$doctores = DB::table('doctores')
    ->select('nombre')
    ->selectSub(
        DB::table('citas')
            ->selectRaw('COUNT(*)')
            ->whereColumn('doctor_id', 'doctores.id'),
        'total_citas'
    )
    ->get();

Aquí conviene recordar lo que vimos en la lección de subconsultas de MySQL: una subconsulta correlacionada se ejecuta una vez por fila. Con muchos doctores, un JOIN con GROUP BY rinde mejor.

SQL directo

Cuando ni el Query Builder alcanza:

$resultados = DB::select('SELECT * FROM citas WHERE estado = ? AND costo > ?', ['atendida', 250]);

DB::insert('INSERT INTO logs (mensaje) VALUES (?)', ['Proceso iniciado']);
DB::update('UPDATE citas SET estado = ? WHERE id = ?', ['atendida', 5]);
DB::delete('DELETE FROM logs WHERE created_at < ?', [now()->subYear()]);
DB::statement('ALTER TABLE citas ADD INDEX idx_fecha (fecha)');

Fíjate en los ? con sus valores en el arreglo. Siempre parámetros.

Es el equivalente al DB::select() que vimos en el curso de Laravel Gohu.

Transacciones

DB::transaction(function () {
    DB::table('citas')->where('id', 5)->update(['estado' => 'cancelada']);

    DB::table('citas')->insert([
        'paciente_id' => 1,
        'doctor_id' => 1,
        'fecha' => '2026-06-01',
        'created_at' => now(),
        'updated_at' => now(),
    ]);
});

Si algo dentro falla, se revierte todo automáticamente. Es la forma recomendada, más segura que hacer beginTransaction() y commit() a mano.

Lo veremos a fondo en la lección de transacciones del módulo avanzado.

Ver el SQL generado

DB::table('citas')->where('estado', 'atendida')->toSql();
DB::table('citas')->where('estado', 'atendida')->toRawSql();

Y para registrar todas las consultas de una petición, lo que vimos en la lección de configuración con DB::listen().

Query Builder o Eloquent

Situación Preferible
CRUD sobre una entidad Eloquent
Necesitas relaciones, casts o eventos Eloquent
Reportes con agrupaciones y cálculos Query Builder
Joins de cuatro o más tablas Query Builder
Insertar miles de registros Query Builder
Tablas sin modelo, como una pivote Query Builder
Consultas muy optimizadas Query Builder

La regla práctica: Eloquent por defecto, Query Builder para reportes y operaciones masivas.

Y algo importante: no son excluyentes. Puedes usar Eloquent en el 90 % de la aplicación y bajar al Query Builder en las tres consultas de reportes donde hace falta.

De hecho, puedes empezar desde el modelo y bajar cuando convenga:

Cita::query()
    ->join('pacientes', 'citas.paciente_id', '=', 'pacientes.id')
    ->selectRaw('pacientes.ciudad, COUNT(*) as total')
    ->groupBy('pacientes.ciudad')
    ->get();

Errores comunes

  • Olvidar created_at y updated_at al insertar.
  • Concatenar variables en DB::raw(). Es la puerta a la inyección SQL.
  • Usar Query Builder para todo y perder relaciones, casts y borrado lógico.
  • Usar Eloquent para reportes con muchas tablas y cálculos, cuando el Query Builder es más directo.
  • Esperar que dispare los eventos del modelo. No pasa por Eloquent.

Para cerrar

El Query Builder es la herramienta para cuando Eloquent estorba: reportes, joins complejos y operaciones masivas. Saber que existe y cuándo bajar a él es parte de escribir Laravel con criterio.

Y la regla de seguridad que no cambia nunca: los datos van como parámetros.

En la siguiente lección veremos las relaciones entre modelos, que es lo que hace que Eloquent valga la pena.

Saludos y éxitos.

Norvic Software

Desarrollamos el software que tu empresa necesita

Somos una fábrica de software en Bolivia. Construimos sistemas a medida y aplicaciones móviles, y llevamos Inteligencia Artificial a las empresas que ya tienen un sistema funcionando.

Solicitar cotizaciónVer todos los servicios

Cotización sin costo · Respuesta directa por WhatsApp