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

Namespaces, Autoloading, PSR-4 e Composer
Namespaces, Autoloading, PSR-4 e Composer

Sem namespace, dois arquivos com a mesma classe brigam pelo mesmo nome. O…

Sessions e Cookies
Sessions e Cookies

O que fica guardado no navegador, o que fica no servidor e por que trocar isso…

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

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