Qué es Eloquent en Laravel 13: Guía y Operaciones CRUD con el ORM

- Andrés Cruz - EN In english

Video thumbnail

Índice de contenido

En Laravel, prácticamente todo se articula alrededor de clases orientadas a objetos. La verdadera potencia de los modelos radica en que heredan de la clase base Illuminate\Database\Eloquent\Model, lo que les otorga de manera automática la capacidad de comunicarse con la base de datos sin necesidad de configurar mapeos manuales tediosos.

Al hacer que una clase herede de Model, obtenemos de inmediato un amplio abanico de métodos integrados para interactuar con las tablas. Este patrón de diseño se conoce como Active Record, donde cada clase modelo representa una tabla en la base de datos y cada instancia de dicha clase corresponde a una fila o registro específico.

Por convención, Laravel realiza un emparejamiento automático: si tu modelo se llama Post (en singular y con notación PascalCase), el framework asume de forma predeterminada que debe conectarse a la tabla posts (en plural y en minúsculas). Gracias a esta magia de la convención sobre la configuración, no necesitas definir nombres de tablas salvo que utilices una estructura no convencional; la herencia te brinda de fábrica todas las operaciones de tipo CRUD (acrónimo en inglés de Create, Read, Update, Delete; traducido como Crear, Leer, Actualizar y Eliminar).

En este artículo exploraremos las operaciones más comunes del día a día, pero el ecosistema de Eloquent ofrece muchísimas funciones adicionales que puedes consultar en la documentación oficial:

https://laravel.com/docs/master/eloquent

Anteriormente analizamos cómo gestionar operaciones transaccionales en bloques en Laravel para garantizar la atomicidad e integridad de los datos. Ahora daremos un paso fundamental dominando las operaciones esenciales de Eloquent.

¿Qué es Eloquent y qué es un ORM?

Video thumbnail

Para manipular datos de manera fluida y expresiva, Laravel utiliza Eloquent, el cual es un ORM (Object-Relational Mapping o Mapeador Objeto-Relacional).

Para comprender su importancia en el desarrollo web moderno, desglosemos los tres pilares que componen este concepto:

  • Object (Objeto): Porque trabajamos directamente con clases, objetos, métodos y propiedades de PHP con tipado estricto.
  • Relational (Relacional): Porque nuestras bases de datos son relacionales (como MySQL, MariaDB, PostgreSQL, SQLite o SQL Server), organizadas en tablas estructuradas con claves primarias (PK) y claves foráneas (FK).
  • Mapping (Mapeo): Porque Eloquent se encarga de "traducir" de forma bidireccional: convierte cada fila de tu tabla relacional en un objeto vivo de PHP listo para manipular, y cada cambio en el objeto lo traduce en sentencias SQL al persistir.

Bases de datos relacionales vs. no relacionales: ¿Dónde encaja Eloquent?

Una duda recurrente al adentrarse en el desarrollo backend es la distinción fundamental entre una base de datos relacional y una no relacional:

  • Bases de datos relacionales (SQL / RDBMS): Organizan la información en tablas rígidas compuestas por columnas con tipos definidos y filas de datos. La integridad se mantiene mediante restricciones estrictas y relaciones entre tablas (uno a uno, uno a muchos, muchos a muchos) con claves foráneas. Ejemplos directos son MySQL, MariaDB, PostgreSQL y SQLite. Eloquent está concebido desde sus cimientos para interactuar con este tipo de bases de datos relacionales.
  • Bases de datos no relacionales (NoSQL): Almacenan la información en formatos flexibles como documentos JSON (por ejemplo, MongoDB), pares clave-valor (como Redis) o grafos. No requieren un esquema tabular fijo ni relaciones clásicas entre tablas.

En Laravel, el modelo representa la capa fundamental de abstracción con el motor de base de datos (DB). Esto aporta una enorme ventaja arquitectónica: la aplicación se vuelve independiente del motor subyacente. Puedes desarrollar localmente con sqlite en memoria o archivo, y pasar a mysql o pgsql en entornos de producción con tan solo ajustar las variables de entorno en tu archivo .env, sin tener que reescribir una sola consulta en tu código.

1. Comparativa: PHP Puro con SQL vs. Eloquent ORM en Laravel

En el desarrollo tradicional con PHP sin framework (o utilizando PDO clásico), la interacción con la base de datos requiere concatenar cadenas de texto SQL manualmente y gestionar los parámetros vinculados. Por ejemplo, para obtener los productos activos:

// Enfoque tradicional con PHP puro y SQL nativo
$sql = "SELECT * FROM productos WHERE status = 'activo'";
$stmt = $pdo->prepare($sql);
$stmt->execute();
$productos = $stmt->fetchAll(PDO::FETCH_ASSOC);

En Laravel, Eloquent abstrae por completo esta complejidad mediante una sintaxis fluida (*Fluent Syntax*) ligada al modelo Product:

// Enfoque moderno y expresivo con Eloquent ORM
$productos = Product::where('status', 'activo')->get();

Esta abstracción no solo hace que el código sea mucho más limpio, conciso y mantenible, sino que implementa de forma transparente la preparación de sentencias mediante PDO (*parameter binding*), neutralizando de raíz las vulnerabilidades más comunes de inyección SQL.

2. Principales ventajas de utilizar Eloquent ORM

  • Portabilidad entre motores de base de datos: Al no atarte a un dialecto SQL específico, puedes migrar de motor sin alterar la lógica de negocio de tus controladores y servicios.
  • Depuración y trazabilidad de consultas: Eloquent actúa como un traductor transparente. Con métodos como toSql() o herramientas como Laravel Debugbar, puedes inspeccionar con total precisión la sentencia SQL que se enviará al gestor.
  • Mapeo de tablas mediante Modelos: Cada tabla del esquema se refleja en un modelo específico (por ejemplo, el modelo User se asocia a la tabla users, y el modelo Post a la tabla posts).
  • Gestión elegante de relaciones: Permite consultar datos vinculados entre tablas sin escribir cláusulas JOIN complejas a mano.

3. Simplificación de operaciones CRUD y relaciones en Laravel

Con Eloquent, crear registros, manipular campos y aplicar transformaciones se gestiona mediante arreglos asociativos simples:

// Inserción masiva segura con Eloquent
$user = User::create([
    'name' => 'Juan',
    'email' => 'juan@example.com',
    'password' => bcrypt('secreto'),
]);

Gestión de relaciones entre tablas

En lugar de redactar sentencias JOIN manuales y mapear columnas duplicadas, en Eloquent declaras las relaciones en los propios modelos (utilizando métodos como hasOne, hasMany, belongsTo o belongsToMany):

// Obtención de un usuario y carga de sus posts relacionados
$user = User::find(1);
$posts = $user->posts; // Relación hasMany resuelta automáticamente

4. Manejo avanzado de datos: Colecciones en Laravel

Cuando ejecutas una consulta de Eloquent que retorna múltiples filas (usando por ejemplo get() o all()), los resultados no se devuelven en un arreglo simple nativo de PHP, sino dentro de una instancia de Illuminate\Database\Eloquent\Collection.

Las colecciones de Laravel envuelven los datos con decenas de métodos utilitarios de orden superior para filtrar, transformar, ordenar o agrupar información directamente en memoria de forma encadenada:

// Filtrado directo sobre la colección recuperada en memoria
$productosCaros = $productos->filter(function (Product $producto) {
    return $producto->precio > 100;
});

Eloquent ORM convierte las filas relacionales en objetos vivos con comportamiento propio, erradicando los errores típicos de sintaxis al concatenar cadenas SQL. Y cuando te enfrentes a escenarios analíticos de alta exigencia donde requieras una consulta sumamente optimizada, Laravel siempre te permite recurrir al Query Builder o a sentencias SQL directas (Raw SQL con DB::raw()) sin abandonar la comodidad del framework.

Operaciones CRUD con Eloquent sobre los Modelos

A continuación, analizaremos paso a paso cómo implementar las cuatro operaciones básicas de un CRUD sobre un modelo representativo: Post.

1. Crear un registro (Create / Eloquent Insert)

Para insertar un nuevo registro en la base de datos de manera masiva, podemos recurrir al método estático Post::create(), pasándole un array asociativo con los pares clave-valor que deseamos guardar:

use App\Models\Post;

public function store(): Post
{
    return Post::create([
        'title' => 'Introducción a Eloquent',
        'slug' => 'introduccion-a-eloquent',
        'content' => 'Contenido detallado de la publicación...',
        'category_id' => 1,
        'description' => 'Breve resumen del post',
        'posted' => 'yes',
        'image' => 'portada.jpg',
    ]);
}

Es fundamental tener en cuenta las restricciones de clave foránea (Foreign Key Constraints). Si la categoría con id = 1 no existe previamente en la tabla categories, el motor de base de datos detendrá la operación arrojando un error de violación de integridad referencial:

SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`midatabase`.`posts`, CONSTRAINT `posts_category_id_foreign` FOREIGN KEY (`category_id`) REFERENCES `categories` (`id`))

Para evitar esto, asegúrate de que el registro padre exista previamente. Puedes crearlo mediante un seeder, desde tinker o directamente con el modelo Category:

use App\Models\Category;

Category::create([
    'title' => 'Desarrollo Web',
    'slug' => 'desarrollo-web',
]);

Asignación masiva (Mass Assignment) y la propiedad $fillable

Si ejecutas el método create() por primera vez en un modelo nuevo, es muy probable que te encuentres con la siguiente excepción de Laravel:

Illuminate\Database\Eloquent\MassAssignmentException: Add [title] to fillable property to allow mass assignment on [App\Models\Post].

Esta excepción no es un error de programación fortuito; es una medida de protección crítica contra vulnerabilidades de asignación masiva (*Mass Assignment Vulnerability*). Cuando recibes datos desde una petición HTTP (por ejemplo, con $request->all()) y los pasas directamente al método create(), un atacante podría manipular la petición enviando campos sensibles que tú no planeabas modificar, como role = 'admin', is_verified = true o alteraciones en saldos y suscripciones.

Para proteger tu aplicación, Laravel te exige definir de forma explícita qué columnas de la tabla tienen autorización de ser rellenadas masivamente. Esto se configura tradicionalmente en el modelo mediante la propiedad protegida $fillable:

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Post extends Model
{
    /**
     * Atributos que se pueden asignar de manera masiva.
     *
     * @var array<int, string>
     */
    protected $fillable = [
        'title',
        'slug',
        'content',
        'category_id',
        'description',
        'posted',
        'image',
    ];
}

Una vez definido $fillable, al ejecutar nuevamente la inserción, el registro se creará correctamente y Laravel nos retornará la instancia recién persistida con sus timestamps (created_at y updated_at) y su id autogenerado:

{
  "id": 1,
  "title": "Introducción a Eloquent",
  "slug": "introduccion-a-eloquent",
  "content": "Contenido detallado de la publicación...",
  "category_id": 1,
  "description": "Breve resumen del post",
  "posted": "yes",
  "image": "portada.jpg",
  "created_at": "2026-03-02T19:43:42.000000Z",
  "updated_at": "2026-03-02T19:43:42.000000Z"
}

Tipos de retorno en controladores: Model vs. Response

Si estás probando este código dentro de un controlador web y tipas el retorno como Illuminate\Http\Response, pero devuelves directamente el modelo Post:

use Illuminate\Http\Response;

public function index(): Response
{
    return Post::create([...]); // ¡Error de tipo!
}

Obtendrás una excepción de tipado de PHP:

App\Http\Controllers\PostController::index(): Return value must be of type Illuminate\Http\Response, App\Models\Post returned

Recuerda que en una arquitectura web, el controlador procesa un objeto Request y debe devolver una respuesta HTTP válida. Si estás construyendo una API REST, lo habitual es transformar el recurso y retornar una respuesta JSON estructurada con su código de estado HTTP 201 Created:

use Illuminate\Http\JsonResponse;

public function store(Request $request): JsonResponse
{
    $post = Post::create($request->validated());

    return response()->json($post, 201);
}

2. Actualizar un registro (Update)

A diferencia de la creación masiva inicial donde invocamos un método estático (Post::create()) porque la entidad aún no existe en la base de datos, para actualizar primero obtenemos la instancia existente y luego invocamos el método update():

public function update(int $id): bool
{
    $post = Post::findOrFail($id);

    return $post->update([
        'title' => 'Título actualizado de la publicación',
        'description' => 'Nueva descripción editada...',
    ]);
}

Inyección implícita de modelos (Route Model Binding)

Laravel incluye un mecanismo sumamente elegante denominado Route Model Binding. Si en la definición de tu ruta declaras un parámetro que coincida con el nombre del modelo:

use App\Http\Controllers\PostController;
use Illuminate\Support\Facades\Route;

Route::get('/posts/{post}/edit', [PostController::class, 'edit'])->name('posts.edit');

Y luego en el método de tu controlador tipas el parámetro con la clase del modelo correspondiente (Post $post), Laravel resolverá la consulta por ti en segundo plano:

  • Sin inyección automática: Recibes un identificador escalar (int $id) y te ves obligado a consultar manualmente con Post::findOrFail($id).
  • Con Route Model Binding: Laravel busca automáticamente el registro por su clave primaria id. Si el registro existe, inyecta la instancia $post completamente hidratada en tu método; si no existe, detiene la ejecución inmediatamente y lanza un error 404 Not Found.
// Controlador limpio gracias a la inyección de dependencias y Route Model Binding
public function edit(Post $post)
{
    return view('posts.edit', compact('post'));
}

public function update(Request $request, Post $post)
{
    $post->update($request->validated());

    return redirect()->route('posts.show', $post)->with('success', 'Post actualizado con éxito.');
}

3. Obtener y consultar registros (Read / Eloquent Select)

Para consultar información de la base de datos, Eloquent ofrece métodos versátiles adaptados a cada necesidad.

Obtener todos los registros: all() y get()

Cuando necesitas listar todos los registros de una tabla sin filtros previos, puedes usar all():

$categories = Category::all();

Por otra parte, get() se utiliza al final de una cadena de condiciones o restricciones de consulta:

$postsPublicados = Post::where('posted', 'yes')
    ->orderBy('created_at', 'desc')
    ->get();

Consejo de depuración con dd(): La función auxiliar dd() (Dump and Die) es tu aliada imprescindible en el desarrollo diario. Imprime el contenido de cualquier colección, modelo o variable con formato colapsable y colores, interrumpiendo la ejecución del script para que puedas inspeccionar los datos antes de renderizar la vista.

$categories = Category::all();
dd($categories); // Muestra la colección de categorías y detiene la ejecución

Buscar por clave primaria: find() y findOrFail()

Para recuperar un único registro a partir de su clave primaria (id), utilizamos find():

$post = Post::find(1);
  • Acepta un número entero o una cadena de texto (si trabajas con identificadores UUID).
  • Si el registro existe en la tabla, devuelve la instancia del modelo Post; si no existe, devuelve null.
  • Si necesitas que lance un error 404 Not Found automático cuando no se encuentre el registro, emplea Post::findOrFail(1).

Obtener el primer resultado: first()

A diferencia de get() (que siempre devuelve una colección de modelos), el método first() ejecuta un LIMIT 1 en la consulta SQL y retorna exclusivamente el primer modelo coincidente (o null):

$category = Category::where('slug', 'desarrollo-web')->first();

Consultas personalizadas con where()

El método where() permite aplicar filtros con total naturalidad:

// SQL generado: SELECT * FROM categories WHERE title = 'Tecnología' LIMIT 1;
$category = Category::where('title', 'Tecnología')->first();

// También admite operadores de comparación
$postsRecientes = Post::where('id', '>', 10)->get();

4. Eliminar un registro (Delete)

Para eliminar un registro, este debe existir previamente en la base de datos. En Eloquent, simplemente recuperamos la instancia del modelo y llamamos a su método delete():

public function destroy(int $id): bool
{
    $post = Post::findOrFail($id);

    return $post->delete();
}

Aprovechando el Route Model Binding, la acción del controlador queda reducida a su mínima expresión:

public function destroy(Post $post)
{
    $post->delete();

    return redirect()->route('posts.index')->with('status', 'Registro eliminado correctamente.');
}
  1. Si el registro existe: Laravel inyecta el objeto $post y ejecuta la eliminación física en la base de datos (o borrado suave si tienes implementado el trait SoftDeletes).
  2. Si el registro no existe: Laravel aborta la petición con un error 404 de inmediato, evitando que se ejecute cualquier instrucción posterior.

Atributos de Clase: La Nueva Sintaxis en Laravel 13

A partir de las versiones recientes de Laravel y consolidado plenamente en Laravel 13, el framework aprovecha los Atributos nativos de PHP 8 para configurar modelos de una forma más limpia, compacta y moderna.

En los modelos predeterminados de Laravel 13, notarás que junto a las propiedades protegidas clásicas podemos definir la configuración directamente sobre la clase mediante atributos con corchetes y numeral (#[...]).

Cómo implementar el Atributo #[Fillable]

Para migrar de la propiedad clásica protected $fillable al nuevo atributo de clase, importamos la clase Illuminate\Database\Eloquent\Attributes\Fillable y la declaramos justo encima de la definición del modelo:

app/Models/Category.php

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;

#[Fillable(['title', 'slug'])]
class Category extends Model
{
    // Ya no es estrictamente obligatorio declarar:
    // protected $fillable = ['title', 'slug'];

    public function posts(): HasMany
    {
        return $this->hasMany(Post::class);
    }
}

¿Cuál de las dos sintaxis deberías utilizar?

Ambas sintaxis son totalmente compatibles y funcionan exactamente igual por debajo:

  • Sintaxis Clásica (protected $fillable): Es la más extendida históricamente. La verás en la inmensa mayoría de paquetes, proyectos legacy y tutoriales existentes.
  • Sintaxis de Atributos de PHP (#[Fillable]): Es más declarativa, reduce el código repetitivo (*boilerplate*) y aprovecha la introspección nativa del lenguaje sin ensuciar el cuerpo de la clase con variables protegidas.

Operaciones Avanzadas en Eloquent y Query Builder

Tanto Eloquent como el Query Builder subyacente de Laravel (DB::table()) comparten una interfaz de consulta unificada. Conocer estos métodos te permitirá resolver requerimientos complejos de filtrado y extracción de datos con máxima eficiencia.

Para experimentar con estas consultas en tiempo real sin levantar vistas, te recomiendo utilizar la consola interactiva de Laravel:

php artisan tinker

Inspeccionar la sentencia SQL con toSql()

Si deseas conocer la sentencia SQL exacta que Laravel va a ejecutar, sustituye métodos terminales como get() o first() por toSql():

// Con el Query Builder de la fachada DB:
$sql = DB::table('posts')->where('id', '>', 5)->toSql();
// Resultado: "select * from `posts` where `id` > ?"

// Directamente desde el modelo Eloquent:
$sql = Post::where('posted', 'yes')->orderBy('created_at', 'desc')->toSql();
// Resultado: "select * from `posts` where `posted` = ? order by `created_at` desc"

Unión de tablas con join()

Cuando requieres combinar campos de dos tablas relacionales basadas en una clave común, puedes aplicar la cláusula join():

$posts = Post::join('categories', 'categories.id', '=', 'posts.category_id')
    ->select('posts.*', 'categories.title as category_title')
    ->orderBy('posts.created_at', 'desc')
    ->paginate(10);

Los métodos como select(), where() y orderBy() pueden encadenarse fluidamente, siempre colocándolos antes de los métodos terminales que resuelven la consulta (como get(), first() o paginate()).

Agrupación lógica con where() y orWhere() anidados

Un error sumamente común en SQL ocurre cuando combinamos condiciones AND y OR en el mismo nivel sin paréntesis, provocando que se devuelvan registros inesperados debido a la precedencia de operadores.

Para agrupar condiciones con paréntesis en Eloquent, pasamos una función anónima (Closure) dentro de where():

$categorySlug = 'laravel';

$posts = Post::join('categories', 'categories.id', '=', 'posts.category_id')
    ->select('posts.*', 'categories.title as category_title', 'categories.slug as category_slug')
    ->where('categories.slug', $categorySlug)
    ->where('posted', 'yes')
    ->where(function ($query) {
        $query->where('type', 'post')
            ->orWhere('type', 'course')
            ->orWhere('type', 'tutorial');
    })
    ->orderBy('posts.created_at', 'desc')
    ->paginate(10);

Si necesitas pasar variables externas dentro del closure, utiliza la directiva use ($variable):

$categoryId = 1;

$posts = Post::where('id', '>=', 1)
    ->where(function ($query) use ($categoryId) {
        $query->where('category_id', $categoryId)
            ->orWhere('posted', 'yes');
    })
    ->get();

Filtrar por conjuntos con whereIn() y whereNotIn()

Para buscar registros cuya columna coincida con cualquiera de los valores presentes en un array:

$ids = [1, 2, 3, 4, 5];

$postsIncluidos = Post::whereIn('id', $ids)->get();
$postsExcluidos = Post::whereNotIn('id', $ids)->get();

Paginación manual con limit() y offset() (o take() y skip())

Si no deseas utilizar el paginador automático de Laravel (paginate()) y prefieres limitar la cantidad de resultados o saltar un lote de registros:

// Limitar a 5 registros saltando los primeros 10:
$posts = Post::offset(10)->limit(5)->get();

// O con su sintaxis equivalente en Eloquent:
$posts = Post::skip(10)->take(5)->get();

Conteo y ordenación aleatoria

// Contar el total de filas que cumplen la condición directamente en SQL:
$totalActivos = Post::where('posted', 'yes')->count();

// Obtener registros en orden aleatorio:
$destacados = Post::where('posted', 'yes')->inRandomOrder()->limit(3)->get();

Serialización a JSON y Array

La serialización es el proceso de convertir objetos de Eloquent a formatos planos para ser transmitidos vía web, esencial al construir APIs REST:

$post = Post::find(1);

// Convertir a array asociativo de PHP:
$array = $post->toArray();

// Convertir a cadena de texto con formato JSON:
$json = $post->toJson();

Clonación de Modelos con replicate()

Video thumbnail

En muchas ocasiones necesitamos tomar un registro existente de la base de datos, duplicarlo con pequeñas variaciones y persistirlo como una fila completamente nueva. Para resolver esto de manera impecable, Laravel nos provee el método replicate().

// Duplicar un envío modificando únicamente el tipo
$shipping = Shipping::findOrFail(1);
$newShipping = $shipping->replicate();

$newShipping->fill([
    'type' => 'billing',
]);

$newShipping->save();

El método replicate() también acepta un arreglo con la lista de columnas que deseas excluir explícitamente para que no se copien en el nuevo registro:

$newShipping = $shipping->replicate([
    'tracking_number',
    'shipped_at',
]);

$newShipping->type = 'express';
$newShipping->save();

Caso práctico en un flujo de pagos y cursos (Legacy Version)

Veamos un ejemplo real extraído de un módulo de matriculación: cuando un estudiante adquiere un curso nuevo que posee una versión previa (Legacy), replicamos el registro de pago para otorgarle acceso a ambas versiones:

// Creación del pago principal del curso
$payment = CoursePayment::create([
    'user_id' => $user->id,
    'purchasable_id' => $newCourse->id,
    'purchasable_type' => Course::class,
    'amount' => $amount,
    'reference' => $reference,
]);

// Si el curso comprado cuenta con una versión anterior (Legacy)
if ($newCourse->has_legacy_version) {
    // Replicamos la instancia de pago original manteniendo la integridad
    $legacyPayment = $payment->replicate();

    // Modificamos puntualmente los campos requeridos
    $legacyPayment->purchasable_id = $newCourse->legacy_id;
    $legacyPayment->reference = $payment->reference . '-LEGACY';

    // Guardamos el nuevo registro clonado
    $legacyPayment->save();
}

Modificar manualmente las propiedades sobre la instancia clonada aporta una gran expresividad al código, dejando meridianamente claro qué atributos deben diferenciarse antes de persistir con save().

pluck() vs. modelKeys(): Cómo obtener un array de IDs en Eloquent

Un requerimiento habitual en Laravel consiste en extraer un array simple con los IDs de un conjunto de registros relacionados (por ejemplo, los permisos asociados a un rol). La decisión entre pluck() y modelKeys() tiene implicaciones directas en el rendimiento de tu aplicación.

1. Uso de pluck()

El método pluck() extrae los valores de una columna específica y devuelve una colección con dichos valores:

// A nivel de consulta SQL (SELECT id FROM permissions...):
$permissionIds = Permission::where('active', true)->pluck('id');

// Sobre una relación ya cargada en memoria:
$permissionIds = $role->permissions->pluck('id');

Además, pluck() admite el uso de funciones anónimas (closures) para generar pares clave-valor personalizados (por ejemplo, para poblar elementos <select> en vistas Blade):

$options = Product::available()->get()->pluck(
    fn (Product $p) => "{$p->brand} - {$p->model}", 
    'id'
);

2. Uso de modelKeys()

El método modelKeys() pertenece exclusivamente a las colecciones de Eloquent (Illuminate\Database\Eloquent\Collection) y devuelve un array plano con las claves primarias de los modelos contenidos:

// Retorna el array de claves primarias:
$ids = $role->permissions->modelKeys();

La ventaja de modelKeys() es que respeta automáticamente las claves primarias personalizadas (definidas en la propiedad $primaryKey del modelo). Sin embargo, no existe en el constructor de consultas ni en el modelo directamente. Si ejecutas Permission::modelKeys() obtendrás un error.

Regla de oro sobre rendimiento: Nunca ejecutes Model::all()->modelKeys() solo para obtener una lista de identificadores. Eso forzaría a Laravel a consultar e instanciar en memoria miles de modelos completos con todas sus columnas. En su lugar, ejecuta siempre Model::pluck('id'), lo que generará una consulta SQL ligera que traerá únicamente la columna requerida directamente desde el motor de base de datos.

Error común: Por qué only() no funciona en los atributos de los modelos dentro de una colección

Una confusión recurrente entre desarrolladores es intentar filtrar atributos de modelos usando only() sobre la colección:

// ¡Esto devolverá una colección vacía o no filtrará los campos de los modelos!
$collection->only(['title', 'slug']);

El motivo es que el método only() de las colecciones opera sobre las claves internas de la propia colección, no sobre las propiedades o columnas del modelo Eloquent. Si deseas limitar las columnas recuperadas, indícalo directamente en la consulta SQL con select('title', 'slug').

Recuperar el Último ID Insertado en Laravel

Tras insertar un registro en la base de datos, con frecuencia necesitamos su identificador recién generado para vincular registros hijos o redirigir al usuario. Disponemos de varios métodos para lograrlo:

1. Mediante el método create() de Eloquent (Recomendado)

El método create() devuelve inmediatamente la instancia del modelo recién persistido, con su ID disponible:

$post = Post::create([
    'title' => 'Nuevo Post',
    'slug' => 'nuevo-post',
]);

$nuevoId = $post->id;

2. Mediante el método save()

Al instanciar un modelo manualmente y llamar a save(), Eloquent actualiza automáticamente la propiedad id de la instancia:

$post = new Post();
$post->title = 'Nuevo Post';
$post->slug = 'nuevo-post';
$post->save();

$nuevoId = $post->id;

3. Con el Query Builder mediante insertGetId()

Si utilizas la fachada DB en lugar de modelos, el método insertGetId() inserta la fila y retorna el identificador en una sola operación:

$id = DB::table('posts')->insertGetId([
    'title' => 'Post vía Query Builder',
    'slug' => 'post-via-query-builder',
]);

4. Mediante latest('id')->first()

$ultimoPost = Post::latest('id')->first();
$ultimoId = $ultimoPost?->id;

5. Mediante PDO con lastInsertId()

Si ejecutas consultas crudas directamente sobre PDO, puedes consultar el último identificador de la conexión:

DB::table('posts')->insert([
    'title' => 'Post con PDO crudo',
    'slug' => 'post-pdo-crudo',
]);

$id = DB::getPdo()->lastInsertId();

Métodos Model Casts: Conversión de Atributos

El casteo (*casting*) de atributos en Eloquent es una funcionalidad indispensable para convertir tipos de datos primitivos de la base de datos en tipos nativos de PHP (como enteros, booleanos, colecciones, fechas Carbon o Enums respaldados).

A partir de Laravel 11 y plenamente vigente en Laravel 13, la forma recomendada para definir los casteos es a través del método casts(), superando la limitación histórica de la propiedad protected $casts que no admitía la invocación de métodos estáticos:

namespace App\Models;

use App\Enums\UserRole;
use Illuminate\Database\Eloquent\Casts\AsEnumCollection;
use Illuminate\Database\Eloquent\Model;

class User extends Model
{
    /**
     * Define las conversiones de atributos del modelo.
     *
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'email_verified_at' => 'datetime',
            'password' => 'hashed',
            'is_active' => 'boolean',
            'role' => UserRole::class,
        ];
    }
}

En Laravel 10, esta misma conversión con Enums exigía concatenar cadenas de texto en la propiedad $casts:

// Enfoque legacy de Laravel 10:
protected $casts = [
    'options' => AsEnumCollection::class . ':' . UserOption::class,
];

El nuevo método casts() aporta seguridad de tipos, autocompletado en el IDE y la posibilidad de encadenar métodos estáticos como AsEnumCollection::of(UserOption::class).

Filtros Dinámicos con Cláusulas Condicionales: when() y whereBetween()

Video thumbnail

Al construir paneles de administración (ya sea en Livewire, Vue o controladores clásicos de Blade), es muy común tener filtros opcionales de búsqueda, estado, categorías y rangos de fechas.

En lugar de anidar múltiples bloques if tradicionales que fragmentan la consulta, la forma recomendada en Laravel es emplear el método when():

use App\Models\Post;
use Illuminate\Database\Eloquent\Builder;

$posts = Post::query()
    ->when(request('search'), function (Builder $query, string $search) {
        $query->where(function ($q) use ($search) {
            $q->where('id', 'like', "%{$search}%")
              ->orWhere('title', 'like', "%{$search}%")
              ->orWhere('description', 'like', "%{$search}%");
        });
    })
    ->when(request('category_id'), function (Builder $query, int $categoryId) {
        $query->where('category_id', $categoryId);
    })
    ->when(request('type'), function (Builder $query, string $type) {
        $query->where('type', $type);
    })
    ->when(request('posted'), function (Builder $query, string $posted) {
        $query->where('posted', $posted);
    })
    ->when(request('from') && request('to'), function (Builder $query) {
        $query->whereBetween('created_at', [request('from'), request('to')]);
    })
    ->with('category')
    ->orderBy('created_at', 'desc')
    ->paginate(15);

El método when() evalúa el primer parámetro como booleano: si tiene un valor verdadero (no nulo ni vacío), ejecuta el closure pasando el valor evaluado; si es falso o nulo, omite el bloque y continúa la cadena sin alterar la consulta.

Implementación en la interfaz con Laravel Livewire

En una vista Blade con Livewire, estos filtros reactivos pueden estructurarse de forma intuitiva:

<div class="grid grid-cols-1 md:grid-cols-4 gap-3 mb-4">
    <select class="block w-full rounded border-gray-300" wire:model.live="posted">
        <option value="">{{ __('Todos los estados') }}</option>
        <option value="not">{{ __('Borrador') }}</option>
        <option value="yes">{{ __('Publicado') }}</option>
    </select>

    <select class="block w-full rounded border-gray-300" wire:model.live="category_id">
        <option value="">{{ __('Todas las categorías') }}</option>
        @foreach ($categories as $id => $title)
            <option value="{{ $id }}">{{ $title }}</option>
        @endforeach
    </select>

    <input type="text" class="block w-full rounded border-gray-300" wire:model.live.debounce.300ms="search" placeholder="{{ __('Buscar por título...') }}" />

    <div class="grid grid-cols-2 gap-2">
        <input type="date" class="block w-full rounded border-gray-300" wire:model.live="from" />
        <input type="date" class="block w-full rounded border-gray-300" wire:model.live="to" />
    </div>
</div>

Puedes consultar el código completo del componente en el repositorio de ejemplo:

https://github.com/libredesarrollo/book-course-laravel-livewire/blob/main/resources/views/livewire/dashboard/post/index.blade.php

Filtrar por rangos de fecha con whereBetween()

Video thumbnail

Para filtrar registros entre dos fechas extremas (cota inicial y final), el método whereBetween() recibe el nombre de la columna y un array con los dos valores límites:

use Carbon\Carbon;

if (request()->filled(['from', 'to'])) {
    $posts->whereBetween('created_at', [
        Carbon::parse(request('from'))->startOfDay(),
        Carbon::parse(request('to'))->endOfDay(),
    ]);
}

Y si estás trabajando en una aplicación SPA con Vue 3 / Inertia:

resources/js/Pages/Dashboard/Post/Index.vue

<template>
  <div class="flex gap-4 mb-4">
    <TextInput type="date" placeholder="Fecha Desde" v-model="from" />
    <TextInput type="date" placeholder="Fecha Hasta" v-model="to" />
  </div>
</template>

<script setup>
import { ref, watch } from 'vue';
import { router } from '@inertiajs/vue3';

const from = ref('');
const to = ref('');

watch([from, to], () => {
  if (from.value && to.value) {
    router.get(route('posts.index'), { from: from.value, to: to.value }, { preserveState: true });
  }
});
</script>

Ver y Depurar Consultas SQL en Laravel: Detección del Problema N+1

Escribir consultas con Eloquent es extraordinariamente cómodo, pero nunca debemos olvidar que por debajo se ejecutan sentencias SQL en un motor de base de datos relacional. Si no supervisamos qué consultas se están disparando, es muy fácil introducir graves cuellos de botella de rendimiento.

El peligro del problema N+1 y cómo solucionarlo con with() (Eager Loading)

El error de rendimiento más extendido en Laravel es el problema N+1. Ocurre cuando cargamos una lista de registros y luego, dentro de un bucle en la vista o controlador, accedemos a una relación no precargada:

// Código ineficiente (Provoca N+1 consultas):
$posts = Post::limit(20)->get(); // 1 consulta

foreach ($posts as $post) {
    echo $post->category->title; // ¡Se dispara 1 consulta adicional por CADA post! (20 consultas)
}
// Total: 1 + 20 = 21 consultas SQL

Para solucionar esto de raíz, utilizamos la carga ansiosa o Eager Loading mediante el método with():

// Código optimizado con Eager Loading:
$posts = Post::with('category')->limit(20)->get();

foreach ($posts as $post) {
    echo $post->category->title; // La relación ya está cargada en memoria
}
// Total: Solo 2 consultas SQL en total (una para posts y otra con WHERE IN para las categorías)

Herramientas y métodos para inspeccionar el SQL ejecutado

1. Laravel Debugbar (Recomendado en desarrollo)

Es el estándar de la comunidad para inspeccionar consultas en entornos locales. Muestra el listado exhaustivo de todas las sentencias SQL ejecutadas en la petición, los parámetros vinculados (bindings), el tiempo en milisegundos y alertas automáticas de consultas duplicadas:

composer require barryvdh/laravel-debugbar --dev

2. Laravel Telescope y Laravel Pulse

Para una supervisión integral en tiempo real, Laravel Telescope permite auditar peticiones, excepciones, trabajos en cola y queries detalladas:

composer require laravel/telescope --dev
php artisan telescope:install
php artisan migrate

3. Registro de eventos de consulta con DB::listen()

Puedes escuchar cada consulta ejecutada registrando un listener en el método boot() de tu AppServiceProvider para enviar las sentencias a los logs de Laravel:

use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

public function boot(): void
{
    if (app()->isLocal()) {
        DB::listen(function ($query) {
            Log::info("SQL: {$query->sql} [Tiempo: {$query->time}ms]", [
                'bindings' => $query->bindings,
            ]);
        });
    }
}

Advertencia de rendimiento: Nunca mantengas DB::listen() activo sin filtros en entornos de producción, ya que incrementa la sobrecarga de I/O y puede generar archivos de registro desmesurados.

4. Activar el registro puntual con DB::getQueryLog()

Ideal para scripts aislados, comandos artisan y pruebas unitarias con Pest:

DB::enableQueryLog();

// Ejecuta tus consultas con Eloquent
$posts = Post::where('posted', 'yes')->with('category')->get();

$queries = DB::getQueryLog();
dd($queries); // Muestra el array con las sentencias ejecutadas durante el bloque

5. Inspección interactiva con Tinkerwell

Para depuración puntual sin levantar el navegador ni ejecutar vistas completas, herramientas como Tinkerwell permiten escribir consultas con Eloquent o el Query Builder y visualizar de inmediato el resultado y el SQL generado.

Arquitectura en Laravel: De Modelos Gordos (Fat Models) a Servicios

Video thumbnail

A medida que las aplicaciones crecen, es muy común caer en el antipatrón del Modelo Gordo (Fat Model): acumular dentro de la clase del modelo validaciones de negocio, procesamientos de pagos, suscripciones, notificaciones y cálculos de cupones.

Cuando un modelo supera cientos de líneas mezclando responsabilidades de persistencia con lógica de negocio compleja, el mantenimiento y las pruebas automatizadas se vuelven un desafío crítico.

Segregación de responsabilidades con la Capa de Servicios

La solución arquitectónica óptima consiste en delegar la lógica de negocio a Clases de Servicio (Services), manteniendo los modelos enfocados estrictamente en la representación de datos, scopes y relaciones:

namespace App\Services;

use App\Models\Tutorial;
use App\Models\User;

class TutorialService
{
    /**
     * Determina si el recurso es accesible para el usuario.
     */
    public function isAccessible(User $user, Tutorial $tutorial, ?string $coupon = null): bool
    {
        if ($tutorial->price === 0) {
            return true;
        }

        return $user->hasPurchased($tutorial) || $this->applyCoupon($tutorial, $coupon);
    }
}

El Service Container y la Inyección de Dependencias

Laravel incluye un potente Service Container (Contenedor de Servicios) que resuelve de forma automática las dependencias mediante reflexión de clases. Puedes inyectar tus servicios directamente en los constructores o en los métodos de tus controladores:

namespace App\Http\Controllers;

use App\Models\Tutorial;
use App\Services\TutorialService;
use Illuminate\Http\Request;

class TutorialController extends Controller
{
    public function show(Tutorial $tutorial, TutorialService $tutorialService)
    {
        $canAccess = $tutorialService->isAccessible(auth()->user(), $tutorial);

        return view('tutorials.show', compact('tutorial', 'canAccess'));
    }
}

O también resolverlos en cualquier parte del ciclo de vida de la aplicación utilizando el helper global app():

$tutorialService = app(TutorialService::class);

Preguntas Frecuentes (FAQ) sobre Eloquent ORM en Laravel

  • ¿Qué es Eloquent en Laravel?
    • Eloquent es el ORM (Mapeador Objeto-Relacional) oficial incluido por defecto en el framework Laravel. Implementa el patrón Active Record, permitiendo manipular tablas de bases de datos relacionales mediante clases y objetos PHP.
  • ¿Cuál es la diferencia entre all() y get()?
    • all() es un método estático directo que devuelve todas las filas de la tabla sin filtros. Por el contrario, get() se encadena al final de consultas construidas con cláusulas como where(), orderBy() o with().
  • ¿Cómo solucionar el problema de consultas N+1?
    • Se soluciona utilizando la carga ansiosa (Eager Loading) mediante el método with('relacion') antes de ejecutar la consulta, evitando que se dispare una consulta individual por cada elemento en un bucle.
  • ¿Por qué usar #[Fillable] en lugar de protected $fillable?
    • La sintaxis de atributos de PHP introducida en Laravel 13 permite una declaración más limpia y moderna a nivel de metadatos de clase, aunque ambas formas son totalmente compatibles.
  • ¿Cuándo usar pluck() frente a modelKeys()?
    • Usa pluck('id') a nivel de consulta SQL para extraer solo esa columna sin sobrecargar la memoria. Usa modelKeys() solo si ya tienes la colección de modelos cargada previamente en memoria.

Conclusión y Siguientes Pasos

Dominar las operaciones CRUD sobre los modelos y comprender en profundidad cómo funciona Eloquent ORM es el pilar fundamental para cualquier desarrollador que aspire a construir aplicaciones profesionales, seguras y escalables con Laravel.

Desde la configuración adecuada de la asignación masiva con $fillable o atributos nativos de Laravel 13, pasando por la construcción de consultas eficientes con when() y whereBetween(), hasta la detección de consultas N+1 con herramientas como Debugbar o Telescope, cada detalle influye en la velocidad y calidad de tu software.

Ahora que dominas la manipulación de datos con los modelos de Eloquent, el siguiente paso natural consiste en exponer esta lógica al mundo exterior. Te invito a continuar expandiendo tus habilidades creando una REST API completa en Laravel.

Descubre qué es Eloquent ORM en el framework Laravel 13. Aprende cómo hacer un CRUD completo con modelos, atributos #[Fillable], filtros y consultas optimizadas.


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