PHP

ORM com Eloquent Já leu

10 min de leitura

ORM com Eloquent
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 OR

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

  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.

  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']).

  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.

  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.

  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().


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
Comentários

Mais em PHP

Estruturas de Controle
Estruturas de Controle

Um programa que executa sempre as mesmas instru&ccedil;&otilde;es na mesma or...

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

At&eacute; agora todos os exemplos couberam em um &uacute;nico arquivo. Na pr...

Upload de Arquivos
Upload de Arquivos

Upload de arquivos parece simples — e na superfície é. Mas quando você consid...