Skip to content

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 datosEloquent
Tabla productosClase Producto
Una filaUn objeto $producto
Columna nombrePropiedad $producto->nombre
SELECT * FROM productos WHERE id = 5Producto::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:

  1. Nombrar modelos y tablas en inglés (lo más común en equipos internacionales).
  2. 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 --all

Ejemplo: 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étodoTipo SQLUso
id()BIGINT UNSIGNED AI PKLlave primaria
string('x', 255)VARCHARTextos cortos
text('x') / longText('x')TEXTTextos largos
integer, unsignedInteger, bigIntegerINTNúmeros enteros
decimal('x', 10, 2)DECIMALDinero (nunca float)
boolean('x')TINYINT(1)Sí/No
date, dateTime, timestampDATE/DATETIMEFechas
json('x')JSONDatos estructurados
enum('x', ['a','b'])ENUMLista cerrada de valores
foreignId('user_id')BIGINT UNSIGNEDLlave 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_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::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 definitivamente

Consultas 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 cero

Nunca 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 ​

  1. Crea el modelo Cliente con migración, factory y seeder. Columnas: nombre, email (único), telefono (opcional), rfc (opcional, 13 caracteres) y timestamps.
  2. Llena la tabla con 30 clientes falsos usando la factory.
  3. Escribe consultas Eloquent para: (a) clientes sin teléfono, (b) los 5 más recientes, (c) contar los que tienen correo de Gmail.
  4. Crea una migración que agregue la columna activo (boolean, true por defecto) a clientes.
Ver solución
  1. Generar archivos:
powershell
php artisan make:model Cliente -mfs
php
// 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,
    ];
}
  1. 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
  1. Consultas:
php
$sinTelefono = Cliente::whereNull('telefono')->get();
$recientes   = Cliente::latest()->take(5)->get();
$gmail       = Cliente::where('email', 'like', '%@gmail.com')->count();
  1. Nueva columna:
powershell
php artisan make:migration agregar_activo_a_clientes_table
php
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.
  • SoftDeletes permite papelera y restauración.
  • Factories generan datos falsos; seeders cargan datos iniciales.
  • Usa decimal para dinero y filtra en la BD, no en colecciones.

Academia Web — aprende, copia y construye.