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
- Eloquent ORM — Documentação oficial: https://laravel.com/docs/eloquent
- Eloquent Relationships: https://laravel.com/docs/eloquent-relationships
- Eloquent Mutators & Casting: https://laravel.com/docs/eloquent-mutators
- Eloquent Events: https://laravel.com/docs/eloquent#events
- illuminate/database standalone: https://github.com/illuminate/database
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');