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 deSELECT * 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:
// 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 -mEsto crea dos archivos:
- Model:
app/Models/Post.php - 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_atyupdated_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')- TEXTinteger('views')- INTEGERboolean('published')- BOOLEANtimestamp('published_at')->nullable()- TIMESTAMP NULLtimestamps()- Crea created_at y updated_atforeignId('user_id')->constrained()- Foreign key a tabla users
Ejecutando Migrations
Para crear las tablas en tu base de datos:
php artisan migrateVerás algo como:
INFO Running migrations.
2025_02_14_000001_create_posts_table ............ 10ms DONEOtros 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 + migrateNunca 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 verdadEse 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=1en 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 modelLos 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 tresEl 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 postsSolució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
}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 accessAccessors & 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
- Documentación de Eloquent
- Migrations
- Eloquent Relationships
- Soft deleting
- Query scopes
- UUIDs y ULIDs
Video de la lección
Ver video tutorial: Aprende Laravel - Models y Database
Playlist completa en YouTube: Aprende Laravel @ YouTube