ORM com Eloquent

ORM com Eloquent

Relacionamento em uma linha, cast automático de tipos, escopo que se encadeia como prosa e observer reagindo ao ciclo de vida do modelo — o Eloquent fora do Laravel, via illuminate/database, com atenção a cada convenção que o schema do projeto não segue.
PHP

• • 20 min de leitura

ORM (Object-Relational Mapping) é a camada que traduz automaticamente entre objetos PHP e tabelas relacionais. Em vez de escrever SQL para cada operação, você trabalha com objetos que representam linhas do banco — e o ORM cuida do SQL nos bastidores.

O Eloquent é o ORM do Laravel, mas pode ser usado em qualquer projeto PHP via pacote standalone illuminate/database. É um dos ORMs mais expressivos disponíveis: relacionamentos declarados em uma linha, escopos de query reutilizáveis, eventos de modelo para hooks automáticos, e uma interface fluente que lê quase como inglês.

Neste artigo você aprende Eloquent do jeito que projetos reais usam: não apenas o básico de find() e save(), mas relacionamentos polimórficos, escopos globais e locais, eventos e observers, e como estruturar modelos para um domínio real.

Configuração standalone do Eloquent

Eloquent pode ser usado fora do Laravel instalando apenas o pacote illuminate/database:

<?php
declare(strict_types=1);

// composer require illuminate/database illuminate/events

use Illuminate\Database\Capsule\Manager as Capsule;
use Illuminate\Events\Dispatcher;
use Illuminate\Container\Container;

$capsule = new Capsule();

$capsule->addConnection([
    'driver'    => 'mysql',
    'host'      => getenv('DB_HOST')     ?: 'localhost',
    'database'  => getenv('DB_DATABASE') ?: 'meuapp',
    'username'  => getenv('DB_USERNAME') ?: 'root',
    'password'  => getenv('DB_PASSWORD') ?: '',
    'charset'   => 'utf8mb4',
    'collation' => 'utf8mb4_unicode_ci',
    'prefix'    => '',
    // Com strict, o Laravel define o sql_mode da sessão como ONLY_FULL_GROUP_BY,
    // STRICT_TRANS_TABLES, NO_ZERO_IN_DATE, NO_ZERO_DATE, ERROR_FOR_DIVISION_BY_ZERO
    // e NO_ENGINE_SUBSTITUTION. Repare: STRICT_TRANS_TABLES, não STRICT_ALL_TABLES
    // — o rigor vale nas tabelas transacionais, InnoDB, e não em todas.
    'strict'    => true,
]);

// Habilita eventos de modelo (necessário para observers e timestamps automáticos)
$capsule->setEventDispatcher(new Dispatcher(new Container()));

// Torna a conexão disponível globalmente
$capsule->setAsGlobal();
$capsule->bootEloquent();

Modelos básicos e convenções

Eloquent segue convenções sobre configuração: se a tabela se chama usuarios, o modelo se chama Usuario. Se a chave primária é id, não há nada para configurar. Você apenas declara a classe e o Eloquent cuida do resto.

<?php
declare(strict_types=1);

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;

class Usuario extends Model
{
    // Convenção: tabela 'usuarios' (plural do nome da classe em snake_case)
    // Se a tabela tiver nome diferente:
    protected $table = 'usuarios';

    // Colunas que podem ser preenchidas via fill() ou create()
    protected $fillable = ['nome', 'email', 'senha_hash', 'perfil'];

    // Colunas nunca retornadas em arrays/JSON
    protected $hidden = ['senha_hash'];

    // Cast automático de tipos — PHP recebe o tipo correto, não string
    protected $casts = [
        'ativo'       => 'boolean',
        'criado_em'   => 'datetime',
        'ultimo_login'=> 'datetime',
        'perfil'      => \App\Enums\PerfilUsuario::class, // PHP 8.1+ Enum cast
    ];

    // Nomes customizados para timestamps (padrão: created_at, updated_at)
    const CREATED_AT = 'criado_em';
    const UPDATED_AT = 'atualizado_em';

    // Soft Delete — adiciona deleted_at em vez de apagar fisicamente
    use SoftDeletes;
    const DELETED_AT = 'deletado_em';

    // ── Accessors — transforma o valor ao LER ────────────────────────
    // PHP 8+ syntax: retorna Attribute
    protected function nomeCompleto(): \Illuminate\Database\Eloquent\Casts\Attribute
    {
        return \Illuminate\Database\Eloquent\Casts\Attribute::make(
            get: fn () => strtoupper($this->nome[0]) . substr($this->nome, 1),
        );
    }

    // ── Mutators — transforma o valor ao GRAVAR ──────────────────────
    protected function senhaHash(): \Illuminate\Database\Eloquent\Casts\Attribute
    {
        return \Illuminate\Database\Eloquent\Casts\Attribute::make(
            set: fn (string $valor) => password_hash($valor, PASSWORD_ARGON2ID),
        );
    }
}

// ── Operações básicas ─────────────────────────────────────────────────

// Criar
$usuario = Usuario::create([
    'nome'       => 'Ana Lima',
    'email'      => 'ana@email.com',
    'senha_hash' => 'minhasenha123', // mutator aplica password_hash automaticamente
    'perfil'     => 'usuario',
]);

// Buscar
$usuario = Usuario::find(1);                // null se não encontrar
$usuario = Usuario::findOrFail(99);         // lança ModelNotFoundException
$usuario = Usuario::where('email', 'ana@email.com')->first();
$ativos  = Usuario::where('ativo', true)->orderBy('nome')->get();

// Atualizar
$usuario->nome = 'Ana Souza';
$usuario->save();
// ou: Usuario::where('id', 1)->update(['nome' => 'Ana Souza']);

// Deletar (soft delete se trait estiver presente)
$usuario->delete();                          // marca deletado_em
$usuario->forceDelete();                     // apaga fisicamente
Usuario::withTrashed()->where('id', 1)->restore(); // restaura

Relacionamentos

Relacionamentos Eloquent são declarados como métodos no modelo e retornam objetos de relacionamento. O SQL de JOIN é gerado automaticamente quando o relacionamento é acessado.

<?php
declare(strict_types=1);

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\{HasMany, BelongsTo, BelongsToMany, HasManyThrough};

class Categoria extends Model
{
    protected $table    = 'categorias';
    public    $timestamps = false;
    protected $fillable = ['nome', 'slug'];

    // Um para muitos — uma categoria tem muitos produtos
    public function produtos(): HasMany
    {
        return $this->hasMany(Produto::class, 'categoria_id');
    }

    // Com escopos — apenas produtos ativos desta categoria
    public function produtosAtivos(): HasMany
    {
        return $this->hasMany(Produto::class, 'categoria_id')
                    ->where('ativo', true)
                    ->orderBy('nome');
    }

    // HasManyThrough — os itens de pedido desta categoria, passando por produtos.
    // As duas colunas são as FKs do caminho, na ordem em que ele é percorrido:
    // categoria_id liga Produto a Categoria, produto_id liga PedidoItem a Produto.
    // O atalho só existe se essa corrente existir no schema — não dá para
    // declarar um through entre duas tabelas que não se alcançam.
    public function itensDePedido(): HasManyThrough
    {
        return $this->hasManyThrough(
            PedidoItem::class, // modelo final
            Produto::class,    // modelo intermediário
            'categoria_id',    // FK de Produto apontando para Categoria
            'produto_id',      // FK de PedidoItem apontando para Produto
        );
    }
}

class Produto extends Model
{
    protected $table    = 'produtos';
    protected $fillable = ['categoria_id', 'nome', 'preco', 'estoque', 'ativo'];
    protected $casts    = [
        'preco'  => 'decimal:2',
        'ativo'  => 'boolean',
        'criado_em' => 'datetime',
    ];
    const CREATED_AT = 'criado_em';
    const UPDATED_AT = null; // sem updated_at

    // Muitos para um — produto pertence a uma categoria
    public function categoria(): BelongsTo
    {
        return $this->belongsTo(Categoria::class, 'categoria_id');
    }

    // Muitos para muitos com pivot — produto tem muitas tags
    public function tags(): BelongsToMany
    {
        return $this->belongsToMany(Tag::class, 'produto_tags', 'produto_id', 'tag_id')
                    ->withTimestamps(); // pivot tem created_at/updated_at
    }

}

class Pedido extends Model
{
    protected $table    = 'pedidos';
    protected $fillable = ['usuario_id', 'total', 'status'];
    const CREATED_AT    = 'criado_em';
    const UPDATED_AT    = null;

    public function usuario(): BelongsTo
    {
        return $this->belongsTo(Usuario::class);
    }

    public function itens(): HasMany
    {
        return $this->hasMany(PedidoItem::class);
    }

    // Eager loading automático — sempre carrega estes relacionamentos
    protected $with = ['itens'];
}

// ── Uso dos relacionamentos ───────────────────────────────────────────

// Eager loading — evita N+1 queries
$pedidos = Pedido::with(['usuario', 'itens.produto', 'itens.produto.categoria'])
                 ->where('status', 'aprovado')
                 ->get();

foreach ($pedidos as $pedido) {
    echo $pedido->usuario->nome . "\n";      // sem query extra
    foreach ($pedido->itens as $item) {
        echo $item->produto->nome . "\n";    // sem query extra
        echo $item->produto->categoria->nome . "\n"; // sem query extra
    }
}

// Relacionamento muitos-para-muitos com pivot
$produto = Produto::find(1);
$produto->tags()->attach([1, 2, 3]);         // adiciona tags
$produto->tags()->detach(2);                  // remove tag 2
$produto->tags()->sync([1, 4, 5]);           // substitui todas as tags

Escopos — queries reutilizáveis

Escopos encapsulam condições de query frequentes para que possam ser reutilizadas em qualquer lugar com uma chamada de método.

<?php
declare(strict_types=1);

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;

class Produto extends Model
{
    // ── Escopo global — aplica automaticamente em TODAS as queries ────
    // Aqui: sempre filtra por empresa_id do usuário logado (multitenancy).
    // Atenção: auth() e \Log:: são helpers do Laravel e não existem na
    // instalação standalone do começo do artigo. Fora do framework, troque
    // pela sua própria fonte de contexto — sessão, container ou request.
    protected static function booted(): void
    {
        static::addGlobalScope('empresa', function (Builder $query) {
            if ($empresaId = auth()?->user()?->empresa_id) {
                $query->where('empresa_id', $empresaId);
            }
        });
    }

    // ── Escopos locais — ativados manualmente por nome ─────────────────
    // Convenção: método scopeXxx() → chamado como ::xxx()

    public function scopeAtivo(Builder $query): Builder
    {
        return $query->where('ativo', true);
    }

    public function scopeEmEstoque(Builder $query): Builder
    {
        return $query->where('estoque', '>', 0);
    }

    public function scopeNaCategoria(Builder $query, string $slug): Builder
    {
        return $query->whereHas('categoria', fn($q) => $q->where('slug', $slug));
    }

    public function scopeNaFaixaDePreco(Builder $query, float $min, float $max): Builder
    {
        return $query->whereBetween('preco', [$min, $max]);
    }

    public function scopeOrdenado(Builder $query, string $col = 'nome', string $dir = 'asc'): Builder
    {
        return $query->orderBy($col, $dir);
    }
}

// ── Encadeando escopos locais — legível como prosa ────────────────────
$produtos = Produto::ativo()
                   ->emEstoque()
                   ->naCategoria('eletronicos')
                   ->naFaixaDePreco(100, 2000)
                   ->ordenado('preco', 'asc')
                   ->paginate(20);

// Desabilitando escopo global pontualmente
$todosSemFiltroDeEmpresa = Produto::withoutGlobalScope('empresa')->get();

Eventos e Observers

Eloquent dispara eventos em pontos específicos do ciclo de vida de um modelo: creating, created, updating, updated, saving, saved, deleting, deleted. Observers agrupam handlers desses eventos em uma classe dedicada.

<?php
declare(strict_types=1);

use Illuminate\Database\Eloquent\Model;

// ── Observer — agrupa todos os handlers de eventos de um modelo ───────
class PedidoObserver
{
    // Antes de criar. Para abortar a operação, o handler precisa retornar
    // false — e aí teria de ser tipado como bool, como o deleting() abaixo,
    // e não void.
    public function creating(Pedido $pedido): void
    {
        // Gera número de pedido único antes de persistir
        $pedido->numero = 'PED-' . strtoupper(uniqid());
    }

    // Após criar
    public function created(Pedido $pedido): void
    {
        // Notifica o time via fila (sem bloquear a requisição)
        // NotificarNovoPedidoJob::dispatch($pedido->id);
        \Log::info("Novo pedido #{$pedido->numero} criado", ['id' => $pedido->id]);
    }

    // Após atualizar — reage a mudanças de status
    public function updated(Pedido $pedido): void
    {
        if ($pedido->wasChanged('status')) {
            $anterior = $pedido->getOriginal('status');
            $novo     = $pedido->status;
            \Log::info("Pedido #{$pedido->numero}: {$anterior} → {$novo}");

            // Dispara ação específica por status
            match ($novo) {
                'aprovado'  => $this->aoAprovar($pedido),
                'cancelado' => $this->aoCancelar($pedido),
                default     => null,
            };
        }
    }

    // Antes de deletar — valida se pode ser excluído
    public function deleting(Pedido $pedido): bool
    {
        if ($pedido->status === 'entregue') {
            throw new \RuntimeException(
                "Pedidos entregues não podem ser excluídos: #{$pedido->numero}"
            );
        }
        return true;
    }

    private function aoAprovar(Pedido $pedido): void
    {
        // EnviarEmailConfirmacaoJob::dispatch($pedido);
    }

    private function aoCancelar(Pedido $pedido): void
    {
        // Restaura estoque dos itens cancelados
        foreach ($pedido->itens as $item) {
            $item->produto->increment('estoque', $item->quantidade);
        }
    }
}

// ── Registrar o observer — normalmente em AppServiceProvider ──────────
Pedido::observe(PedidoObserver::class);

// Agora todas as operações em Pedido disparam o observer automaticamente
$pedido = Pedido::create([
    'usuario_id' => 1,
    'total'      => 350.00,
    'status'     => 'pendente',
]);
// creating → número gerado automaticamente
// created  → log registrado

$pedido->update(['status' => 'aprovado']);
// updated → log + email de confirmação

A troca que o Eloquent propõe é clara: menos SQL escrito à mão em troca de convenções que você precisa conhecer. Enquanto o schema segue o que ele espera, um relacionamento cabe em uma linha e o with() resolve o N+1 antes que ele apareça no log. Quando o schema foge do padrão — e o deste artigo foge, com criado_em no lugar de created_at — cada desvio precisa ser declarado, e é aí que mora a maior parte dos enganos.

Dois pontos merecem o seu ceticismo. Um hasManyThrough só é possível quando as chaves estrangeiras formam mesmo o caminho entre as duas pontas; declarar o atalho entre tabelas que não se alcançam gera uma query que nunca devolve linha. E os helpers globais do Laravel, auth() e Log entre eles, não vieram junto com o illuminate/database: no projeto standalone, quem fornece o contexto é você.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Crie o modelo Produto com os casts corretos para preco (decimal), ativo (boolean) e criado_em (datetime). Adicione um accessor precoFormatado que retorna "R$ 1.350,00" e um mutator que normaliza o nome do produto (trim + ucwords) antes de salvar.

Ver resposta

✓ Resposta: O cast decimal:2 devolve string, não float — de propósito, para não perder precisão. Some com + e você volta ao float; para dinheiro, converta para centavos (inteiro) antes de qualquer conta, como faz o precoEmCentavos.

<?php

declare(strict_types=1);

namespace App\Models;

use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;

class Produto extends Model
{
    protected $table = 'produtos';

    protected $fillable = ['categoria_id', 'nome', 'preco', 'estoque', 'ativo'];

    // Sem os casts, `preco` volta string "1350.00" e `ativo` volta int 1 —
    // e aí `$produto->ativo === true` é false, o que quebra comparação estrita.
    protected function casts(): array
    {
        return [
            'preco'     => 'decimal:2',
            'ativo'     => 'boolean',
            'estoque'   => 'integer',
            'criado_em' => 'datetime',
        ];
    }

    // Accessor: propriedade virtual, não existe coluna precoFormatado.
    protected function precoFormatado(): Attribute
    {
        return Attribute::make(
            get: fn (mixed $valor, array $atributos): string =>
                'R$ ' . number_format((float) $atributos['preco'], 2, ',', '.'),
        );
    }

    // Mutator: normaliza na ESCRITA. O banco nunca recebe "  teclado  ".
    protected function nome(): Attribute
    {
        return Attribute::make(
            set: fn (string $valor): string => ucwords(
                mb_strtolower(preg_replace('/\s+/u', ' ', trim($valor)), 'UTF-8')
            ),
        );
    }

    // Accessor e mutator na mesma propriedade: guarda centavos, expõe reais.
    protected function precoEmCentavos(): Attribute
    {
        return Attribute::make(
            get: fn (mixed $v, array $attr): int => (int) round((float) $attr['preco'] * 100),
            set: fn (int $centavos): array => ['preco' => $centavos / 100],
        );
    }
}

// ---------- Uso -----------------------------------------------------------
$produto = Produto::create([
    'categoria_id' => 1,
    'nome'         => '   teclado    MECÂNICO rgb  ',
    'preco'        => 1350.00,
    'estoque'      => 12,
    'ativo'        => true,
]);

echo $produto->nome, PHP_EOL;             // Teclado Mecânico Rgb
echo $produto->precoFormatado, PHP_EOL;   // R$ 1.350,00
echo $produto->precoEmCentavos, PHP_EOL;  // 135000

var_dump($produto->ativo);                 // bool(true), não int(1)
var_dump($produto->criado_em);             // Carbon\Carbon, não string

// Para o accessor virtual aparecer no JSON da API:
// protected $appends = ['preco_formatado'];

Exercício 2

Implemente os relacionamentos: Produto pertence a Categoria; Categoria tem muitos Produto; Produto tem muitos Tag (muitos-para-muitos com tabela produto_tags). Demonstre eager loading com with(['categoria', 'tags']).

Ver resposta

✓ Resposta: preventLazyLoading() é o item mais útil daqui: o N+1 não dá erro nenhum — só deixa a página lenta em produção, com dados de verdade. Transformando em exceção no ambiente local, ele aparece no dia em que foi escrito.

<?php

declare(strict_types=1);

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Categoria extends Model
{
    protected $table = 'categorias';
    protected $fillable = ['nome', 'slug'];

    public function produtos(): HasMany
    {
        return $this->hasMany(Produto::class, 'categoria_id');
    }

    // Atalho para o que a listagem quase sempre quer.
    public function produtosAtivos(): HasMany
    {
        return $this->produtos()->where('ativo', true);
    }
}

class Tag extends Model
{
    protected $table = 'tags';
    protected $fillable = ['nome'];

    public function produtos(): BelongsToMany
    {
        return $this->belongsToMany(Produto::class, 'produto_tags');
    }
}

class Produto extends Model
{
    public function categoria(): BelongsTo
    {
        // 2º arg = FK nesta tabela; 3º = PK na tabela de destino.
        return $this->belongsTo(Categoria::class, 'categoria_id', 'id');
    }

    public function tags(): BelongsToMany
    {
        return $this->belongsToMany(Tag::class, 'produto_tags', 'produto_id', 'tag_id')
                    ->withTimestamps()
                    ->withPivot('destaque');   // coluna extra da tabela pivô
    }
}

// ---------- O problema N+1 -----------------------------------------------
// SEM eager loading: 1 query para produtos + 1 por produto para categoria
// + 1 por produto para tags. Com 100 produtos → 201 queries.
foreach (Produto::all() as $produto) {
    echo $produto->categoria->nome;          // ← query aqui, a cada volta
    echo $produto->tags->pluck('nome')->implode(', ');
}

// COM eager loading: 3 queries no total, independente da quantidade.
$produtos = Produto::with(['categoria', 'tags'])->get();

foreach ($produtos as $produto) {
    printf("%s [%s] — %s%s",
        $produto->nome,
        $produto->categoria->nome,
        $produto->tags->pluck('nome')->implode(', '),
        PHP_EOL,
    );
}
// SELECT * FROM produtos
// SELECT * FROM categorias WHERE id IN (1, 2, 3)
// SELECT * FROM tags INNER JOIN produto_tags ... WHERE produto_id IN (...)

// Eager loading com restrição e seleção de colunas:
$produtos = Produto::with([
    'categoria:id,nome',                          // só o necessário
    'tags' => fn ($q) => $q->where('destaque', 1)->orderBy('nome'),
])->get();

// Aninhado, e contagem sem carregar os registros:
$categorias = Categoria::with('produtos.tags')
                       ->withCount('produtos')
                       ->get();

foreach ($categorias as $c) {
    echo "{$c->nome}: {$c->produtos_count} produto(s)", PHP_EOL;
}

// Carregar depois, sobre uma coleção que já existe:
$produtos->loadMissing('categoria');

// Em desenvolvimento, isto transforma N+1 em exceção (AppServiceProvider):
// Model::preventLazyLoading(! app()->isProduction());

Exercício 3

Adicione ao modelo Produto os escopos locais: ativo(), emEstoque(), naFaixaDePreco(float $min, float $max) e mais Vendidos(int $limite) (que usa subquery contando pedido_itens). Encadeie todos numa única query.

Ver resposta

✓ Resposta: Escopo que devolve o Builder é o que permite encadear em qualquer ordem. Um escopo que faz $query->where(...) sem return ainda funciona por efeito colateral, mas quebra na hora em que alguém tenta encadear a partir dele.

<?php

declare(strict_types=1);

namespace App\Models;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;

class Produto extends Model
{
    // Prefixo `scope` + nome: chama-se como ->ativo(), sem o prefixo.
    // Todo escopo recebe o Builder e devolve o Builder — é isso que permite
    // encadear na ordem que quiser.
    public function scopeAtivo(Builder $query): Builder
    {
        return $query->where('ativo', true);
    }

    public function scopeEmEstoque(Builder $query, int $minimo = 1): Builder
    {
        return $query->where('estoque', '>=', $minimo);
    }

    public function scopeNaFaixaDePreco(Builder $query, float $min, float $max): Builder
    {
        if ($min > $max) {
            [$min, $max] = [$max, $min];   // tolera a faixa invertida
        }

        return $query->whereBetween('preco', [$min, $max]);
    }

    /** Ordena pelos mais vendidos usando subquery de contagem. */
    public function scopeMaisVendidos(Builder $query, int $limite = 10): Builder
    {
        return $query
            ->withCount(['itensPedido as unidades_vendidas' => fn ($q) =>
                $q->select(\DB::raw('COALESCE(SUM(quantidade), 0)'))
            ])
            // orderByDesc no alias da subquery: o banco ordena pelo total
            // agregado sem trazer os itens para o PHP.
            ->orderByDesc('unidades_vendidas')
            ->limit($limite);
    }

    public function itensPedido()
    {
        return $this->hasMany(PedidoItem::class, 'produto_id');
    }
}

// ---------- Encadeando tudo numa query só --------------------------------
$destaques = Produto::query()
    ->ativo()
    ->emEstoque(5)
    ->naFaixaDePreco(50.00, 500.00)
    ->maisVendidos(10)
    ->with('categoria:id,nome')
    ->get();

foreach ($destaques as $p) {
    printf("%-30s %10s  %4d vendidos%s",
           $p->nome, $p->precoFormatado, $p->unidades_vendidas, PHP_EOL);
}

// SELECT produtos.*,
//        (SELECT COALESCE(SUM(quantidade), 0) FROM pedido_itens
//          WHERE pedido_itens.produto_id = produtos.id) AS unidades_vendidas
//   FROM produtos
//  WHERE ativo = 1 AND estoque >= 5 AND preco BETWEEN 50 AND 500
//  ORDER BY unidades_vendidas DESC
//  LIMIT 10

// Escopo também funciona em relacionamento:
$categoria->produtos()->ativo()->emEstoque()->get();

Exercício 4

Crie um UsuarioObserver com handlers para: creating (gera UUID v4 e armazena em uuid), created (registra log), updated (notifica se email mudou) e deleting (bloqueia exclusão de admins). Registre e teste todos os eventos.

Ver resposta

✓ Resposta: A armadilha dos observers: eles só disparam quando o evento passa pelo modelo. Usuario::where(...)->delete() ou ->update() em massa vão direto ao banco e não acionam deleting nem updated — o admin seria apagado sem passar pelo bloqueio.

<?php

declare(strict_types=1);

namespace App\Observers;

use App\Models\Usuario;
use Illuminate\Support\Str;

class UsuarioObserver
{
    /** Antes do INSERT: ainda dá para alterar atributos. */
    public function creating(Usuario $usuario): void
    {
        // ??= respeita um uuid que já tenha vindo preenchido.
        $usuario->uuid ??= (string) Str::uuid();

        $usuario->email = mb_strtolower(trim($usuario->email));
    }

    /** Depois do INSERT: o id já existe. */
    public function created(Usuario $usuario): void
    {
        \Log::info('Usuário criado', [
            'id'    => $usuario->id,
            'uuid'  => $usuario->uuid,
            'email' => $usuario->email,
        ]);
    }

    public function updated(Usuario $usuario): void
    {
        // wasChanged() olha o que MUDOU no save que acabou de acontecer.
        // (isDirty() é o par dele para "antes de salvar".)
        if ($usuario->wasChanged('email')) {
            $anterior = $usuario->getOriginal('email');

            \Log::warning('E-mail alterado', [
                'usuario_id' => $usuario->id,
                'de'         => $anterior,
                'para'       => $usuario->email,
            ]);

            // Avisa o endereço ANTIGO: é a defesa contra tomada de conta.
            \Mail::to($anterior)->send(new \App\Mail\EmailAlterado($usuario));
        }
    }

    /** Antes do DELETE: retornar false cancela a exclusão. */
    public function deleting(Usuario $usuario): bool
    {
        if ($usuario->papel === 'admin') {
            \Log::alert('Tentativa de excluir admin bloqueada', ['id' => $usuario->id]);

            return false;   // aborta: o delete() devolve false
        }

        return true;
    }
}

// ---------- Registro (AppServiceProvider::boot) --------------------------
use App\Models\Usuario;
use App\Observers\UsuarioObserver;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Usuario::observe(UsuarioObserver::class);
    }
}

// A partir do Laravel 11, dá para declarar no próprio modelo:
// #[ObservedBy([UsuarioObserver::class])]
// class Usuario extends Model { }

// ---------- Testando ------------------------------------------------------
$usuario = Usuario::create([
    'nome'  => 'Ana Souza',
    'email' => '  ANA@EXEMPLO.COM  ',
    'senha_hash' => bcrypt('segredo123'),
]);

echo $usuario->uuid, PHP_EOL;    // gerado no creating
echo $usuario->email, PHP_EOL;   // ana@exemplo.com — normalizado

$usuario->update(['email' => 'ana.souza@exemplo.com']);   // dispara updated

$admin = Usuario::where('papel', 'admin')->first();
var_dump($admin->delete());      // false — bloqueado pelo deleting

Exercício 5

Desafio: implemente um escopo global EmpresaScope que filtra todos os modelos por empresa_id da sessão. Crie uma trait PertenceAEmpresa que adiciona o escopo automaticamente via booted(). Aplique a trait em Produto, Pedido e Usuario, e demonstre como desabilitá-lo pontualmente com withoutGlobalScope().

Ver resposta

✓ Resposta: Dois furos que tornam isolamento por escopo global uma proteção incompleta: DB::table() e SQL cru passam por fora, e sem o creating a escrita cria registro sem tenant. Para dado realmente sensível, o isolamento tem de existir também no banco — conexão ou schema por tenant.

<?php

declare(strict_types=1);

namespace App\Scopes;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;

class EmpresaScope implements Scope
{
    public function apply(Builder $builder, Model $model): void
    {
        $empresaId = auth()->user()?->empresa_id;

        // Sem usuário (fila, comando artisan, seeder) não há tenant: filtrar
        // por null esconderia tudo. Melhor não aplicar e deixar o chamador
        // decidir — mas isso é uma decisão consciente, não um esquecimento.
        if ($empresaId === null) {
            return;
        }

        // qualifyColumn prefixa com a tabela: sem isso, um JOIN entre duas
        // tabelas que têm empresa_id gera "column ambiguous".
        $builder->where($model->qualifyColumn('empresa_id'), $empresaId);
    }
}

// ---------------------------------------- app/Models/Concerns/PertenceAEmpresa.php
declare(strict_types=1);

namespace App\Models\Concerns;

use App\Scopes\EmpresaScope;

trait PertenceAEmpresa
{
    // booted() é chamado pelo Eloquent uma vez por modelo. O nome do método
    // é convenção: `boot` + nome do trait também funciona (bootPertenceAEmpresa).
    public static function bootPertenceAEmpresa(): void
    {
        static::addGlobalScope(new EmpresaScope());

        // O outro lado do escopo: sem isto, o filtro protege a leitura mas
        // a escrita cria registro órfão, sem empresa.
        static::creating(function ($model): void {
            $model->empresa_id ??= auth()->user()?->empresa_id;
        });
    }

    public function empresa()
    {
        return $this->belongsTo(\App\Models\Empresa::class);
    }
}

// ---------------------------------------- nos modelos
class Produto extends Model
{
    use PertenceAEmpresa;
}

class Pedido extends Model
{
    use PertenceAEmpresa;
}

class Usuario extends Model
{
    use PertenceAEmpresa;
}

// ---------------------------------------- uso
// Toda consulta já sai filtrada — ninguém precisa lembrar do where.
$produtos = Produto::all();
// SELECT * FROM produtos WHERE produtos.empresa_id = 7

$pedidos = Pedido::where('status', 'pago')->get();
// SELECT * FROM pedidos WHERE pedidos.empresa_id = 7 AND status = 'pago'

// Desabilitar pontualmente — relatório consolidado do super-admin:
$todos = Produto::withoutGlobalScope(EmpresaScope::class)->get();
// SELECT * FROM produtos

$todosTotal = Produto::withoutGlobalScopes()->count();

// Consultar outra empresa explicitamente:
$deOutra = Produto::withoutGlobalScope(EmpresaScope::class)
                  ->where('empresa_id', 12)
                  ->get();

// ⚠️ O escopo NÃO alcança query builder puro nem SQL cru:
// DB::table('produtos')->get();     ← devolve tudo, de todas as empresas
// DB::select('SELECT * FROM produtos');
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…

PHP 8.x Moderno
PHP 8.x Moderno

Enums, readonly, atributos e fibers mudaram o que se considera código PHP…

PDO Avançado, Prepared Statements e Padrão Repository
PDO Avançado, Prepared Statements e Padrão Repository

Configurar os atributos da conexão, separar sempre os parâmetros da query…