Tema
Eloquent y migraciones
Esta es una de las lecciones más importantes del curso. Aprenderás a crear tablas con migraciones, a trabajar con ellas mediante modelos Eloquent (sin escribir SQL), y a llenarlas con datos de prueba usando factories y seeders. Con esto podrás construir el CRUD de cualquier sistema.
Concepto
Migraciones: control de versiones para tu base de datos
Imagina que trabajas en equipo y agregas una columna telefono a la tabla clientes. ¿Cómo se entera tu compañero? ¿Y el servidor de producción? Con PHP puro tendrías que pasar archivos .sql y rezar para que nadie se equivoque.
Una migración es un archivo PHP que describe un cambio en la base de datos (crear tabla, agregar columna...). Se guardan en database/migrations, se suben a Git y cualquiera ejecuta php artisan migrate para dejar su BD igual que la tuya. Laravel lleva el registro de cuáles ya se ejecutaron en la tabla migrations.
Eloquent: tus tablas como objetos
Eloquent es el ORM (Object-Relational Mapper) de Laravel. Cada tabla tiene un modelo (una clase) y cada fila es un objeto:
| Base de datos | Eloquent |
|---|---|
Tabla productos | Clase Producto |
| Una fila | Un objeto $producto |
Columna nombre | Propiedad $producto->nombre |
SELECT * FROM productos WHERE id = 5 | Producto::find(5) |
Por debajo, Eloquent usa consultas preparadas de PDO (como aprendiste en PDO), así que estás protegido contra inyección SQL.
Convenciones de nombres
Eloquent asume nombres en inglés: el modelo Product usa la tabla products. Si nombras en español, el plural automático puede fallar (Producto → productos y Categoria → categorias funcionan, pero Proveedor → proveedors ¡no!). Tienes dos opciones:
- Nombrar modelos y tablas en inglés (lo más común en equipos internacionales).
- Nombrar en español e indicar la tabla en el modelo con
protected $table = 'proveedores';.
En este curso usamos español para que el código sea fácil de leer, indicando $table cuando haga falta.
Crear modelo, migración, factory y seeder
powershell
# -m migración, -f factory, -s seeder, -c controlador, -r recurso
php artisan make:model Producto -mfs
# Todo a la vez (migración, factory, seeder, policy, controller de recurso, form requests)
php artisan make:model Producto --allEjemplo: la migración
php
<?php
// database/migrations/2026_10_18_000000_create_productos_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
// Se ejecuta con: php artisan migrate
public function up(): void
{
Schema::create('productos', function (Blueprint $table) {
$table->id(); // BIGINT autoincremental, llave primaria
$table->string('nombre', 120); // VARCHAR(120)
$table->string('slug')->unique(); // único: no se repite
$table->text('descripcion')->nullable(); // puede ser NULL
$table->decimal('precio', 10, 2); // 99999999.99
$table->unsignedInteger('stock')->default(0); // entero positivo, por defecto 0
$table->boolean('activo')->default(true);
$table->string('imagen')->nullable();
$table->timestamps(); // created_at y updated_at
$table->softDeletes(); // deleted_at (borrado lógico)
});
}
// Se ejecuta con: php artisan migrate:rollback
public function down(): void
{
Schema::dropIfExists('productos');
}
};Tipos de columna más usados
| Método | Tipo SQL | Uso |
|---|---|---|
id() | BIGINT UNSIGNED AI PK | Llave primaria |
string('x', 255) | VARCHAR | Textos cortos |
text('x') / longText('x') | TEXT | Textos largos |
integer, unsignedInteger, bigInteger | INT | Números enteros |
decimal('x', 10, 2) | DECIMAL | Dinero (nunca float) |
boolean('x') | TINYINT(1) | Sí/No |
date, dateTime, timestamp | DATE/DATETIME | Fechas |
json('x') | JSON | Datos estructurados |
enum('x', ['a','b']) | ENUM | Lista cerrada de valores |
foreignId('user_id') | BIGINT UNSIGNED | Llave foránea (ver relaciones) |
Modificadores: ->nullable(), ->default(valor), ->unique(), ->index(), ->after('columna'), ->comment('texto').
Modificar una tabla existente
Nunca edites una migración que ya se ejecutó en producción. Crea una nueva:
powershell
php artisan make:migration agregar_sku_a_productos_tablephp
<?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::table('productos', function (Blueprint $table) {
$table->string('sku', 30)->nullable()->unique()->after('id');
});
}
public function down(): void
{
Schema::table('productos', function (Blueprint $table) {
$table->dropColumn('sku');
});
}
};Comandos de migración
powershell
php artisan migrate # ejecuta las pendientes
php artisan migrate:status # cuáles se ejecutaron
php artisan migrate:rollback # deshace el último lote
php artisan migrate:fresh --seed # BORRA TODO, migra y siembra (solo en local)El modelo
php
<?php
// app/Models/Producto.php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
class Producto extends Model
{
use HasFactory, SoftDeletes;
// Opcional: solo si el nombre de la tabla no sigue la convención
protected $table = 'productos';
// Columnas que se pueden asignar en masa con create() y update()
protected $fillable = [
'nombre', 'slug', 'descripcion', 'precio', 'stock', 'activo', 'imagen',
];
// Conversión automática de tipos al leer/escribir
protected function casts(): array
{
return [
'precio' => 'decimal:2',
'stock' => 'integer',
'activo' => 'boolean',
];
}
// Accesor: $producto->precio_formateado
protected function precioFormateado(): Attribute
{
return Attribute::get(fn () => '$' . number_format($this->precio, 2));
}
// Scope local: Producto::activos()->get()
public function scopeActivos(Builder $query): void
{
$query->where('activo', true);
}
// Scope con parámetro: Producto::conStockMinimo(5)->get()
public function scopeConStockMinimo(Builder $query, int $minimo): void
{
$query->where('stock', '>=', $minimo);
}
}Error común: MassAssignmentException
"Add [nombre] to fillable property to allow mass assignment". Si usas Producto::create([...]), cada columna que envías debe estar en $fillable. Esto es una protección de seguridad: impide que alguien mande campos que no esperas (como es_admin).
CRUD con Eloquent
php
use App\Models\Producto;
use Illuminate\Support\Str;
// ---------- CREAR ----------
// Opción 1: create (requiere $fillable)
$producto = Producto::create([
'nombre' => 'Taza de cerámica',
'slug' => Str::slug('Taza de cerámica'), // "taza-de-ceramica"
'precio' => 149.90,
'stock' => 30,
]);
// Opción 2: instanciar y guardar
$producto = new Producto();
$producto->nombre = 'Playera';
$producto->slug = 'playera';
$producto->precio = 199;
$producto->save();
// ---------- LEER ----------
$todos = Producto::all(); // Colección con todos (cuidado con tablas grandes)
$uno = Producto::find(5); // por id, o null
$uno = Producto::findOrFail(5); // por id, o 404
$primero = Producto::where('slug', 'playera')->first();
$baratos = Producto::where('precio', '<', 200)
->where('activo', true)
->orderBy('precio')
->get();
$conteo = Producto::where('stock', 0)->count();
$promedio = Producto::avg('precio');
$pagina = Producto::latest()->paginate(15); // paginación lista para Blade
// ---------- ACTUALIZAR ----------
$producto = Producto::findOrFail(5);
$producto->update(['precio' => 179.90]); // requiere $fillable
// o propiedad por propiedad
$producto->stock = $producto->stock - 1;
$producto->save();
// Actualización masiva (sin cargar los modelos)
Producto::where('stock', 0)->update(['activo' => false]);
// ---------- ELIMINAR ----------
$producto->delete(); // con SoftDeletes: solo llena deleted_at
Producto::destroy([1, 2, 3]); // por ids
// Con SoftDeletes
Producto::withTrashed()->find(5); // incluye eliminados
Producto::onlyTrashed()->get(); // solo eliminados (papelera)
$producto->restore(); // recuperar
$producto->forceDelete(); // borrar definitivamenteConsultas más elaboradas
php
// Buscar por texto en varias columnas
$termino = request('q');
$resultados = Producto::query()
->when($termino, function ($query, $termino) {
// when() solo aplica el filtro si $termino tiene valor
$query->where(function ($q) use ($termino) {
$q->where('nombre', 'like', "%{$termino}%")
->orWhere('descripcion', 'like', "%{$termino}%");
});
})
->activos() // scope del modelo
->whereBetween('precio', [100, 500])
->whereIn('id', [1, 2, 3, 4])
->whereNotNull('imagen')
->orderByDesc('created_at')
->paginate(12)
->withQueryString(); // conserva ?q= en los enlaces de paginación
// Crear o actualizar según exista
Producto::updateOrCreate(
['slug' => 'taza-de-ceramica'], // busca por esto
['precio' => 159.90, 'stock' => 50] // y actualiza/crea con esto
);
// Buscar o crear
$categoria = \App\Models\Categoria::firstOrCreate(['nombre' => 'Hogar']);Colecciones
get() y all() devuelven una Colección: un array con superpoderes.
php
$productos = Producto::all();
$nombres = $productos->pluck('nombre'); // ['Taza', 'Playera', ...]
$caros = $productos->filter(fn ($p) => $p->precio > 500);
$total = $productos->sum(fn ($p) => $p->precio * $p->stock); // valor del inventario
$porLetra = $productos->groupBy(fn ($p) => strtoupper($p->nombre[0]));
$primero = $productos->sortBy('precio')->first();Buena práctica
Filtra en la base de datos (where) y no en la colección (filter) siempre que puedas. Producto::all()->filter(...) trae toda la tabla a memoria; Producto::where(...)->get() solo trae lo necesario.
Factories: datos falsos realistas
php
<?php
// database/factories/ProductoFactory.php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
class ProductoFactory extends Factory
{
public function definition(): array
{
$nombre = fake()->unique()->words(3, true); // "mesa roble rústica"
return [
'nombre' => ucfirst($nombre),
'slug' => Str::slug($nombre),
'descripcion' => fake()->paragraph(),
'precio' => fake()->randomFloat(2, 50, 5000),
'stock' => fake()->numberBetween(0, 100),
'activo' => fake()->boolean(85), // 85 % de probabilidad de true
];
}
// Estado: Producto::factory()->agotado()->create()
public function agotado(): static
{
return $this->state(fn () => ['stock' => 0]);
}
}Para datos en español, en .env agrega APP_FAKER_LOCALE=es_MX.
Seeders: datos iniciales
php
<?php
// database/seeders/DatabaseSeeder.php
namespace Database\Seeders;
use App\Models\Producto;
use App\Models\User;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
public function run(): void
{
// Un usuario administrador fijo para poder entrar
User::factory()->create([
'name' => 'Administrador',
'email' => 'admin@mitienda.com',
// la factory de User pone por defecto la contraseña "password"
]);
// 50 productos aleatorios y 5 agotados
Producto::factory(50)->create();
Producto::factory(5)->agotado()->create();
// Llamar a otros seeders
// $this->call([CategoriaSeeder::class]);
}
}powershell
php artisan db:seed # ejecuta DatabaseSeeder
php artisan db:seed --class=CategoriaSeeder
php artisan migrate:fresh --seed # reconstruye todo desde ceroNunca en producción
migrate:fresh borra todas las tablas. En producción usa solo php artisan migrate --force. Laravel te pide confirmación en producción precisamente para evitar desastres.
Uniendo todo: listado con búsqueda
php
<?php
// app/Http/Controllers/CatalogoController.php
namespace App\Http\Controllers;
use App\Models\Producto;
use Illuminate\Http\Request;
use Illuminate\View\View;
class CatalogoController extends Controller
{
public function __invoke(Request $request): View
{
$productos = Producto::activos()
->when($request->filled('q'), fn ($q) => $q->where('nombre', 'like', '%' . $request->q . '%'))
->orderBy('nombre')
->paginate(12)
->withQueryString();
return view('catalogo', compact('productos'));
}
}blade
{{-- resources/views/catalogo.blade.php --}}
<x-layout titulo="Catálogo">
<form method="GET">
<input name="q" value="{{ request('q') }}" placeholder="Buscar...">
<button>Buscar</button>
</form>
@forelse ($productos as $producto)
<p>{{ $producto->nombre }} — {{ $producto->precio_formateado }}</p>
@empty
<p>No encontramos productos.</p>
@endforelse
{{ $productos->links() }}
</x-layout>Ejercicios
- Crea el modelo
Clientecon migración, factory y seeder. Columnas:nombre,email(único),telefono(opcional),rfc(opcional, 13 caracteres) y timestamps. - Llena la tabla con 30 clientes falsos usando la factory.
- Escribe consultas Eloquent para: (a) clientes sin teléfono, (b) los 5 más recientes, (c) contar los que tienen correo de Gmail.
- Crea una migración que agregue la columna
activo(boolean, true por defecto) aclientes.
Ver solución
- Generar archivos:
powershell
php artisan make:model Cliente -mfsphp
// Migración: database/migrations/xxxx_create_clientes_table.php
public function up(): void
{
Schema::create('clientes', function (Blueprint $table) {
$table->id();
$table->string('nombre', 120);
$table->string('email')->unique();
$table->string('telefono', 20)->nullable();
$table->string('rfc', 13)->nullable();
$table->timestamps();
});
}php
// app/Models/Cliente.php
class Cliente extends Model
{
use HasFactory;
protected $fillable = ['nombre', 'email', 'telefono', 'rfc'];
}php
// database/factories/ClienteFactory.php
public function definition(): array
{
return [
'nombre' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'telefono' => fake()->optional(0.7)->numerify('55########'), // 30 % sin teléfono
'rfc' => null,
];
}- Seeder:
php
// database/seeders/ClienteSeeder.php
public function run(): void
{
\App\Models\Cliente::factory(30)->create();
}powershell
php artisan migrate
php artisan db:seed --class=ClienteSeeder- Consultas:
php
$sinTelefono = Cliente::whereNull('telefono')->get();
$recientes = Cliente::latest()->take(5)->get();
$gmail = Cliente::where('email', 'like', '%@gmail.com')->count();- Nueva columna:
powershell
php artisan make:migration agregar_activo_a_clientes_tablephp
public function up(): void
{
Schema::table('clientes', function (Blueprint $table) {
$table->boolean('activo')->default(true)->after('rfc');
});
}
public function down(): void
{
Schema::table('clientes', function (Blueprint $table) {
$table->dropColumn('activo');
});
}No olvides agregar 'activo' a $fillable y ejecutar php artisan migrate.
Resumen
- Las migraciones versionan la estructura de la BD; nunca edites una ya ejecutada en producción, crea otra.
- Un modelo Eloquent representa una tabla; define
$fillable,casts(), accesores y scopes. - CRUD:
create,find/findOrFail,where()->get(),update,delete;paginate()para listados. SoftDeletespermite papelera y restauración.- Factories generan datos falsos; seeders cargan datos iniciales.
- Usa
decimalpara dinero y filtra en la BD, no en colecciones.