Eloquent é o ORM do Laravel, e é um dos mais elegantes disponíveis em qualquer linguagem. Cada tabela do banco tem um Model correspondente. Cada linha da tabela é representada por uma instância do Model. Relacionamentos entre tabelas viram métodos fluentes. Queries complexas viram código que se lê em voz alta.
Já vimos Eloquent brevemente no artigo Query Builder e Migrations. Aqui aprofundamos no contexto do Laravel: migrations integradas, factories, relacionamentos avançados, e os recursos que fazem o Eloquent brilhar em projetos reais.
Migrations
Migrations são controle de versão para o banco de dados. Cada alteração no schema é um arquivo versionado que pode ser aplicado e revertido:
php artisan make:migration criar_tabela_produtos
<?php
// database/migrations/2024_01_15_000001_criar_tabela_produtos.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('categorias', function (Blueprint $table) {
$table->id();
$table->string('nome', 100);
$table->string('slug', 120)->unique();
$table->text('descricao')->nullable();
$table->boolean('ativo')->default(true);
$table->timestamps(); // created_at e updated_at automáticos
});
Schema::create('produtos', function (Blueprint $table) {
$table->id();
$table->foreignId('categoria_id')
->constrained() // referencia categorias.id
// `cascadeOnDelete()` aqui seria uma armadilha: apagar UMA
// categoria levaria junto todo o catálogo dela, sem aviso e
// sem volta. Cascata só faz sentido quando o filho não
// existe sem o pai (um item de pedido, por exemplo). Para
// vínculo de categorização, o certo é barrar a exclusão e
// obrigar quem apaga a decidir o destino dos produtos.
->restrictOnDelete();
$table->string('nome', 200);
$table->string('slug', 220)->unique();
$table->text('descricao')->nullable();
$table->decimal('preco', 10, 2);
$table->unsignedInteger('estoque')->default(0);
$table->boolean('ativo')->default(true);
$table->json('atributos')->nullable(); // dados extras flexíveis
$table->softDeletes(); // deleted_at — soft delete
$table->timestamps();
$table->index(['ativo', 'categoria_id']); // índice composto
});
}
public function down(): void
{
Schema::dropIfExists('produtos');
Schema::dropIfExists('categorias');
}
};
php artisan migrate # Aplica migrations pendentes
php artisan migrate:rollback # Reverte a última batch
php artisan migrate:status # Mostra status de cada migration
Models e fillable
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
class Produto extends Model
{
use HasFactory, SoftDeletes;
// Campos que podem ser preenchidos em massa
protected $fillable = [
'categoria_id',
'nome',
'slug',
'descricao',
'preco',
'estoque',
'ativo',
'atributos',
];
// Casting automático de tipos
protected $casts = [
'preco' => 'decimal:2',
'estoque' => 'integer',
'ativo' => 'boolean',
'atributos' => 'array', // JSON → array automaticamente
];
// Hook: executado antes de criar
protected static function booted(): void
{
static::creating(function (Produto $produto) {
if (empty($produto->slug)) {
$produto->slug = Str::slug($produto->nome);
}
});
}
// Accessor: transforma o valor ao ler.
// O cast `decimal:2` devolve STRING, não float — e como este arquivo
// declara `strict_types=1`, passar `$this->preco` direto para o
// `number_format()` lança TypeError em toda leitura. O cast explícito
// é obrigatório aqui.
public function getPrecoFormatadoAttribute(): string
{
return 'R$ ' . number_format((float) $this->preco, 2, ',', '.');
}
// Scope: query reutilizável
public function scopeAtivo($query)
{
return $query->where('ativo', true)->where('estoque', '>', 0);
}
// Relacionamentos
public function categoria(): BelongsTo
{
return $this->belongsTo(Categoria::class);
}
public function avaliacoes(): HasMany
{
return $this->hasMany(Avaliacao::class);
}
}
Relacionamentos completos
<?php
// Belongs To Many (muitos para muitos) — pedidos ↔ produtos
class Pedido extends Model
{
public function produtos(): BelongsToMany
{
return $this->belongsToMany(Produto::class, 'pedido_produto')
->withPivot(['quantidade', 'preco_unitario']) // colunas extras na pivot
->withTimestamps();
}
}
// Has Many Through — categoria → produtos → avaliações.
// O `through` exige uma CADEIA de chaves estrangeiras simples: o modelo do
// meio aponta para o pai, e o do fim aponta para o do meio. Confere com o
// schema lá de cima — `produtos.categoria_id` e `avaliacoes.produto_id`.
// Pedido → produtos NÃO cabe aqui: entre os dois há uma tabela pivot,
// e pivot é trabalho de `belongsToMany`, como no exemplo anterior.
class Categoria extends Model
{
public function avaliacoes(): HasManyThrough
{
// select * from avaliacoes
// inner join produtos on produtos.id = avaliacoes.produto_id
// where produtos.categoria_id = ?
return $this->hasManyThrough(Avaliacao::class, Produto::class);
}
}
// Polymorphic — comentários em produtos E posts
class Comentario extends Model
{
public function comentavel(): MorphTo
{
return $this->morphTo();
}
}
class Produto extends Model
{
public function comentarios(): MorphMany
{
return $this->morphMany(Comentario::class, 'comentavel');
}
}
Queries com Eloquent
<?php
// Básico
$produtos = Produto::all();
$produto = Produto::find(1);
$produto = Produto::findOrFail(1); // lança 404 se não encontrar
// Com condições
$ativos = Produto::where('ativo', true)
->where('estoque', '>', 0)
->orderBy('nome')
->get();
// Usando o scope definido no model
$ativos = Produto::ativo()->orderBy('nome')->get();
// Eager loading — carrega relacionamentos em uma query (evita N+1)
$produtos = Produto::with(['categoria', 'avaliacoes'])
->where('ativo', true)
->get();
// Paginação
$produtos = Produto::ativo()
->with('categoria')
->paginate(15);
// Inserir
$produto = Produto::create([
'nome' => 'Monitor LG 27"',
'preco' => 2499.90,
'categoria_id' => 3,
'estoque' => 10,
]);
// Atualizar
$produto->update(['preco' => 2299.90, 'estoque' => 8]);
// Upsert
Produto::updateOrCreate(
['slug' => 'monitor-lg-27'], // condição
['nome' => 'Monitor LG 27"', 'preco' => 2299.90] // valores
);
// Deletar (soft delete com SoftDeletes)
$produto->delete(); // seta deleted_at, não remove do banco
$produto->restore(); // restaura
$produto->forceDelete(); // remove definitivamente
// Incluir deletados nas queries
Produto::withTrashed()->where('nome', 'like', '%lg%')->get();
Produto::onlyTrashed()->get();
// Usando pivot em many-to-many
$pedido->produtos()->attach($produtoId, [
'quantidade' => 2,
'preco_unitario' => 299.90,
]);
$pedido->produtos()->sync([1, 2, 3]); // sincroniza a lista completa
Factories e Seeders
php artisan make:factory ProdutoFactory
<?php
// database/factories/ProdutoFactory.php
namespace Database\Factories;
use App\Models\Categoria;
use Illuminate\Database\Eloquent\Factories\Factory;
class ProdutoFactory extends Factory
{
public function definition(): array
{
return [
'categoria_id' => Categoria::factory(),
'nome' => $this->faker->words(3, true),
'preco' => $this->faker->randomFloat(2, 9.90, 2999.90),
'estoque' => $this->faker->numberBetween(0, 100),
'ativo' => $this->faker->boolean(80), // 80% chance de true
'descricao' => $this->faker->paragraph(),
];
}
// States — variações da factory
public function esgotado(): static
{
return $this->state(['estoque' => 0, 'ativo' => false]);
}
public function premium(): static
{
return $this->state(['preco' => $this->faker->randomFloat(2, 1000, 9999)]);
}
}
// Uso nos testes:
$produto = Produto::factory()->create();
$produtos = Produto::factory()->count(20)->create();
$esgotado = Produto::factory()->esgotado()->create();
// Seeder:
class DatabaseSeeder extends Seeder
{
public function run(): void
{
// Sem o `recycle()`, o `'categoria_id' => Categoria::factory()` da
// definition roda 50 vezes e cria 50 categorias NOVAS: o banco
// termina com 60 categorias, as 10 daqui vazias e cada produto
// sozinho na sua. Medido. O `recycle()` manda a factory reaproveitar
// os registros que você já tem em vez de fabricar outros.
$categorias = Categoria::factory()->count(10)->create();
Produto::factory()
->count(50)
->recycle($categorias)
->create();
}
}
O Eloquent cobra o preço de toda boa abstração: ele esconde o SQL tão bem que é fácil esquecer que existe SQL embaixo. Produto::with('categoria')->paginate(15) lê como uma frase e pode disparar duas queries ou dezesseis, dependendo de uma palavra. O $casts parece devolver o tipo que você declarou e, no caso do decimal, devolve uma string. O hasManyThrough aceita qualquer par de classes no construtor e só reclama quando o banco recebe a consulta impossível. Nada disso é defeito do ORM — é o custo de uma camada que aceita mais do que o esquema permite.
O antídoto é barato e cabe no fluxo de trabalho: toSql() quando a relação é nova, dd($produto->getAttributes()) quando o tipo surpreende, e um query log ligado em desenvolvimento para que o N+1 apareça enquanto ainda é um número na tela, e não uma reclamação de lentidão meses depois. Vale também a régua de escopo: $casts, hooks e accessors vivem na instância, e toda escrita que não passa por ela ignora os três em silêncio. Saber disso é a diferença entre confiar no Eloquent e depender dele.
Fontes e leituras recomendadas
- Eloquent ORM — Documentação Laravel: https://laravel.com/docs/eloquent
- Eloquent Relationships: https://laravel.com/docs/eloquent-relationships
- Database Migrations: https://laravel.com/docs/migrations
- Laravel Factories: https://laravel.com/docs/eloquent-factories
- Laravel — query builder, para quando hidratar model por linha custa caro demais: https://laravel.com/docs/queries
- Laravel — mutators e casts, que tiram a conversão de tipo de dentro do controller: https://laravel.com/docs/eloquent-mutators
Exercícios
Exercício 1
Crie migrations para: usuarios, categorias, produtos, pedidos, pedido_produto (pivot). Use foreignId()->constrained(), softDeletes(), timestamps() e índices apropriados.
Ver resposta
✓ Resposta: onDelete é decisão consciente, e o padrão certo quase nunca é CASCADE. Categoria → produto e usuário → pedido pedem restrictOnDelete(): apagar uma categoria não pode varrer o catálogo, nem apagar um cliente pode sumir com o histórico contábil. CASCADE fica para o que não existe sem o pai — o item da pivô sem o pedido. E note o preco_unitario copiado na pivô: preço de catálogo muda, preço de pedido fechado não.
<?php
// ============ ordem de criação importa ============
// A migration que declara a FK tem de rodar DEPOIS da tabela referenciada.
// O Laravel executa por ordem alfabética do arquivo, e o timestamp do nome
// garante isso — desde que você crie na ordem certa.
//
// php artisan make:migration create_usuarios_table
// php artisan make:migration create_categorias_table
// php artisan make:migration create_produtos_table
// php artisan make:migration create_pedidos_table
// php artisan make:migration create_pedido_produto_table
// ------------------- database/migrations/..._create_usuarios_table.php
declare(strict_types=1);
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('usuarios', function (Blueprint $tabela): void {
$tabela->id();
$tabela->string('nome');
$tabela->string('email')->unique();
$tabela->timestamp('email_verificado_em')->nullable();
$tabela->string('senha');
$tabela->rememberToken();
$tabela->timestamps();
$tabela->softDeletes();
});
}
public function down(): void
{
Schema::dropIfExists('usuarios');
}
};
// ------------------- database/migrations/..._create_produtos_table.php
declare(strict_types=1);
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('produtos', function (Blueprint $tabela): void {
$tabela->id();
// foreignId()->constrained() cria a coluna UNSIGNED BIGINT e a
// FK numa linha só, deduzindo a tabela pelo nome da coluna.
//
// O onDelete é DECISÃO, não padrão: excluir uma categoria não
// pode varrer os produtos junto. restrictOnDelete() faz o banco
// recusar a exclusão enquanto houver produto — que é o que se
// quer para vínculo de categorização.
$tabela->foreignId('categoria_id')
->constrained('categorias')
->restrictOnDelete()
->cascadeOnUpdate();
$tabela->string('nome');
$tabela->string('slug')->unique();
$tabela->text('descricao')->nullable();
// decimal, nunca float: o float perde centavo em soma. O
// alternativo é guardar centavos como integer.
$tabela->decimal('preco', 10, 2);
$tabela->unsignedInteger('estoque')->default(0);
$tabela->boolean('ativo')->default(true);
$tabela->timestamps();
$tabela->softDeletes();
// Índice composto na ORDEM da consulta mais frequente:
// WHERE categoria_id = ? AND ativo = 1. O MySQL usa o prefixo
// à esquerda, então este índice serve também para
// "WHERE categoria_id = ?" sozinho — mas não para "ativo"
// sozinho, que precisaria de outro.
$tabela->index(['categoria_id', 'ativo']);
$tabela->index('preco');
});
}
public function down(): void
{
Schema::dropIfExists('produtos');
}
};
// ------------------- database/migrations/..._create_pedidos_table.php
declare(strict_types=1);
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('pedidos', function (Blueprint $tabela): void {
$tabela->id();
// Também RESTRICT: apagar um cliente não pode apagar o
// histórico de vendas — isso é dado contábil.
$tabela->foreignId('usuario_id')
->constrained('usuarios')
->restrictOnDelete();
$tabela->string('codigo', 20)->unique();
// Enum como string, não como ENUM do MySQL: acrescentar um
// status vira uma linha no PHP, não um ALTER TABLE que trava
// a tabela em produção.
$tabela->string('status', 20)->default('rascunho');
$tabela->decimal('total', 10, 2)->default(0);
$tabela->timestamp('confirmado_em')->nullable();
$tabela->timestamps();
$tabela->softDeletes();
// A consulta do painel: pedidos de um cliente, mais recentes
// primeiro.
$tabela->index(['usuario_id', 'created_at']);
$tabela->index(['status', 'created_at']);
});
}
public function down(): void
{
Schema::dropIfExists('pedidos');
}
};
// ------------------- database/migrations/..._create_pedido_produto_table.php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
// Nome da pivô: singular dos dois models, em ordem alfabética —
// `pedido_produto`. Seguindo a convenção, o Eloquent a encontra
// sozinho e o belongsToMany não precisa do segundo argumento.
Schema::create('pedido_produto', function (Blueprint $tabela): void {
$tabela->id();
// Aqui CASCADE faz sentido: o item não existe sem o pedido.
$tabela->foreignId('pedido_id')
->constrained('pedidos')
->cascadeOnDelete();
// Mas o produto, não: apagar um produto do catálogo não pode
// sumir com o item de um pedido já faturado.
$tabela->foreignId('produto_id')
->constrained('produtos')
->restrictOnDelete();
$tabela->unsignedInteger('quantidade');
// O preço é COPIADO para a pivô de propósito: o preço do
// catálogo muda, o do pedido fechado não pode mudar junto.
$tabela->decimal('preco_unitario', 10, 2);
// Permite desativar um item sem apagá-lo — o produto saiu do
// catálogo, mas a linha do pedido continua existindo.
$tabela->boolean('ativo')->default(true);
$tabela->timestamps();
// Impede o mesmo produto duas vezes no mesmo pedido — a regra
// fica no banco, onde nenhuma corrida de requisição a burla.
$tabela->unique(['pedido_id', 'produto_id']);
});
}
public function down(): void
{
Schema::dropIfExists('pedido_produto');
}
};
// ============ conferindo o resultado ============
// php artisan migrate
// php artisan migrate:status
// php artisan migrate:rollback --step=1 ← testar o down() SEMPRE
// php artisan migrate:fresh --seed ← só em desenvolvimento
//
// db:show e db:table mostram o schema real, com índices e chaves:
// php artisan db:table produtos
Exercício 2
Implemente o relacionamento many-to-many entre Pedido e Produto com withPivot(['quantidade', 'preco_unitario']). Escreva código que cria um pedido, adiciona produtos via attach(), e calcula o total iterando sobre $pedido->produtos.
Ver resposta
✓ Resposta: Três armadilhas da pivô, em ordem de dor. Sem withPivot(), $produto->pivot->quantidade vem null — o Eloquent só traz as colunas declaradas. Sem with('produtos'), cada pedido dispara uma consulta nova (N+1). E detach() sem argumento remove todos os vínculos — é o esquecimento mais caro da lista. De projeto: o preco_unitario é copiado para a pivô porque preço de catálogo muda e pedido fechado não pode mudar junto.
<?php
// ---------------------------------------- app/Models/Pedido.php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Pedido extends Model
{
use HasFactory;
use SoftDeletes;
protected $fillable = ['usuario_id', 'codigo', 'status', 'total', 'confirmado_em'];
protected function casts(): array
{
return [
'total' => 'decimal:2',
'confirmado_em' => 'datetime',
'status' => StatusPedido::class, // enum PHP como cast
];
}
public function usuario(): BelongsTo
{
return $this->belongsTo(Usuario::class);
}
public function produtos(): BelongsToMany
{
return $this->belongsToMany(Produto::class)
// Sem withPivot, $produto->pivot->quantidade vem NULL: o
// Eloquent só traz as colunas que você declarar.
->withPivot(['quantidade', 'preco_unitario'])
->withTimestamps();
}
/** Accessor: $pedido->total_calculado */
protected function totalCalculado(): Attribute
{
return Attribute::get(fn (): float => (float) $this->produtos->sum(
fn (Produto $produto): float =>
$produto->pivot->quantidade * (float) $produto->pivot->preco_unitario
));
}
}
// ---------------------------------------- app/Models/StatusPedido.php
declare(strict_types=1);
namespace App\Models;
enum StatusPedido: string
{
case Rascunho = 'rascunho';
case Confirmado = 'confirmado';
case Enviado = 'enviado';
case Entregue = 'entregue';
case Cancelado = 'cancelado';
public function rotulo(): string
{
return match ($this) {
self::Rascunho => 'Rascunho',
self::Confirmado => 'Confirmado',
self::Enviado => 'A caminho',
self::Entregue => 'Entregue',
self::Cancelado => 'Cancelado',
};
}
public function podeSerCancelado(): bool
{
return in_array($this, [self::Rascunho, self::Confirmado, self::Enviado], true);
}
}
// ---------------------------------------- app/Services/MontadorDePedido.php
declare(strict_types=1);
namespace App\Services;
use App\Models\Pedido;
use App\Models\Produto;
use App\Models\StatusPedido;
use App\Models\Usuario;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use RuntimeException;
class MontadorDePedido
{
/** @param array<int, int> $itens produto_id => quantidade */
public function criar(Usuario $usuario, array $itens): Pedido
{
if ($itens === []) {
throw new RuntimeException('O pedido precisa de ao menos um item.');
}
return DB::transaction(function () use ($usuario, $itens): Pedido {
$pedido = $usuario->pedidos()->create([
'codigo' => 'PED-' . Str::upper(Str::random(8)),
'status' => StatusPedido::Rascunho,
'total' => 0,
]);
// Uma consulta para todos os produtos. Buscar dentro do laço
// seria N consultas — o N+1 clássico, aqui na escrita.
$produtos = Produto::query()
->whereIn('id', array_keys($itens))
->lockForUpdate() // trava as linhas até o commit
->get()
->keyBy('id');
$anexar = [];
foreach ($itens as $produtoId => $quantidade) {
$produto = $produtos->get($produtoId)
?? throw new RuntimeException("Produto {$produtoId} não existe.");
if ($produto->estoque < $quantidade) {
throw new RuntimeException(
"Estoque insuficiente de \"{$produto->nome}\": "
. "restam {$produto->estoque}."
);
}
// O preço é CONGELADO no momento da compra. Ler
// $produto->preco depois traria o preço de hoje, não o do
// pedido — e o histórico deixaria de fechar.
$anexar[$produtoId] = [
'quantidade' => $quantidade,
'preco_unitario' => $produto->preco,
];
$produto->decrement('estoque', $quantidade);
}
// attach com array associativo: um único INSERT para todos os
// itens, com os dados da pivô.
$pedido->produtos()->attach($anexar);
// Recarrega o relacionamento antes de somar; sem isso, a
// coleção ainda está vazia em memória.
$pedido->load('produtos');
$pedido->update(['total' => $pedido->total_calculado]);
return $pedido;
});
}
}
// ---------------------------------------- exemplo de uso
declare(strict_types=1);
use App\Models\Pedido;
use App\Models\Produto;
use App\Models\Usuario;
use App\Services\MontadorDePedido;
$usuario = Usuario::query()->firstOrFail();
$pedido = (new MontadorDePedido())->criar($usuario, [
1 => 2, // produto 1, duas unidades
5 => 1,
]);
// ---- iterando sobre a pivô ----
// with() é obrigatório aqui: sem ele, cada $pedido->produtos dispara uma
// consulta nova — 50 pedidos viram 51 consultas.
$pedidos = Pedido::query()->with('produtos')->get();
foreach ($pedidos as $pedido) {
echo "Pedido {$pedido->codigo} ({$pedido->status->rotulo()})\n";
$total = 0.0;
foreach ($pedido->produtos as $produto) {
$subtotal = $produto->pivot->quantidade * (float) $produto->pivot->preco_unitario;
$total += $subtotal;
printf(" %-30s %2d x %8.2f = %9.2f\n",
$produto->nome,
$produto->pivot->quantidade,
$produto->pivot->preco_unitario,
$subtotal);
}
printf(" %-30s %25.2f\n", 'TOTAL', $total);
}
// ---- os quatro métodos da pivô, e a diferença entre eles ----
$pedido->produtos()->attach(7, ['quantidade' => 1, 'preco_unitario' => 99.90]);
// acrescenta. Chamado duas vezes com o mesmo id, insere DUAS linhas —
// a menos que exista o unique(['pedido_id','produto_id']) da migration.
$pedido->produtos()->detach(7);
// remove só o 7. Sem argumento, detach() remove TODOS — é o erro de digitação
// mais caro desta lista.
$pedido->produtos()->sync([
3 => ['quantidade' => 2, 'preco_unitario' => 50.00],
9 => ['quantidade' => 1, 'preco_unitario' => 30.00],
]);
// deixa EXATAMENTE esses dois: insere o que falta, remove o que sobra.
$pedido->produtos()->syncWithoutDetaching([4 => ['quantidade' => 1, 'preco_unitario' => 10.0]]);
// insere/atualiza sem remover nada.
$pedido->produtos()->updateExistingPivot(3, ['quantidade' => 5]);
// muda só a coluna da pivô, sem tocar no vínculo.
// ---- somando no banco, sem trazer as linhas ----
$totalNoBanco = $pedido->produtos()
->selectRaw('SUM(pedido_produto.quantidade * pedido_produto.preco_unitario) AS soma')
->value('soma');
// Para um pedido de 5 itens, iterar em PHP é indiferente. Para um relatório
// de 200 mil linhas, esta é a única forma que não estoura a memória.
Exercício 3
Crie factories para todos os models. Use states: Produto::factory()->esgotado(), Pedido::factory()->cancelado(). Crie um seeder que gera dados realistas para o ambiente de desenvolvimento.
Ver resposta
✓ Resposta: Um state é uma função do estado anterior, e é daí que vem a composição: ->caro()->promocao() aplica o desconto sobre o preço alto, porque o segundo state recebe em $atributos o que o primeiro definiu. O outro ponto que economiza horas é afterCreating — pivô e relacionamento precisam do id, que só existe depois do INSERT. E a trava de environment('production') no seeder não é paranoia: migrate:fresh --seed apontado para o banco errado é um erro de uma tecla.
<?php
// ============ no terminal ============
// php artisan make:factory ProdutoFactory --model=Produto
// php artisan make:seeder LojaSeeder
// ---------------------------------------- database/factories/CategoriaFactory.php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\Categoria;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/** @extends Factory<Categoria> */
class CategoriaFactory extends Factory
{
protected $model = Categoria::class;
public function definition(): array
{
$nome = fake('pt_BR')->unique()->randomElement([
'Livros', 'Games', 'Eletrônicos', 'Casa e Cozinha',
'Esportes', 'Moda', 'Brinquedos', 'Papelaria',
]);
return [
'nome' => $nome,
'slug' => Str::slug($nome),
'descricao' => fake('pt_BR')->sentence(),
'ativa' => true,
'ordem' => fake()->numberBetween(0, 100),
];
}
public function inativa(): static
{
return $this->state(fn (): array => ['ativa' => false]);
}
}
// ---------------------------------------- database/factories/ProdutoFactory.php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\Categoria;
use App\Models\Produto;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/** @extends Factory<Produto> */
class ProdutoFactory extends Factory
{
protected $model = Produto::class;
public function definition(): array
{
$nome = fake('pt_BR')->words(3, true);
return [
// A factory da categoria só é executada se ninguém informar a
// categoria — `Produto::factory()->for($categoria)` substitui.
'categoria_id' => Categoria::factory(),
'nome' => Str::title($nome),
'slug' => Str::slug($nome) . '-' . fake()->unique()->numberBetween(1, 999999),
'descricao' => fake('pt_BR')->paragraph(),
'preco' => fake()->randomFloat(2, 9.90, 999.90),
'estoque' => fake()->numberBetween(0, 200),
'ativo' => true,
];
}
/** Produto::factory()->esgotado() */
public function esgotado(): static
{
return $this->state(fn (): array => ['estoque' => 0]);
}
public function inativo(): static
{
return $this->state(fn (): array => ['ativo' => false]);
}
public function caro(): static
{
return $this->state(fn (): array => [
'preco' => fake()->randomFloat(2, 1000, 15000),
]);
}
/** States compõem: Produto::factory()->caro()->esgotado()->create() */
public function promocao(): static
{
return $this->state(fn (array $atributos): array => [
// O array $atributos traz o que já foi definido — dá para
// calcular em cima do valor anterior.
'preco' => round(($atributos['preco'] ?? 100) * 0.7, 2),
]);
}
}
// ---------------------------------------- database/factories/PedidoFactory.php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\Pedido;
use App\Models\StatusPedido;
use App\Models\Usuario;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/** @extends Factory<Pedido> */
class PedidoFactory extends Factory
{
protected $model = Pedido::class;
public function definition(): array
{
return [
'usuario_id' => Usuario::factory(),
'codigo' => 'PED-' . Str::upper(Str::random(8)),
'status' => StatusPedido::Confirmado,
'total' => 0,
'confirmado_em' => now()->subDays(fake()->numberBetween(0, 90)),
];
}
public function cancelado(): static
{
return $this->state(fn (): array => [
'status' => StatusPedido::Cancelado,
'confirmado_em' => null,
]);
}
public function entregue(): static
{
return $this->state(fn (): array => ['status' => StatusPedido::Entregue]);
}
/**
* afterCreating roda com o model JÁ salvo — é onde entra tudo que
* precisa de id: relacionamento, pivô, arquivo.
*/
public function comItens(int $quantidade = 3): static
{
return $this->afterCreating(function (Pedido $pedido) use ($quantidade): void {
$produtos = \App\Models\Produto::query()
->inRandomOrder()
->limit($quantidade)
->get();
foreach ($produtos as $produto) {
$pedido->produtos()->attach($produto->id, [
'quantidade' => fake()->numberBetween(1, 4),
'preco_unitario' => $produto->preco,
]);
}
$pedido->load('produtos');
$pedido->update(['total' => $pedido->total_calculado]);
});
}
}
// ---------------------------------------- database/seeders/LojaSeeder.php
declare(strict_types=1);
namespace Database\Seeders;
use App\Models\Categoria;
use App\Models\Pedido;
use App\Models\Produto;
use App\Models\Usuario;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
class LojaSeeder extends Seeder
{
public function run(): void
{
// Seeder de desenvolvimento NUNCA roda em produção sem trava.
if (app()->environment('production')) {
$this->command->error('Este seeder é só para desenvolvimento.');
return;
}
// Uma transação para tudo: se o seeder falhar no meio, o banco não
// fica pela metade.
DB::transaction(function (): void {
// Conta fixa para o time entrar sem procurar credencial —
// e-mail e senha previsíveis são uma VANTAGEM aqui.
$admin = Usuario::factory()->create([
'nome' => 'Administradora',
'email' => 'admin@loja.test',
]);
$this->command->info("Admin: {$admin->email} / senha: password");
$clientes = Usuario::factory()->count(20)->create();
$categorias = Categoria::factory()->count(6)->create();
// Distribuição realista: cada categoria com uma quantidade
// diferente de produtos, alguns esgotados, alguns inativos.
$categorias->each(function (Categoria $categoria): void {
$quantos = fake()->numberBetween(8, 25);
Produto::factory()
->count($quantos)
->for($categoria)
->create();
// 10% esgotados — a vitrine precisa desse caso para ser
// testada de verdade.
Produto::factory()
->count((int) ceil($quantos * 0.1))
->for($categoria)
->esgotado()
->create();
Produto::factory()->count(2)->for($categoria)->inativo()->create();
});
// Pedidos espalhados pelos clientes, com estados variados.
$clientes->each(function (Usuario $cliente): void {
Pedido::factory()
->count(fake()->numberBetween(0, 5))
->for($cliente)
->comItens(fake()->numberBetween(1, 4))
->create();
});
Pedido::factory()->count(4)->cancelado()->comItens(2)->create();
Pedido::factory()->count(8)->entregue()->comItens(3)->create();
$this->command->table(
['Tabela', 'Registros'],
[
['usuarios', Usuario::count()],
['categorias', Categoria::count()],
['produtos', Produto::count()],
['pedidos', Pedido::count()],
],
);
});
}
}
// ---------------------------------------- database/seeders/DatabaseSeeder.php
declare(strict_types=1);
namespace Database\Seeders;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
public function run(): void
{
$this->call([
LojaSeeder::class,
]);
}
}
// ============ usando ============
// php artisan migrate:fresh --seed
// php artisan db:seed --class=LojaSeeder
//
// Nos testes, a factory substitui o seeder — cada teste monta só o cenário
// de que precisa:
//
// $produto = Produto::factory()->esgotado()->create();
// $pedido = Pedido::factory()->comItens(2)->for($usuario)->create();
// $lista = Produto::factory()->count(30)->for($categoria)->create();
//
// makeOne() cria o model sem INSERT — teste de unidade sem banco.
Exercício 4
Desafio: implemente o Observer Pattern com Eloquent Observers: php artisan make:observer ProdutoObserver. Ao criar um produto, gere o slug automaticamente. Ao deletar (soft delete), desative todos os itens de pedidos pendentes que contêm esse produto.
Ver resposta
✓ Resposta: A limitação que derruba todo mundo uma vez: operação em massa não dispara observer. Produto::where(...)->update([...]) e ->delete() não carregam os models, então nenhum evento roda — para disparar, é preciso iterar (each) ou usar os métodos do model. As outras duas decisões que valem atenção: creating, não created, é onde se altera o model sem gerar um UPDATE extra; e o slug não é regenerado ao renomear — URL publicada e indexada não pode mudar sozinha.
<?php
// ============ no terminal ============
// php artisan make:observer ProdutoObserver --model=Produto
// ---------------------------------------- app/Observers/ProdutoObserver.php
declare(strict_types=1);
namespace App\Observers;
use App\Models\Produto;
use App\Models\StatusPedido;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class ProdutoObserver
{
/**
* creating roda ANTES do INSERT — é o único lugar onde dá para alterar
* o model sem disparar um UPDATE extra. Em `created` já seria tarde: o
* registro estaria salvo, e mudá-lo exigiria outro save().
*/
public function creating(Produto $produto): void
{
if (blank($produto->slug)) {
$produto->slug = $this->slugUnico($produto->nome);
}
}
/**
* Renomear o produto NÃO regenera o slug: URL publicada e indexada não
* pode mudar sozinha. A troca é ação explícita de quem edita.
*/
public function updating(Produto $produto): void
{
if ($produto->isDirty('slug') && blank($produto->slug)) {
$produto->slug = $this->slugUnico($produto->nome, $produto->id);
}
}
/**
* O soft delete dispara `deleting` e `deleted` — não `forceDeleted`.
* Aqui, desativar os itens de pedidos que ainda estão em aberto.
*/
public function deleted(Produto $produto): void
{
// isForceDeleting() distingue os dois: no force delete a linha some
// e não faz sentido "desativar" nada.
if ($produto->isForceDeleting()) {
return;
}
$afetados = DB::transaction(function () use ($produto): int {
$pedidosPendentes = DB::table('pedidos')
->whereIn('status', [
StatusPedido::Rascunho->value,
StatusPedido::Confirmado->value,
])
->whereNull('deleted_at')
->pluck('id');
if ($pedidosPendentes->isEmpty()) {
return 0;
}
return DB::table('pedido_produto')
->where('produto_id', $produto->id)
->whereIn('pedido_id', $pedidosPendentes)
->update([
'ativo' => false,
'updated_at' => now(),
]);
});
// Log com contexto estruturado: o dia em que alguém perguntar
// "por que este item sumiu do meu pedido", a resposta está aqui.
Log::info('Produto excluído: itens de pedidos pendentes desativados', [
'produto_id' => $produto->id,
'produto_nome' => $produto->nome,
'itens_desativados' => $afetados,
]);
}
public function restored(Produto $produto): void
{
// Restaurar desfaz o efeito — senão o produto volta e os itens
// continuam desativados para sempre.
DB::table('pedido_produto')
->where('produto_id', $produto->id)
->update(['ativo' => true, 'updated_at' => now()]);
}
/**
* Slug único com sufixo numérico: "camisa-azul", "camisa-azul-2"…
* A corrida entre dois cadastros simultâneos ainda existe — quem
* resolve de verdade é o UNIQUE da migration, que devolve erro em vez
* de gravar duplicado.
*/
private function slugUnico(string $nome, ?int $ignorarId = null): string
{
$base = Str::slug($nome);
$slug = $base;
$contador = 1;
while (
Produto::withTrashed()
->where('slug', $slug)
->when($ignorarId !== null, fn ($c) => $c->whereKeyNot($ignorarId))
->exists()
) {
$slug = $base . '-' . ++$contador;
}
return $slug;
}
}
// ---------------------------------------- app/Models/Produto.php (registro)
declare(strict_types=1);
namespace App\Models;
use App\Observers\ProdutoObserver;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
// O atributo (Laravel 11+) substitui o registro manual no
// AppServiceProvider::boot(). A vantagem é a descoberta: quem abre o model
// vê imediatamente que existe um observer atuando nele.
#[ObservedBy([ProdutoObserver::class])]
class Produto extends Model
{
use HasFactory;
use SoftDeletes;
protected $fillable = ['categoria_id', 'nome', 'slug', 'descricao', 'preco', 'estoque', 'ativo'];
protected function casts(): array
{
return ['preco' => 'decimal:2', 'ativo' => 'boolean', 'estoque' => 'integer'];
}
}
// ---------------------------------------- tests/Feature/ProdutoObserverTest.php
declare(strict_types=1);
namespace Tests\Feature;
use App\Models\Pedido;
use App\Models\Produto;
use App\Models\StatusPedido;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class ProdutoObserverTest extends TestCase
{
use RefreshDatabase;
public function test_slug_e_gerado_a_partir_do_nome(): void
{
$produto = Produto::factory()->create(['nome' => 'Camisa Azul', 'slug' => null]);
$this->assertSame('camisa-azul', $produto->slug);
}
public function test_slug_repetido_ganha_sufixo(): void
{
Produto::factory()->create(['nome' => 'Camisa Azul', 'slug' => null]);
$segundo = Produto::factory()->create(['nome' => 'Camisa Azul', 'slug' => null]);
$this->assertSame('camisa-azul-2', $segundo->slug);
}
public function test_renomear_nao_muda_o_slug(): void
{
$produto = Produto::factory()->create(['nome' => 'Camisa Azul', 'slug' => null]);
$produto->update(['nome' => 'Camisa Vermelha']);
// URL publicada não muda sozinha.
$this->assertSame('camisa-azul', $produto->fresh()->slug);
}
public function test_soft_delete_desativa_itens_de_pedidos_pendentes(): void
{
$produto = Produto::factory()->create();
$pendente = Pedido::factory()->create(['status' => StatusPedido::Confirmado]);
$entregue = Pedido::factory()->create(['status' => StatusPedido::Entregue]);
foreach ([$pendente, $entregue] as $pedido) {
$pedido->produtos()->attach($produto->id, [
'quantidade' => 1, 'preco_unitario' => 10.0, 'ativo' => true,
]);
}
$produto->delete();
// O pendente foi desativado…
$this->assertSame(0, (int) DB::table('pedido_produto')
->where('pedido_id', $pendente->id)->value('ativo'));
// …e o entregue, não: pedido fechado é histórico, não se mexe.
$this->assertSame(1, (int) DB::table('pedido_produto')
->where('pedido_id', $entregue->id)->value('ativo'));
}
public function test_restaurar_reativa_os_itens(): void
{
$produto = Produto::factory()->create();
$pedido = Pedido::factory()->create(['status' => StatusPedido::Confirmado]);
$pedido->produtos()->attach($produto->id, [
'quantidade' => 1, 'preco_unitario' => 10.0, 'ativo' => true,
]);
$produto->delete();
$produto->restore();
$this->assertSame(1, (int) DB::table('pedido_produto')
->where('pedido_id', $pedido->id)->value('ativo'));
}
public function test_delete_em_massa_nao_dispara_o_observer(): void
{
$produto = Produto::factory()->create();
$pedido = Pedido::factory()->create(['status' => StatusPedido::Confirmado]);
$pedido->produtos()->attach($produto->id, [
'quantidade' => 1, 'preco_unitario' => 10.0, 'ativo' => true,
]);
// ATENÇÃO: operação em massa NÃO carrega os models e NÃO dispara
// evento nenhum. Este teste documenta a limitação em vez de fingir
// que ela não existe — o item continua ATIVO depois do delete.
Produto::query()->whereKey($produto->id)->delete();
$this->assertSame(1, (int) DB::table('pedido_produto')
->where('pedido_id', $pedido->id)->value('ativo'));
// Para disparar, é preciso carregar os models:
// Produto::query()->where(...)->each(fn ($p) => $p->delete());
}
}
Exercício 5
O Produto deste artigo tem duas coisas que só existem na camada do model: o hook creating que gera o slug e o trait SoftDeletes. Descubra o que sobrevive quando a escrita não passa por uma instância. Numa base com dois produtos, execute e observe: Produto::where('estoque', '>', 0)->update(['estoque' => 1]) dispara o evento updating? E mexe no updated_at? Produto::insert(['nome' => 'Mouse', 'estoque' => 1]) gera o slug? Produto::where('id', 2)->delete() apaga a linha ou só marca? E DB::table('produtos')->where('id', 1)->delete()?
Ver resposta
✓ Resposta: São três camadas, não duas, e a confusão nasce de tratá-las como se fossem a mesma. Medido no Eloquent 13.32 com SQLite:
<?php
// 1. Eloquent Builder — Produto::where(...)
Produto::where('estoque', '>', 0)->update(['estoque' => 1]);
// eventos de model: NENHUM ← o hook do slug, observers e `updating` não rodam
// updated_at: ATUALIZADO ← o Eloquent Builder cuida disso sozinho
$produto->estoque = 2;
$produto->save();
// eventos de model: 1 ← só o caminho pela instância dispara
// 2. insert() é repassado ao Query Builder, mesmo chamado no model
Produto::insert(['nome' => 'Mouse', 'estoque' => 1]);
// slug: NULL ← o `creating` não existe nesse caminho
// created_at: NULL ← nem os timestamps
// 3. delete() depende de QUEM é o builder
Produto::where('id', 2)->delete();
// linha no banco: SIM | deleted_at: preenchido ← SoftDeletes respeitado
DB::table('produtos')->where('id', 1)->delete();
// linha no banco: NÃO ← apagou de verdade
A linha divisória é a instância. Eventos de model, accessors, mutators, $casts e hooks de booted() pertencem ao objeto: tudo isso mora em save(), create(), update() de instância e delete() de instância. O Produto::where(...) devolve um Eloquent Builder, que ainda sabe do model — por isso respeita SoftDeletes e mexe no updated_at —, mas escreve direto no banco sem materializar objeto nenhum, e portanto sem eventos. Já o DB::table(...) é o Query Builder puro: não sabe que Produto existe.
Isso explica dois chamados clássicos. O primeiro é o slug que aparece vazio só nas linhas vindas de importação em massa — quem escreveu usou insert() pela velocidade e perdeu o hook junto. O segundo é o registro que "voltou do nada" depois de uma limpeza: alguém rodou DB::table()->delete() num model com SoftDeletes, e o que era para ser reversível virou perda definitiva. Quando a escrita em massa é mesmo necessária e os hooks importam, o caminho é chunkById() com save() dentro, ou a regra passa a viver no banco — trigger ou DEFAULT —, não num evento que metade dos caminhos não executa.