Traits Avançados

Traits Avançados

Trait resolve o que herança não alcança, e traz regras próprias: quem vence quando dois traits definem o mesmo método, o que insteadof e as realmente fazem, como exigir que a classe forneça algo com método abstrato, e por que combinar trait e interface muda a natureza da garantia.
PHP

• • 21 min de leitura

Traits são o mecanismo do PHP para reutilização horizontal de código — uma forma de compor comportamentos em classes sem usar herança. Você já viu traits básicos, mas o uso profissional envolve resolver conflitos entre traits, definir requisitos que a classe hospedeira deve satisfazer, compor traits com interfaces para criar contratos verificáveis, e entender as regras de precedência que determinam qual implementação vence quando dois traits definem o mesmo método.

Traits aparecem intensamente em frameworks: o SoftDeletes do Laravel é um trait, o HasTimestamps é um trait, o Authenticatable é um trait. O Symfony usa traits para adicionar comportamentos a entidades Doctrine. Entender as regras avançadas é o que permite criar e depurar esses comportamentos com confiança.

Revisão: o que um trait resolve

Herança resolve o problema de especialização — um Gato é um Animal. Traits resolvem o problema de comportamento compartilhado entre classes não relacionadas: um Pedido, um Produto e um Usuario podem todos ter timestamps, sem serem subclasses de nenhuma classe comum.

<?php
declare(strict_types=1);

// Sem trait — duplicação em toda classe que precisa de timestamps
class Pedido
{
    private \DateTimeImmutable $criadoEm;
    private \DateTimeImmutable $atualizadoEm;

    public function criadoEm(): \DateTimeImmutable    { return $this->criadoEm; }
    public function atualizadoEm(): \DateTimeImmutable { return $this->atualizadoEm; }
    // ... mesmo código se repete em Produto, Usuario, Categoria etc.
}

// Com trait — comportamento definido uma vez, usado em qualquer classe
trait HasTimestamps
{
    private \DateTimeImmutable $criadoEm;
    private \DateTimeImmutable $atualizadoEm;

    public function inicializarTimestamps(): void
    {
        $agora = new \DateTimeImmutable();
        $this->criadoEm     = $agora;
        $this->atualizadoEm = $agora;
    }

    public function tocarTimestamp(): void
    {
        $this->atualizadoEm = new \DateTimeImmutable();
    }

    public function criadoEm(): \DateTimeImmutable    { return $this->criadoEm; }
    public function atualizadoEm(): \DateTimeImmutable { return $this->atualizadoEm; }
}

// Classes completamente não relacionadas compartilham o mesmo comportamento
class Produto { use HasTimestamps; }
class Usuario { use HasTimestamps; }
class Categoria { use HasTimestamps; }

$produto = new Produto();
$produto->inicializarTimestamps();
echo $produto->criadoEm()->format('Y-m-d H:i:s') . "\n";

Regras de precedência

Quando um trait e a classe hospedeira definem o mesmo método, PHP segue uma ordem estrita de precedência: método da classe própria > método do trait > método herdado da classe pai. Isso permite que a classe hospedeira sempre sobrescreva o comportamento do trait, enquanto o trait sobrescreve o comportamento herdado.

<?php
declare(strict_types=1);

trait Saudacao
{
    public function cumprimentar(): string
    {
        return "Olá do trait!";
    }
}

class Base
{
    public function cumprimentar(): string
    {
        return "Olá da classe pai!";
    }
}

// Trait vence sobre a classe pai — mas a classe própria vence sobre o trait
class FilhaComTrait extends Base
{
    use Saudacao;
    // cumprimentar() vem do trait — trait tem precedência sobre Base
}

class FilhaComOverride extends Base
{
    use Saudacao;

    // Método próprio vence sobre o trait
    public function cumprimentar(): string
    {
        // Pode chamar o método do trait via alias se necessário
        return "Olá da classe filha! (trait diz: " . parent::cumprimentar() . ")";
    }
}

echo (new FilhaComTrait())->cumprimentar()    . "\n";
// Olá do trait!

echo (new FilhaComOverride())->cumprimentar() . "\n";
// Olá da classe filha! (trait diz: Olá da classe pai!)

Resolução de conflitos entre traits

Quando dois traits definem o mesmo método, o PHP lança um erro fatal — você é forçado a resolver o conflito explicitamente. Existem dois mecanismos: insteadof (escolhe qual trait vence) e as (cria um alias para o método que perdeu, tornando ambos acessíveis).

<?php
declare(strict_types=1);

trait LoggerA
{
    public function log(string $msg): void
    {
        echo "[LoggerA] {$msg}\n";
    }

    public function debug(string $msg): void
    {
        echo "[LoggerA:debug] {$msg}\n";
    }
}

trait LoggerB
{
    public function log(string $msg): void
    {
        echo "[LoggerB] {$msg}\n";
    }

    public function debug(string $msg): void
    {
        echo "[LoggerB:debug] {$msg}\n";
    }
}

class Servico
{
    use LoggerA, LoggerB {
        // insteadof — LoggerA::log vence sobre LoggerB::log
        LoggerA::log     insteadof LoggerB;
        // LoggerB::log fica acessível pelo alias logB()
        LoggerB::log     as logB;

        // Para debug: LoggerB vence, mas LoggerA fica via alias debugA()
        LoggerB::debug   insteadof LoggerA;
        LoggerA::debug   as debugA;
    }
}

$servico = new Servico();
$servico->log("mensagem principal");   // [LoggerA] mensagem principal
$servico->logB("via alias");           // [LoggerB] via alias
$servico->debug("depuração");          // [LoggerB:debug] depuração
$servico->debugA("depuração alt");     // [LoggerA:debug] depuração alt

O alias com as também pode alterar a visibilidade do método sem criar um novo nome:

<?php
declare(strict_types=1);

trait Segredo
{
    public function revelar(): string
    {
        return "segredo interno";
    }
}

class Cofre
{
    use Segredo {
        // Torna revelar() privado nesta classe — só Cofre acessa
        revelar as private;
    }

    public function abrir(string $senha): string
    {
        if ($senha !== '1234') {
            throw new \RuntimeException("Senha incorreta.");
        }
        // Chama o método do trait internamente
        return $this->revelar();
    }
}

$cofre = new Cofre();
echo $cofre->abrir('1234') . "\n"; // segredo interno
// $cofre->revelar() — erro: método privado

Requisitos de trait — abstract e propriedades

Um trait pode declarar métodos abstratos para exigir que a classe hospedeira os implemente. Isso cria um contrato implícito: o trait usa o comportamento que a classe deve fornecer. Traits também podem declarar propriedades — mas se a classe hospedeira declarar a mesma propriedade com tipo ou valor incompatível, o PHP lança um erro.

<?php
declare(strict_types=1);

// Trait com requisito abstrato — exige que a classe forneça getId()
trait Auditavel
{
    // Requisito: a classe hospedeira DEVE implementar este método
    abstract public function getId(): int;
    abstract public function getNome(): string;

    public function registrarAcao(string $acao): void
    {
        // Usa métodos que a classe hospedeira é obrigada a fornecer
        $linha = sprintf(
            "[AUDIT] %s | ID: %d | Nome: %s | Em: %s",
            $acao,
            $this->getId(),
            $this->getNome(),
            date('Y-m-d H:i:s')
        );
        echo $linha . "\n";
    }
}

// Trait com propriedade tipada — PHP 8.2+
trait ComSaldo
{
    // Propriedade com valor padrão
    private float $saldo = 0.0;

    public function saldo(): float { return $this->saldo; }

    public function depositar(float $valor): void
    {
        if ($valor <= 0) throw new \InvalidArgumentException("Valor deve ser positivo.");
        $this->saldo += $valor;
    }

    public function sacar(float $valor): void
    {
        if ($valor > $this->saldo) {
            throw new \RuntimeException("Saldo insuficiente: {$this->saldo}");
        }
        $this->saldo -= $valor;
    }
}

// A classe hospedeira satisfaz os requisitos abstratos do trait Auditavel
class ContaBancaria
{
    use Auditavel, ComSaldo;

    public function __construct(
        private readonly int    $id,
        private readonly string $titular,
    ) {}

    // Satisfaz o requisito abstrato de Auditavel
    public function getId(): int     { return $this->id; }
    public function getNome(): string { return $this->titular; }
}

$conta = new ContaBancaria(7, "Ana Lima");
$conta->depositar(1000.0);
$conta->registrarAcao("Depósito de R$ 1000,00");
// [AUDIT] Depósito de R$ 1000,00 | ID: 7 | Nome: Ana Lima | Em: 2024-01-15 14:32:00

$conta->sacar(250.0);
$conta->registrarAcao("Saque de R$ 250,00");
echo "Saldo: R$ " . $conta->saldo() . "\n"; // Saldo: R$ 750

Traits com interfaces — contratos verificáveis

O problema com traits puros é que não há como garantir, via type hint, que um objeto usa determinado trait. A solução profissional é combinar trait com interface: a interface define o contrato verificável pelo type system, e o trait fornece a implementação padrão.

<?php
declare(strict_types=1);

// Interface — contrato verificável pelo sistema de tipos
interface SerializavelInterface
{
    public function paraArray(): array;
    public function paraJson(): string;
}

// Trait — implementação padrão do contrato
trait SerializavelTrait
{
    public function paraArray(): array
    {
        // Usa reflection para serializar propriedades públicas e protected
        $dados = [];
        $reflection = new \ReflectionObject($this);

        foreach ($reflection->getProperties(\ReflectionProperty::IS_PUBLIC | \ReflectionProperty::IS_PROTECTED) as $prop) {
            $prop->setAccessible(true);
            $valor = $prop->getValue($this);
            $dados[$prop->getName()] = $valor instanceof \DateTimeInterface
                ? $valor->format('Y-m-d H:i:s')
                : $valor;
        }
        return $dados;
    }

    public function paraJson(): string
    {
        return json_encode($this->paraArray(), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
    }
}

// Interface verificável + Trait fornece implementação — padrão recomendado
class Produto implements SerializavelInterface
{
    use SerializavelTrait;  // fornece paraArray() e paraJson()

    public function __construct(
        public readonly int    $id,
        public readonly string $nome,
        public readonly float  $preco,
    ) {}
}

class Usuario implements SerializavelInterface
{
    use SerializavelTrait;  // mesmo trait, outra classe

    public function __construct(
        public readonly int    $id,
        public readonly string $email,
    ) {}
}

// Função tipada na INTERFACE — aceita Produto, Usuario, ou qualquer outro
// que implemente SerializavelInterface, com ou sem o trait
function exportar(SerializavelInterface $objeto): string
{
    return $objeto->paraJson();
}

echo exportar(new Produto(1, 'Teclado', 350.0));
// { "id": 1, "nome": "Teclado", "preco": 350 }

echo exportar(new Usuario(42, 'ana@email.com'));
// { "id": 42, "email": "ana@email.com" }

Este é o padrão usado pelo Laravel em toda a sua base: Authenticatable é uma interface, AuthenticatableTrait é o trait que a implementa, e os modelos usam implements Authenticatable + use AuthenticatableTrait.

Traits dentro de traits

Traits podem usar outros traits, criando composições de comportamentos reutilizáveis. Isso permite construir traits complexos a partir de blocos menores sem repetição.

<?php
declare(strict_types=1);

trait HasCreatedAt
{
    private \DateTimeImmutable $criadoEm;

    public function inicializarCriadoEm(): void
    {
        $this->criadoEm = new \DateTimeImmutable();
    }
    public function criadoEm(): \DateTimeImmutable { return $this->criadoEm; }
}

trait HasUpdatedAt
{
    private \DateTimeImmutable $atualizadoEm;

    public function inicializarAtualizadoEm(): void
    {
        $this->atualizadoEm = new \DateTimeImmutable();
    }
    public function tocar(): void
    {
        $this->atualizadoEm = new \DateTimeImmutable();
    }
    public function atualizadoEm(): \DateTimeImmutable { return $this->atualizadoEm; }
}

trait HasSoftDelete
{
    private ?\DateTimeImmutable $deletadoEm = null;

    public function deletar(): void         { $this->deletadoEm = new \DateTimeImmutable(); }
    public function restaurar(): void       { $this->deletadoEm = null; }
    public function estaDeletado(): bool    { return $this->deletadoEm !== null; }
    public function deletadoEm(): ?\DateTimeImmutable { return $this->deletadoEm; }
}

// Trait composto — usa os três traits menores
// Quem usa ModelTrait ganha tudo automaticamente
trait ModelTrait
{
    use HasCreatedAt, HasUpdatedAt, HasSoftDelete;

    public function inicializar(): void
    {
        $this->inicializarCriadoEm();
        $this->inicializarAtualizadoEm();
    }
}

class Artigo
{
    use ModelTrait;

    public function __construct(public readonly string $titulo) {}
}

$artigo = new Artigo("PHP Avançado");
$artigo->inicializar();

echo $artigo->criadoEm()->format('H:i:s') . "\n";
echo $artigo->estaDeletado() ? "deletado\n" : "ativo\n"; // ativo

$artigo->deletar();
echo $artigo->estaDeletado() ? "deletado\n" : "ativo\n"; // deletado

$artigo->restaurar();
echo $artigo->estaDeletado() ? "deletado\n" : "ativo\n"; // ativo

Trait é cópia de código feita pelo compilador, e essa frase explica quase todo o comportamento estranho dele: a precedência, os conflitos, o static::class que resolve para a classe que usa. Por ser cólagem, ele não cria tipo — e é por isso que trait sozinho não serve como contrato, enquanto trait mais interface serve muito bem. A dose também importa: uma classe montada a partir de seis traits é difícil de ler justamente porque o código dela não está nela.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Crie um trait Validavel com método abstrato regrasDeValidacao(): array (retorna array de regras) e método concreto validar(array $dados): array (retorna erros). Implemente em FormularioCadastro e FormularioPagamento, cada um com suas próprias regras.

Ver resposta

✓ Resposta: O método abstrato no trait é o que o torna um template: validar() conhece o algoritmo, e a classe fornece só a parte que varia. Se esquecer de implementar regrasDeValidacao(), o erro é fatal já no carregamento da classe — não em produção.

<?php

declare(strict_types=1);

trait Validavel
{
    // Método abstrato dentro do trait: quem usar é OBRIGADO a implementar.
    // É assim que o trait exige o pedaço variável sem conhecê-lo.
    abstract public function regrasDeValidacao(): array;

    /** @return array<string, string[]> campo => lista de erros */
    public function validar(array $dados): array
    {
        $erros = [];

        foreach ($this->regrasDeValidacao() as $campo => $regras) {
            $valor = $dados[$campo] ?? null;

            foreach (explode('|', $regras) as $regra) {
                [$nome, $param] = array_pad(explode(':', $regra, 2), 2, null);

                $erro = $this->aplicarRegra($nome, $campo, $valor, $param);

                if ($erro !== null) {
                    $erros[$campo][] = $erro;
                    // Uma falha por campo basta: sem isso, campo vazio
                    // acusaria "obrigatório" e "mínimo 8" ao mesmo tempo.
                    break;
                }
            }
        }

        return $erros;
    }

    private function aplicarRegra(string $regra, string $campo, mixed $valor, ?string $param): ?string
    {
        return match ($regra) {
            'obrigatorio' => (is_string($valor) ? trim($valor) === '' : $valor === null)
                ? "O campo {$campo} é obrigatório." : null,

            'email' => ($valor !== null && !filter_var($valor, FILTER_VALIDATE_EMAIL))
                ? "O campo {$campo} precisa ser um e-mail válido." : null,

            'min' => (is_string($valor) && mb_strlen($valor) < (int) $param)
                ? "O campo {$campo} precisa de ao menos {$param} caracteres." : null,

            'numerico' => ($valor !== null && !is_numeric($valor))
                ? "O campo {$campo} precisa ser numérico." : null,

            'positivo' => (is_numeric($valor) && (float) $valor <= 0)
                ? "O campo {$campo} precisa ser maior que zero." : null,

            default => null,
        };
    }
}

final class FormularioCadastro
{
    use Validavel;

    public function regrasDeValidacao(): array
    {
        return [
            'nome'  => 'obrigatorio|min:3',
            'email' => 'obrigatorio|email',
            'senha' => 'obrigatorio|min:8',
        ];
    }
}

final class FormularioPagamento
{
    use Validavel;

    public function regrasDeValidacao(): array
    {
        return [
            'cartao'    => 'obrigatorio|min:13',
            'cvv'       => 'obrigatorio|numerico|min:3',
            'valor'     => 'obrigatorio|numerico|positivo',
        ];
    }
}

print_r((new FormularioCadastro())->validar([
    'nome' => 'Jo', 'email' => 'nao-eh-email', 'senha' => '123',
]));
// nome  => O campo nome precisa de ao menos 3 caracteres.
// email => O campo email precisa ser um e-mail válido.
// senha => O campo senha precisa de ao menos 8 caracteres.

print_r((new FormularioPagamento())->validar([
    'cartao' => '4111111111111111', 'cvv' => '12x', 'valor' => -5,
]));
// cvv   => O campo cvv precisa ser numérico.
// valor => O campo valor precisa ser maior que zero.

Exercício 2

Construa dois traits LogConsole e LogArquivo, ambos com método log(string $msg): void. Crie uma classe Aplicacao que usa ambos, resolve o conflito via insteadof e mantém o segundo método acessível via alias. Demonstre chamando os dois.

Ver resposta

✓ Resposta: O erro mais comum aqui é achar que as renomeia. Ele adiciona um nome: depois de LogArquivo::log as logArquivo, o método continua existindo como log também — só que insteadof já deu esse nome ao outro trait.

<?php

declare(strict_types=1);

trait LogConsole
{
    public function log(string $msg): void
    {
        printf("[console] %s — %s%s",
               (new DateTimeImmutable())->format('H:i:s'), $msg, PHP_EOL);
    }
}

trait LogArquivo
{
    public function log(string $msg): void
    {
        file_put_contents(
            __DIR__ . '/app.log',
            sprintf("[arquivo] %s — %s\n", (new DateTimeImmutable())->format('c'), $msg),
            FILE_APPEND | LOCK_EX,
        );
    }
}

final class Aplicacao
{
    // Usar os dois sem o bloco de resolução é erro FATAL:
    // "Trait method Aplicacao::log has not been applied as it collides..."
    use LogConsole, LogArquivo {
        // 1) Quem fica com o nome disputado:
        LogConsole::log insteadof LogArquivo;

        // 2) O perdedor ganha um nome adicional — `as` NÃO renomeia,
        //    cria um apelido. Sem isso, LogArquivo::log ficaria inacessível.
        LogArquivo::log as logArquivo;
    }

    public function iniciar(): void
    {
        $this->log('Aplicação iniciada');        // LogConsole
        $this->logArquivo('Aplicação iniciada'); // LogArquivo
    }

    public function falhar(string $motivo): void
    {
        // Erro grave vai para os dois destinos.
        $this->log("ERRO: {$motivo}");
        $this->logArquivo("ERRO: {$motivo}");
    }
}

$app = new Aplicacao();
$app->iniciar();
$app->falhar('conexão recusada');

// Saída no console:
// [console] 14:30:12 — Aplicação iniciada
// [console] 14:30:12 — ERRO: conexão recusada
//
// app.log:
// [arquivo] 2026-08-15T14:30:12-03:00 — Aplicação iniciada
// [arquivo] 2026-08-15T14:30:12-03:00 — ERRO: conexão recusada

// ---------- Bônus: `as` também muda visibilidade -------------------------
final class Servico
{
    use LogConsole {
        // O log vira detalhe interno: ninguém de fora chama $servico->log().
        log as protected registrar;
    }

    public function executar(): void
    {
        $this->registrar('executando');
    }
}

Exercício 3

Combine trait com interface: interface CacheavelInterface com chaveCache(): string e tempoExpiracao(): int. Trait CacheavelTrait implementa armazenarNoCache() e buscarDoCache() usando os valores da interface. Implemente em duas classes diferentes.

Ver resposta

✓ Resposta: A dupla interface + trait resolve o que nenhum dos dois faz sozinho: a interface permite tipar (function aquecer(CacheavelInterface $c)) e o trait evita reescrever o mesmo cache em cada classe. Trait sozinho não serve para type hint.

<?php

declare(strict_types=1);

interface CacheavelInterface
{
    public function chaveCache(): string;

    public function tempoExpiracao(): int;   // segundos
}

trait CacheavelTrait
{
    // O trait não implementa a interface — ele CONSOME os dois métodos que
    // a interface obriga a existir. A classe declara `implements`, o trait
    // entrega o comportamento. Separação limpa entre contrato e implementação.
    public function armazenarNoCache(mixed $valor): bool
    {
        $arquivo = $this->caminhoCache();

        $envelope = [
            'expira_em' => time() + $this->tempoExpiracao(),
            'valor'     => $valor,
        ];

        return file_put_contents(
            $arquivo,
            serialize($envelope),
            LOCK_EX,
        ) !== false;
    }

    public function buscarDoCache(): mixed
    {
        $arquivo = $this->caminhoCache();

        if (!is_file($arquivo)) {
            return null;
        }

        $envelope = unserialize((string) file_get_contents($arquivo), ['allowed_classes' => false]);

        if (!is_array($envelope) || time() > ($envelope['expira_em'] ?? 0)) {
            // Expirou: limpa e devolve null como se nunca tivesse existido.
            @unlink($arquivo);
            return null;
        }

        return $envelope['valor'];
    }

    public function invalidarCache(): void
    {
        $arquivo = $this->caminhoCache();

        if (is_file($arquivo)) {
            unlink($arquivo);
        }
    }

    private function caminhoCache(): string
    {
        // sha1 na chave: nome de arquivo seguro mesmo com "/" ou ":" na chave.
        return sys_get_temp_dir() . '/cache_' . sha1($this->chaveCache()) . '.php';
    }
}

final class RelatorioVendas implements CacheavelInterface
{
    use CacheavelTrait;

    public function __construct(
        private readonly int $ano,
        private readonly int $mes,
    ) {}

    public function chaveCache(): string
    {
        return sprintf('relatorio:vendas:%04d-%02d', $this->ano, $this->mes);
    }

    // Relatório de mês fechado não muda mais: cacheia por um dia.
    public function tempoExpiracao(): int
    {
        $ehMesAtual = $this->ano === (int) date('Y') && $this->mes === (int) date('n');

        return $ehMesAtual ? 300 : 86400;
    }

    public function gerar(): array
    {
        if (($cache = $this->buscarDoCache()) !== null) {
            return $cache;
        }

        $dados = ['total' => 128450.90, 'pedidos' => 312];   // consulta cara

        $this->armazenarNoCache($dados);

        return $dados;
    }
}

final class PerfilUsuario implements CacheavelInterface
{
    use CacheavelTrait;

    public function __construct(private readonly int $usuarioId) {}

    public function chaveCache(): string
    {
        return "usuario:{$this->usuarioId}:perfil";
    }

    public function tempoExpiracao(): int { return 600; }
}

$relatorio = new RelatorioVendas(2026, 7);
print_r($relatorio->gerar());   // 1ª vez: calcula e grava
print_r($relatorio->gerar());   // 2ª vez: vem do cache

Exercício 4

Crie um trait HasUuid que gera um UUID v4 na inicialização e expõe uuid(): string. Use o trait em Pedido, Produto e EventoDominio. Garanta que o UUID é imutável após a criação.

Ver resposta

✓ Resposta: A escolha entre as duas variantes é real: readonly dá garantia forte, mas obriga toda classe a chamar o inicializador no construtor e não aceita atribuição preguiçosa. A versão com ??= é mais tolerante e igualmente imutável na prática, já que não existe setter.

<?php

declare(strict_types=1);

trait HasUuid
{
    // Sem `readonly` aqui, porque o valor é atribuído fora do construtor da
    // classe; a imutabilidade vem de a propriedade ser private e não haver
    // setter. Quem quiser readonly de verdade, veja a variante no fim.
    private ?string $uuid = null;

    public function uuid(): string
    {
        // Geração preguiçosa: o primeiro acesso cria, os seguintes reusam.
        // Assim o trait funciona mesmo em classe cujo construtor não o chama.
        return $this->uuid ??= self::gerarUuidV4();
    }

    public static function gerarUuidV4(): string
    {
        $bytes = random_bytes(16);

        // Versão 4 nos bits 12-15 do time_hi_and_version.
        $bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40);
        // Variante RFC 4122 nos dois bits mais altos do clock_seq_hi.
        $bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80);

        return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4));
    }

    public function mesmaIdentidade(object $outro): bool
    {
        return method_exists($outro, 'uuid') && $this->uuid() === $outro->uuid();
    }
}

final class Pedido
{
    use HasUuid;

    public function __construct(
        public readonly string $cliente,
        public readonly float $total,
    ) {
        $this->uuid();   // fixa a identidade já na criação
    }
}

final class Produto
{
    use HasUuid;

    public function __construct(public readonly string $nome) {}
}

final class EventoDominio
{
    use HasUuid;

    public function __construct(
        public readonly string $nome,
        public readonly DateTimeImmutable $ocorridoEm = new DateTimeImmutable(),
    ) {}

    public function paraFila(): array
    {
        // O UUID é a chave de idempotência: reprocessar o mesmo evento duas
        // vezes é detectável porque o id não muda.
        return ['id' => $this->uuid(), 'nome' => $this->nome,
                'em' => $this->ocorridoEm->format('c')];
    }
}

$pedido = new Pedido('Ana', 349.90);

echo $pedido->uuid(), PHP_EOL;   // 3f2b1c8a-... (v4)
echo $pedido->uuid(), PHP_EOL;   // idêntico: não regenera

var_dump($pedido->uuid() === $pedido->uuid());          // true
var_dump($pedido->mesmaIdentidade(new Pedido('Ana', 349.90)));  // false

// ---------- Variante com readonly de verdade (PHP 8.1+) ------------------
trait HasUuidReadonly
{
    public readonly string $uuid;

    // A classe chama isto no construtor. readonly só aceita UMA escrita, e
    // ela tem de partir do escopo da classe — por isso não dá para usar a
    // inicialização preguiçosa aqui.
    private function inicializarUuid(): void
    {
        $this->uuid = HasUuid::gerarUuidV4();
    }
}

Exercício 5

Desafio: implemente o padrão completo do Laravel para SoftDeletes: interface SoftDeletavelInterface com deletar(), restaurar(), estaDeletado(), deletadoEm(); trait SoftDeletesTrait com a implementação; e um QueryBuilder simplificado que, ao montar uma query para objetos que implementam SoftDeletavelInterface, adiciona automaticamente WHERE deletado_em IS NULL.

Ver resposta

✓ Resposta: O is_subclass_of() com a interface é o que torna o comportamento automático — e é por isso que o Eloquent detecta o trait. Lembre do outro lado: índice em deletado_em e atenção a UNIQUE, porque a linha continua lá e um e-mail "apagado" ainda bloqueia o recadastro.

<?php

declare(strict_types=1);

interface SoftDeletavelInterface
{
    public function deletar(): void;

    public function restaurar(): void;

    public function estaDeletado(): bool;

    public function deletadoEm(): ?DateTimeImmutable;
}

trait SoftDeletesTrait
{
    private ?DateTimeImmutable $deletadoEm = null;

    public function deletar(): void
    {
        // Idempotente: deletar duas vezes não muda a data original.
        $this->deletadoEm ??= new DateTimeImmutable();
    }

    public function restaurar(): void
    {
        $this->deletadoEm = null;
    }

    public function estaDeletado(): bool
    {
        return $this->deletadoEm !== null;
    }

    public function deletadoEm(): ?DateTimeImmutable
    {
        return $this->deletadoEm;
    }

    /** Marca a coluna no banco, sem apagar a linha. */
    public function aplicarDelecao(PDO $pdo, string $tabela, int $id): bool
    {
        $stmt = $pdo->prepare(
            "UPDATE {$tabela} SET deletado_em = :quando WHERE id = :id AND deletado_em IS NULL"
        );

        $this->deletar();
        $stmt->execute([
            ':quando' => $this->deletadoEm->format('Y-m-d H:i:s'),
            ':id'     => $id,
        ]);

        return $stmt->rowCount() > 0;
    }
}

final class Post implements SoftDeletavelInterface
{
    use SoftDeletesTrait;

    public function __construct(
        public readonly int $id,
        public readonly string $titulo,
    ) {}
}

final class Comentario implements SoftDeletavelInterface
{
    use SoftDeletesTrait;

    public function __construct(public readonly int $id, public readonly string $texto) {}
}

// ---------- QueryBuilder que respeita a exclusão lógica -------------------
final class QueryBuilder
{
    private array $condicoes = [];
    private bool $incluirDeletados = false;

    public function __construct(
        private readonly string $tabela,
        private readonly string $classeEntidade,
    ) {}

    public function where(string $condicao): static
    {
        $this->condicoes[] = $condicao;
        return $this;
    }

    /** Equivale ao withTrashed() do Eloquent. */
    public function comDeletados(): static
    {
        $this->incluirDeletados = true;
        return $this;
    }

    /** Só a lixeira — o onlyTrashed(). */
    public function apenasDeletados(): static
    {
        $this->incluirDeletados = true;
        $this->condicoes[] = 'deletado_em IS NOT NULL';
        return $this;
    }

    public function build(): string
    {
        $condicoes = $this->condicoes;

        // O coração do exercício: se a entidade implementa a interface, o
        // filtro entra SOZINHO. Esquecer de escrever o WHERE deixa de ser
        // possível — e é justamente esse esquecimento que faz registro
        // "apagado" reaparecer numa listagem.
        if (!$this->incluirDeletados
            && is_subclass_of($this->classeEntidade, SoftDeletavelInterface::class)) {
            array_unshift($condicoes, 'deletado_em IS NULL');
        }

        $sql = "SELECT * FROM {$this->tabela}";

        if ($condicoes !== []) {
            $sql .= ' WHERE ' . implode(' AND ', $condicoes);
        }

        return $sql;
    }
}

echo (new QueryBuilder('posts', Post::class))
        ->where('autor_id = 7')
        ->build(), PHP_EOL;
// SELECT * FROM posts WHERE deletado_em IS NULL AND autor_id = 7

echo (new QueryBuilder('posts', Post::class))
        ->where('autor_id = 7')
        ->comDeletados()
        ->build(), PHP_EOL;
// SELECT * FROM posts WHERE autor_id = 7

echo (new QueryBuilder('posts', Post::class))->apenasDeletados()->build(), PHP_EOL;
// SELECT * FROM posts WHERE deletado_em IS NOT NULL

// Entidade sem a interface: nada é acrescentado.
final class LogAcesso {}
echo (new QueryBuilder('logs', LogAcesso::class))->build(), PHP_EOL;
// SELECT * FROM logs
Comentários

Mais em PHP

SQLite com PHP
SQLite com PHP

Um arquivo, zero configuração e uma busca textual com FTS5 que dispensa…

Domain-Driven Design
Domain-Driven Design

Value Objects que recusam estado inválido no construtor, um Aggregate que…

ORM com Eloquent
ORM com Eloquent

Relacionamento em uma linha, cast automático de tipos, escopo que se encadeia…