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' => '',
'strict' => true, // equivale a STRICT_ALL_TABLES
]);
// 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');
}
}
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
}
// HasManyThrough — produto tem muitos itens de pedido via pedidos
public function itensDePedido(): HasManyThrough
{
return $this->hasManyThrough(
PedidoItem::class, // modelo final
Pedido::class, // modelo intermediário
'produto_id', // FK de Pedido em Produto
'pedido_id', // FK de PedidoItem em Pedido
);
}
}
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)
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 — pode cancelar a operação retornando false
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
Resumo do artigo
| Conceito | O que aprendemos |
|---|---|
| Configuração | Eloquent standalone via illuminate/database — sem Laravel |
$fillable / $hidden |
Controla quais colunas podem ser preenchidas/expostas |
$casts |
Tipagem automática — boolean, datetime, Enum PHP 8.1 |
| Accessors / Mutators | Transformam valores ao ler ou gravar — via Attribute::make() |
hasMany / belongsTo |
Relacionamentos 1:N — FK configurável |
belongsToMany |
Muitos para muitos com tabela pivot |
| Eager Loading | with(['rel.subrel']) — elimina o problema N+1 |
| Escopo global | Aplica filtro em todas as queries do modelo automaticamente |
| Escopo local | scopeXxx() → chamado como ::xxx() — queries reutilizáveis |
| Observer | Agrupa handlers de eventos do ciclo de vida do modelo |
Exercício da semana
-
Crie o modelo
Produtocom os casts corretos parapreco(decimal),ativo(boolean) ecriado_em(datetime). Adicione um accessorprecoFormatadoque retorna"R$ 1.350,00"e um mutator que normaliza o nome do produto (trim + ucwords) antes de salvar. -
Implemente os relacionamentos:
Produtopertence aCategoria;Categoriatem muitosProduto;Produtotem muitosTag(muitos-para-muitos com tabelaproduto_tags). Demonstre eager loading comwith(['categoria', 'tags']). -
Adicione ao modelo
Produtoos escopos locais:ativo(),emEstoque(),naFaixaDePreco(float $min, float $max)emais Vendidos(int $limite)(que usa subquery contandopedido_itens). Encadeie todos numa única query. -
Crie um
UsuarioObservercom handlers para:creating(gera UUID v4 e armazena emuuid),created(registra log),updated(notifica se email mudou) edeleting(bloqueia exclusão de admins). Registre e teste todos os eventos. -
Desafio: implemente um escopo global
EmpresaScopeque filtra todos os modelos porempresa_idda sessão. Crie uma traitPertenceAEmpresaque adiciona o escopo automaticamente viabooted(). Aplique a trait emProduto,PedidoeUsuario, e demonstre como desabilitá-lo pontualmente comwithoutGlobalScope().
Referências
- 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