Laravel

Aprende Laravel: Models, Database & Eloquent

Autorangel cruz
Actualizado
Publicado
Lectura11 min de lectura
Aprende Laravel: Models, Database & Eloquent

En los posts anteriores aprendiste a crear rutas, vistas y controllers. Ahora es momento de trabajar con la base de datos usando el poderoso Eloquent ORM.

¿Qué es Eloquent ORM?

ORM significa Object-Relational Mapping (Mapeo Objeto-Relacional). Eloquent es el ORM de Laravel que te permite trabajar con la base de datos usando objetos PHP en lugar de escribir SQL directamente.

Beneficios de usar Eloquent:

  • Sintaxis como Post::all() en lugar de SELECT * FROM posts
  • Tu IDE puede autocompletar propiedades
  • Define relaciones entre tablas fácilmente
  • Previene SQL injection automáticamente
  • Versiona cambios en tu base de datos con Migrations

El patrón Active Record

Eloquent usa el patrón Active Record, donde cada model representa una tabla y cada instancia del model representa una fila:

// Una instancia = una fila en la tabla
$post = new Post();
$post->title = 'Mi primer post';
$post->save(); // INSERT INTO posts...

Es la diferencia de fondo con Doctrine, el ORM que usan Symfony y buena parte del PHP empresarial. Doctrine implementa Data Mapper: tus entidades son objetos planos que no saben nada de la base de datos, y un EntityManager aparte se encarga de persistirlas. Eloquent hace lo contrario, el model es la fila y sabe guardarse solo.

Ninguno es mejor en abstracto. Active Record es más rápido de escribir y por eso Laravel avanza tan rápido; Data Mapper separa mejor el dominio de la persistencia y se defiende mejor en modelos de negocio complejos. En Laravel la pregunta no se plantea: Eloquent viene incluido y es el camino normal.

Eloquent, Query Builder y SQL: cuándo usar cada uno

Laravel te da tres niveles de acceso a la base de datos, y conviene saber cuándo bajar un escalón:

Tres capas apiladas. Arriba Eloquent, que devuelve models completos. En medio el Query Builder, que devuelve objetos planos de tipo stdClass. Abajo SQL crudo, que devuelve lo que dé el motor. Una marca señala que del Query Builder hacia abajo se pierden los casts, los accessors, los eventos del model y el filtro de borrado suave.

// 1. Eloquent: devuelve models, con relaciones, casts y eventos
$posts = Post::where('published', true)->get();
 
// 2. Query Builder: devuelve objetos planos (stdClass), sin models de por medio
$posts = DB::table('posts')->where('published', true)->get();
 
// 3. SQL crudo: cuando necesitas algo que el builder no expresa
$posts = DB::select('select * from posts where published = ?', [true]);

Usa Eloquent por defecto. Baja a Query Builder cuando proceses miles de filas y no necesites models (instanciarlos cuesta memoria) o para reportes con joins y agregados que no encajan en relaciones. Baja a SQL crudo solo para cosas específicas del motor, como funciones de ventana o CTEs.

Ojo con el escalón: en Query Builder y SQL crudo pierdes los casts, los accessors, los eventos del model y el borrado suave. Si tu tabla usa soft deletes, DB::table('posts')->get() te devuelve también los borrados, porque ese filtro lo pone Eloquent y no la base de datos.

Creando tu Primer Model

Usa el comando Artisan make:model. El flag -m crea automáticamente una migration:

php artisan make:model Post -m

Esto crea dos archivos:

  1. Model: app/Models/Post.php
  2. Migration: database/migrations/xxxx_create_posts_table.php

Convenciones de naming:

  • Model: Post (singular, PascalCase)
  • Tabla asociada: posts (plural, snake_case)
  • Primary key: id (autoincremento por defecto)
  • Timestamps: created_at y updated_at (automáticos)

Convención sobre configuración: Si sigues las convenciones de Laravel, no necesitas configurar casi nada. El model Post automáticamente usa la tabla posts.

Migrations: Construyendo tu Schema

Las migrations son como "control de versiones" para tu base de datos. Cada migration define cómo crear o modificar tablas.

Abre la migration generada (database/migrations/xxxx_create_posts_table.php):

<?php
 
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
 
return new class extends Migration
{
    public function up(): void
    {
        Schema::create('posts', function (Blueprint $table) {
            $table->id();
            $table->string('title');
            $table->text('content');
            $table->boolean('published')->default(false);
            $table->timestamps();
        });
    }
 
    public function down(): void
    {
        Schema::dropIfExists('posts');
    }
};

Column types comunes:

  • id() - Primary key autoincremento (BIGINT UNSIGNED)
  • string('name', 100) - VARCHAR(100)
  • text('content') - TEXT
  • integer('views') - INTEGER
  • boolean('published') - BOOLEAN
  • timestamp('published_at')->nullable() - TIMESTAMP NULL
  • timestamps() - Crea created_at y updated_at
  • foreignId('user_id')->constrained() - Foreign key a tabla users

Ejecutando Migrations

Para crear las tablas en tu base de datos:

php artisan migrate

Verás algo como:

INFO  Running migrations.
 
2025_02_14_000001_create_posts_table ............ 10ms DONE

Otros comandos útiles:

php artisan migrate:rollback  # Deshace el último batch
php artisan migrate:fresh     # Borra todas las tablas y re-crea
php artisan migrate:refresh   # Rollback + migrate

Nunca modifiques migrations ya ejecutadas en producción. Siempre crea una nueva migration para cambios.

CRUD con Eloquent

Ahora que tienes tu tabla, puedes interactuar con ella usando el model.

Create - Crear registros:

// Opción 1: Create + Save
$post = new Post();
$post->title = 'Mi Primer Post';
$post->content = 'Contenido del post...';
$post->save();
 
// Opción 2: Create (mass assignment)
$post = Post::create([
    'title' => 'Mi Primer Post',
    'content' => 'Contenido del post...',
]);
 
// Opción 3: firstOrCreate (busca o crea)
$post = Post::firstOrCreate(
    ['title' => 'Mi Primer Post'],
    ['content' => 'Contenido...']
);

Read - Leer registros:

// Todos los posts
$posts = Post::all();
 
// Buscar por ID
$post = Post::find(1);
 
// Buscar por ID o lanzar 404
$post = Post::findOrFail(1);
 
// Primera coincidencia
$post = Post::where('published', true)->first();
 
// Múltiples condiciones
$posts = Post::where('published', true)
    ->where('views', '>', 100)
    ->get();
 
// Ordenar
$posts = Post::orderBy('created_at', 'desc')->get();
 
// Limitar resultados
$posts = Post::take(10)->get();
 
// Paginación
$posts = Post::paginate(15);

Update - Actualizar registros:

// Opción 1: Find + Update
$post = Post::find(1);
$post->title = 'Título Actualizado';
$post->save();
 
// Opción 2: Update directo
$post = Post::find(1);
$post->update([
    'title' => 'Título Actualizado',
    'published' => true,
]);
 
// Opción 3: Update masivo (sin instanciar)
Post::where('published', false)
    ->update(['published' => true]);

Delete - Eliminar registros:

// Opción 1: Find + Delete
$post = Post::find(1);
$post->delete();
 
// Opción 2: Delete directo por ID
Post::destroy(1);
 
// Opción 3: Delete múltiples
Post::destroy([1, 2, 3]);
 
// Opción 4: Delete condicional
Post::where('views', '<', 10)->delete();

Consultas que no devuelven models

No todo lo que le pides a Eloquent es una lista de registros. Estos métodos devuelven un booleano o un número, y son mucho más baratos que traerte las filas para contarlas en PHP:

// ¿Existe al menos uno? No trae las filas, solo pregunta
if (Post::where('published', true)->exists()) {
    // ...
}
 
// La negación, más legible que un !exists()
if (Post::where('user_id', 5)->doesntExist()) {
    // ...
}
 
// Agregados: devuelven un escalar, no un model
$total = Post::where('published', true)->count();
$maximo = Post::max('views');
$suma = Post::sum('views');
$media = Post::avg('views');

El error clásico es count(Post::all()), que trae la tabla entera a memoria para contarla. Post::count() lo resuelve en la base de datos con un SELECT count(*).

Otros dos que se buscan mucho y casi nunca se explican:

// Valores únicos de una columna
$autores = Post::select('user_id')->distinct()->get();
 
// Orden aleatorio (útil para "post destacado del día")
$post = Post::inRandomOrder()->first();
 
// Tres al azar
$posts = Post::inRandomOrder()->take(3)->get();

Cuidado con inRandomOrder() en tablas grandes: se traduce a ORDER BY RAND(), que obliga al motor a ordenar la tabla entera. Con decenas de miles de filas conviene sacar un id al azar en PHP y buscarlo directo.

Soft Deletes: borrar sin perder el registro

delete() borra de verdad. En muchas aplicaciones eso no es lo que quieres: si un usuario borra su cuenta, probablemente necesites conservar sus facturas. Los soft deletes marcan la fila como borrada en vez de eliminarla.

Añade el trait al model y la columna a la migration:

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
 
class Post extends Model
{
    use SoftDeletes;
}
Schema::table('posts', function (Blueprint $table) {
    $table->softDeletes(); // crea la columna deleted_at, nullable
});

A partir de ahí, delete() rellena deleted_at con la fecha y hora, y todas tus consultas excluyen los borrados automáticamente, sin que tengas que filtrar nada:

$post->delete();      // UPDATE posts SET deleted_at = NOW()
$post->trashed();     // true
 
Post::all();          // no incluye los borrados
Post::withTrashed()->get();  // incluye borrados y vivos
Post::onlyTrashed()->get();  // solo los borrados
 
$post->restore();     // deleted_at = NULL, vuelve a estar vivo
$post->forceDelete(); // ahora sí, DELETE de verdad

Ese filtro automático es la parte que hay que tener presente: lo aplica Eloquent, no la base de datos. Cualquier consulta que se salte el model (DB::table('posts'), un JOIN en SQL crudo, un reporte externo) va a ver los registros borrados como si nada hubiera pasado.

Mass Assignment Protection

Por seguridad, Laravel protege contra mass assignment (asignación masiva). Debes especificar qué campos son "llenables":

<?php
 
namespace App\Models;
 
use Illuminate\Database\Eloquent\Model;
 
class Post extends Model
{
    protected $fillable = [
        'title',
        'content',
        'published',
    ];
}

O especificar qué campos NO son llenables:

protected $guarded = ['id', 'user_id'];

¿Por qué mass assignment protection? Imagina un usuario malicioso enviando is_admin=1 en un formulario. Sin protección, podría hacerse admin!

Relationships: Conectando Models

Una de las features más poderosas de Eloquent son las relaciones.

Ejemplo: Un User tiene muchos Posts (One-to-Many)

1. Agrega foreign key en la migration:

Schema::create('posts', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained()->onDelete('cascade');
    $table->string('title');
    $table->text('content');
    $table->timestamps();
});

2. Define la relación en el Model User:

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

3. Define la relación inversa en Post:

class Post extends Model
{
    public function user()
    {
        return $this->belongsTo(User::class);
    }
}

4. Usa las relaciones:

// Obtener posts de un usuario
$user = User::find(1);
$posts = $user->posts; // Collection de Posts
 
// Obtener el autor de un post
$post = Post::find(1);
$author = $post->user; // User model

Los otros tipos de relación

hasMany y belongsTo cubren la mayoría de los casos, pero hay tres más que vas a necesitar pronto:

// Uno a uno: un usuario tiene un perfil
public function profile()
{
    return $this->hasOne(Profile::class);
}
 
// Muchos a muchos: un post tiene varios tags y un tag varios posts.
// Requiere una tabla pivote, por convención post_tag (singular, alfabético)
public function tags()
{
    return $this->belongsToMany(Tag::class);
}
 
// Polimórfica: comentarios que sirven para posts Y para vídeos
// sin necesitar una tabla de comentarios por cada tipo
public function comments()
{
    return $this->morphMany(Comment::class, 'commentable');
}

La tabla pivote de belongsToMany lleva las dos claves foráneas y nada más, salvo que quieras guardar datos de la propia relación:

Schema::create('post_tag', function (Blueprint $table) {
    $table->foreignId('post_id')->constrained()->cascadeOnDelete();
    $table->foreignId('tag_id')->constrained()->cascadeOnDelete();
});
 
// Asociar y desasociar
$post->tags()->attach($tagId);
$post->tags()->detach($tagId);
$post->tags()->sync([1, 2, 3]); // deja exactamente estos tres

El Problema N+1 y Eager Loading

Sin eager loading, Eloquent ejecuta 1 query por cada relación:

// N+1 Problem - 1 query + N queries
$posts = Post::all(); // 1 query
 
foreach ($posts as $post) {
    echo $post->user->name; // 1 query por post!
}
// Total: 1 + 10 = 11 queries para 10 posts

Solución: usa with() para eager loading:

// Eager Loading - Solo 2 queries
$posts = Post::with('user')->get(); // 2 queries total
 
foreach ($posts as $post) {
    echo $post->user->name; // Sin queries adicionales
}

Dos columnas comparan el número de consultas para recorrer 10 posts y leer el nombre de su autor. Sin eager loading son 11 consultas: una para los posts y una más por cada post, resaltadas. Con eager loading son siempre 2, y ese número no cambia aunque haya mil posts.

El problema N+1 es una de las causas principales de lentitud en aplicaciones Laravel. Siempre usa eager loading cuando accedas a relaciones en loops.

Que Laravel te avise: preventLazyLoading

Acordarse de poner with() no escala. Lo normal es que el N+1 aparezca meses después, cuando alguien añade un $post->user->name dentro de una vista que ya existía. Laravel puede convertir ese descuido en una excepción:

// app/Providers/AppServiceProvider.php
use Illuminate\Database\Eloquent\Model;
 
public function boot(): void
{
    Model::preventLazyLoading(! $this->app->isProduction());
}

Con eso, cualquier acceso a una relación que no se haya cargado con with() lanza una excepción en desarrollo y en tus tests, mientras producción sigue funcionando con normalidad. Dejas de buscar problemas de rendimiento y pasas a que el problema te encuentre a ti, en local, el día que lo escribes.

En la misma línea hay otro que vale la pena activar:

Model::preventSilentlyDiscardingAttributes(! $this->app->isProduction());

Ese hace que Laravel reviente si intentas rellenar por asignación masiva un campo que no está en $fillable, en vez de ignorarlo en silencio. Es el que convierte "el formulario guarda pero ese campo no se actualiza nunca" en un error visible.

Scopes: consultas con nombre

Cuando el mismo where se repite por toda la aplicación, se encapsula en un scope. En Laravel 13 se declaran con el atributo #[Scope], no con el viejo prefijo scopePopular() que todavía repiten muchos tutoriales:

use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
 
class Post extends Model
{
    #[Scope]
    protected function published(Builder $query): void
    {
        $query->where('published', true);
    }
}
 
// Se usa por el nombre del método, sin el atributo
$posts = Post::published()->get();

El tema da para más de lo que cabe aquí, así que tiene sus propios artículos: cómo se usan los query scopes, los global scopes y cómo hacer que tu IDE los autocomplete, que es el punto flojo de los scopes en el día a día.

UUIDv7 en Laravel 13

Novedad en Laravel 13: Ahora puedes usar UUIDs versión 7 como primary keys. Los UUIDv7 son time-ordered (ordenados por tiempo), lo que mejora el performance en bases de datos.

use Illuminate\Database\Eloquent\Concerns\HasUuids;
 
class Post extends Model
{
    use HasUuids;  // Usa UUIDv7 automáticamente
 
    // No necesitas definir $keyType, Laravel lo detecta
}

Y en tu migration:

Schema::create('posts', function (Blueprint $table) {
    $table->uuid('id')->primary();
    $table->string('title');
    // ...
});

Casting: Transformación Automática

Eloquent puede convertir automáticamente tipos de datos:

class Post extends Model
{
    protected $casts = [
        'published_at' => 'datetime',  // String → Carbon instance
        'settings' => 'array',          // JSON → PHP array
        'published' => 'boolean',       // 0/1 → true/false
        'views' => 'integer',
    ];
}

Ahora puedes usar:

$post->published_at->format('Y-m-d'); // Carbon methods
$post->settings['theme']; // Array access

Accessors & Mutators (Breve)

Desde Laravel 9 existe una sintaxis moderna usando Attribute::make(). En Laravel 13 es la forma recomendada:

use Illuminate\Database\Eloquent\Casts\Attribute;
 
// Accessor + Mutator combinados en un método
protected function title(): Attribute
{
    return Attribute::make(
        get: fn (string $value) => strtoupper($value),
        set: fn (string $value) => trim(strip_tags($value)),
    );
}
 
echo $post->title; // "MI PRIMER POST"
$post->title = '<h1>  Mi Post  </h1>'; // Guarda "Mi Post"

La sintaxis antigua (getTitleAttribute / setTitleAttribute) sigue funcionando en Laravel 13 pero es considerada legacy:

// Forma legacy (todavía válida pero no recomendada)
public function getTitleAttribute($value)
{
    return strtoupper($value);
}

Siguiente Paso

Ahora que dominas Models, Migrations y Eloquent, es momento de poner todo en práctica. En el próximo post crearemos un Blog completo desde cero, usando todo lo aprendido: rutas, controllers, vistas, models y relaciones.

Preguntas Frecuentes

¿Qué es Eloquent ORM?

Eloquent es el ORM (Object-Relational Mapping) de Laravel que te permite trabajar con la base de datos usando objetos PHP en lugar de escribir SQL directamente. Por ejemplo: Post::all() en lugar de SELECT * FROM posts. Previene SQL injection automáticamente y hace el código más legible.

¿Cuál es el ORM de Laravel?

Eloquent, y viene incluido con el framework: no hay que instalar nada ni elegir entre alternativas como en otros ecosistemas, donde Doctrine o similares se agregan aparte. Eloquent implementa el patrón Active Record, así que cada model es a la vez la representación de una fila y el objeto que sabe guardarse, actualizarse y borrarse. Puedes usar el Query Builder (DB::table('posts')) para consultas puntuales, pero el camino normal en Laravel es Eloquent.

¿Cómo creo un Model en Laravel?

Usa php artisan make:model Post -m. El flag -m crea automáticamente una migration asociada. El model Post (singular, PascalCase) se asocia automáticamente con la tabla posts (plural, snake_case) por convención.

¿Qué son las Migrations?

Las migrations son como "control de versiones" para tu base de datos. Cada migration define cómo crear o modificar tablas. Ejecutas php artisan migrate para aplicar los cambios. Nunca modifiques migrations ya ejecutadas en producción - siempre crea una nueva migration para cambios.

¿Qué es Mass Assignment Protection?

Es una protección de seguridad contra asignación masiva. Debes especificar qué campos son "llenables" con protected $fillable = ['title', 'content'] o qué NO son llenables con protected $guarded = ['id']. Esto previene que usuarios maliciosos envíen campos como is_admin=1 en formularios.

¿Qué es el problema N+1 y cómo se soluciona?

El problema N+1 ocurre cuando haces 1 query + N queries adicionales en un loop. Ejemplo: Post::all() (1 query) + $post->user->name dentro del loop (N queries). Solución: Usar eager loading con Post::with('user')->get() - solo 2 queries total en lugar de N+1.

¿Qué son los UUIDs en Laravel 13?

Laravel 13 soporta UUIDs versión 7 como primary keys. Usa use HasUuids; en tu model y $table->uuid('id')->primary() en la migration. El trait genera UUIDv7 por defecto, que son ordenables lexicográficamente, y por eso indexan mucho mejor que un UUIDv4 aleatorio. Si necesitas identificadores más cortos, HasUlids hace lo mismo con ULIDs de 26 caracteres.

¿Qué diferencia hay entre Eloquent y Doctrine?

Eloquent implementa Active Record: el model es la fila y sabe guardarse a sí mismo. Doctrine, el ORM de Symfony, implementa Data Mapper: las entidades son objetos planos y un EntityManager aparte se ocupa de persistirlas. Active Record es más rápido de escribir, Data Mapper separa mejor el dominio de la persistencia. En Laravel la elección no se plantea: Eloquent viene incluido.

¿Cuándo uso Query Builder en vez de Eloquent?

Cuando no necesitas models: procesos de miles de filas donde instanciarlos cuesta memoria, o reportes con joins y agregados que no encajan en relaciones. Ten en cuenta que DB::table() se salta los casts, los accessors, los eventos del model y el filtro de soft deletes, así que te devolverá también los registros borrados.

¿Cómo compruebo si un registro existe en Eloquent?

Con exists() o su negación doesntExist(): Post::where('published', true)->exists() devuelve un booleano sin traer las filas. Para contar, Post::count() en lugar de count(Post::all()), que se trae la tabla entera a memoria para contarla en PHP.

¿Qué son los soft deletes?

Un borrado que no borra: el trait SoftDeletes hace que delete() rellene la columna deleted_at en vez de eliminar la fila, y todas tus consultas excluyen esos registros automáticamente. Los recuperas con restore(), los consultas con withTrashed() u onlyTrashed(), y los eliminas de verdad con forceDelete().

Recursos Adicionales

Video de la lección

Ver video tutorial: Aprende Laravel - Models y Database

Playlist completa en YouTube: Aprende Laravel @ YouTube