Eloquent ORM com Laravel

Eloquent ORM com Laravel

Migrations, fillable e casts, os quatro tipos de relacionamento, factories e seeders — com o que o Eloquent resolve e o que ele só parece resolver. Traz o cast decimal que devolve string e derruba o accessor, e o seeder que promete dez categorias e entrega sessenta.
PHP

• • 31 min de leitura

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

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.

Comentários

Mais em PHP

Introdução ao Laravel
Introdução ao Laravel

Instalação, estrutura de diretórios, rotas, controllers, Form Requests e o…

Tratamento de Erros e Exceções
Tratamento de Erros e Exceções

O PHP tem dois sistemas para lidar com o que dá errado: os níveis de erro…

Query Builder e Migrations
Query Builder e Migrations

Construir o builder do zero deixa claro por que filtro opcional não se resolve…