Boas práticas não são regras arbitrárias inventadas por puristas. São soluções destiladas de décadas de experiência coletiva com código que ficou difícil de manter, difícil de testar, e difícil de entender. Neste artigo reunimos os princípios mais impactantes para escrever PHP profissional: código que se lê como prosa, que resiste ao tempo e que você não se envergonha de mostrar.
Declare tipos em tudo
O sistema de tipos do PHP 8 é expressivo e poderoso. Use-o:
<?php
declare(strict_types=1); // Sempre — ativa verificação estrita de tipos
// Ruim: sem tipos
function calcularDesconto($preco, $percentual) {
return $preco * (1 - $percentual / 100);
}
// Bom: tipos completos
function calcularDesconto(float $preco, float $percentual): float
{
if ($percentual < 0 || $percentual > 100) {
throw new \InvalidArgumentException("Percentual inválido: {$percentual}");
}
return $preco * (1 - $percentual / 100);
}
// Ainda melhor: Value Objects eliminam a necessidade de validar sempre
function calcularDesconto(Dinheiro $preco, Percentual $percentual): Dinheiro
{
return $preco->aplicarDesconto($percentual);
}
// Union types — quando necessário
function buscarOuCriar(int|string $id): Usuario { /* ... */ }
// Nullable — quando null é um valor legítimo
function buscarPorEmail(string $email): ?Usuario { /* ... */ }
// Never — função que nunca retorna (sempre lança exceção ou encerra)
function falhar(string $mensagem): never
{
throw new \RuntimeException($mensagem);
}
Nomes que explicam a intenção
<?php
// Ruim: o que é $d? O que faz $proc?
$d = new DateTime();
function proc($u, $p): bool { /* ... */ }
// Bom: nomes revelam intenção
$dataDeNascimento = new DateTimeImmutable('1990-05-15');
function verificarSenha(string $senhaDigitada, string $hashArmazenado): bool { /* ... */ }
// Ruim: abreviações crípticas
$usr = $uRepo->findById($uid);
$ord->addItm($prd, $qty, $prc);
// Bom: verbosidade que clarifica
$usuario = $usuarioRepositorio->buscarPorId($usuarioId);
$pedido->adicionarItem($produto, $quantidade, $preco);
// Booleans: prefixos que comunicam
$estaAtivo = true;
$podeEditarPerfil = false;
$temEstoqueDisponivel = $produto->estoque > 0;
// Funções booleans: verbos is/has/can/deve
function isEmailValido(string $email): bool { /* ... */ }
function temPermissao(Usuario $usuario, string $recurso): bool { /* ... */ }
function podeRealizarCompra(Usuario $usuario, Pedido $pedido): bool { /* ... */ }
Funções pequenas com uma responsabilidade
<?php
// Ruim: uma função fazendo tudo
function processarCadastro(array $dados): array
{
// validação
if (empty($dados['nome'])) return ['erro' => 'Nome obrigatório'];
if (!filter_var($dados['email'], FILTER_VALIDATE_EMAIL)) return ['erro' => 'Email inválido'];
// hash de senha
$hash = password_hash($dados['senha'], PASSWORD_ARGON2ID);
// inserção no banco
global $pdo;
$pdo->prepare('INSERT INTO usuarios ...');
// envio de email
mail($dados['email'], 'Bem-vindo!', '...');
return ['sucesso' => true];
}
// Bom: responsabilidades separadas
function validarDadosDeCadastro(array $dados): array // retorna erros
{
$erros = [];
if (empty(trim($dados['nome'] ?? ''))) $erros['nome'] = 'Nome obrigatório.';
if (!filter_var($dados['email'] ?? '', FILTER_VALIDATE_EMAIL)) $erros['email'] = 'Email inválido.';
if (strlen($dados['senha'] ?? '') < 8) $erros['senha'] = 'Senha deve ter 8+ caracteres.';
return $erros;
}
class CadastroService
{
public function __construct(
private readonly UsuarioRepositorioInterface $repositorio,
private readonly EmailService $emailService,
) {}
public function cadastrar(DadosDeCadastro $dados): Usuario
{
$usuario = $this->repositorio->salvar(
new Usuario(
nome: $dados->nome,
email: $dados->email,
senhaHash: password_hash($dados->senha, PASSWORD_ARGON2ID),
)
);
$this->emailService->enviarBoasVindas($usuario);
return $usuario;
}
}
Early return — evite else desnecessário
<?php
// Ruim: aninhamento excessivo
function processarPagamento(Pedido $pedido, Usuario $usuario): string
{
if ($pedido->estaAtivo()) {
if ($usuario->temSaldoSuficiente($pedido->total())) {
if (!$pedido->jaFoiPago()) {
// processa...
return 'Pagamento realizado.';
} else {
return 'Pedido já foi pago.';
}
} else {
return 'Saldo insuficiente.';
}
} else {
return 'Pedido inativo.';
}
}
// Bom: early return elimina o aninhamento
function processarPagamento(Pedido $pedido, Usuario $usuario): string
{
if (!$pedido->estaAtivo()) {
return 'Pedido inativo.';
}
if ($pedido->jaFoiPago()) {
return 'Pedido já foi pago.';
}
if (!$usuario->temSaldoSuficiente($pedido->total())) {
return 'Saldo insuficiente.';
}
// Chegou aqui: todas as condições satisfeitas
// processa...
return 'Pagamento realizado.';
}
Imutabilidade onde possível
<?php
// Value Objects são imutáveis por definição
final class Email
{
public function __construct(
private readonly string $valor // readonly: não pode ser alterado após construção
) {
if (!filter_var($valor, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException("Email inválido: {$valor}");
}
}
// "Modificação" retorna nova instância
public function comDominio(string $novoDominio): self
{
[$local] = explode('@', $this->valor);
return new self("{$local}@{$novoDominio}");
}
public function valor(): string { return $this->valor; }
}
// PHP 8.1+ readonly classes
final readonly class Coordenada
{
public function __construct(
public float $latitude,
public float $longitude,
) {
if ($this->latitude < -90 || $this->latitude > 90) {
throw new \InvalidArgumentException('Latitude inválida.');
}
}
}
Prefira exceções a códigos de erro
<?php
// Ruim: retorna false/null em caso de erro — o chamador pode ignorar
function dividir(float $a, float $b): float|false
{
if ($b === 0.0) return false;
return $a / $b;
}
$resultado = dividir(10, 0); // false — fácil de ignorar acidentalmente
// Bom: exceção que não pode ser ignorada silenciosamente
function dividir(float $a, float $b): float
{
if ($b === 0.0) {
throw new \DivisionByZeroError('Divisor não pode ser zero.');
}
return $a / $b;
}
// Hierarquia de exceções próprias — comunicam o problema com precisão.
// Cuidado com o nome: `DomainException` JÁ EXISTE na SPL (e estende
// `LogicException`, não `RuntimeException`). Redeclará-la no namespace
// global é erro fatal na hora de carregar o arquivo. Ou se escolhe um
// nome próprio, como aqui, ou se estende a da SPL diretamente.
class ErroDeDominio extends \RuntimeException {}
class EstoqueInsuficienteException extends ErroDeDominio
{
public function __construct(
public readonly int $disponivel,
public readonly int $solicitado,
) {
parent::__construct(
"Estoque insuficiente: {$disponivel} disponível, {$solicitado} solicitado."
);
}
}
Nenhuma dessas práticas se sustenta sozinha, e é justamente aí que elas se pagam. Tipo declarado sem nome que explique a intenção só empurra a dúvida para o parâmetro seguinte; função pequena sem early return vira uma escada de três níveis em dois meses; imutabilidade sem exceção no construtor entrega um objeto inválido que ninguém consegue rastrear até a origem. O que amarra o conjunto é uma ideia só: tornar o estado errado impossível em vez de conferi-lo a cada uso. O tipo faz isso no parâmetro, o value object faz no domínio, o readonly faz no tempo e a exceção faz na borda — quatro maneiras de dizer a mesma frase.
Vale também a régua honesta, porque nada disso sai de graça: cada abstração cobra uma linha a mais para ler, e há projeto em que a função de dez linhas com um else é simplesmente a resposta certa. A pergunta que resolve o caso concreto não é "está limpo?", e sim se quem abrir o arquivo depois — com boa chance de ser você daqui a seis meses, sem nenhuma das decisões de hoje na memória — consegue mudar a regra de negócio sem precisar entender o arquivo inteiro. Enquanto a resposta for não, sobra refatoração pela frente. Quando for sim, parar ali é a decisão profissional.
Fontes e leituras recomendadas
- Clean Code (livro) — Robert C. Martin: https://www.amazon.com.br/Clean-Code-Handbook-Software-Craftsmanship/dp/0132350882
- PHP The Right Way: https://phptherightway.com/
- PHPStan — análise estática: https://phpstan.org/
- Refactoring Guru — padrões e refatoração: https://refactoring.guru/
- Rector — refatoracao automatizada, util tambem para migrar de versao do PHP: https://getrector.com/
- PHPMD — detecta complexidade ciclomatica alta, metodo longo e parametro demais: https://phpmd.org/
Exercícios
Exercício 1
Pegue um arquivo PHP antigo seu (ou de um repositório open-source) e aplique as práticas deste artigo: adicione tipos, renomeie variáveis, extraia funções, aplique early return. Documente as mudanças e compare a legibilidade.
Ver resposta
✓ Resposta: A refatoração não inventou regra nenhuma — e é justamente esse o ponto. O que ela fez foi tornar visível o que estava escondido: que 1.000,00 exatos não ganham 10% (o > do código original), e que somar dinheiro em float produzia centavos fantasmas. Duas notas técnicas de brinde: usort com subtração de float devolve 0.4, que o PHP trunca para 0 e trata como empate; e comparar $v['st'] == 1 com string do banco fazia "01" passar. Com <=> e enum, os dois casos deixam de existir.
<?php
// ---------- ANTES: 46 linhas de um arquivo real de 2019 ------------------
// processa.php — sem tipos, sem nomes, sem retorno claro.
function proc($d, $t = 0) {
$r = array();
if ($d) {
foreach ($d as $k => $v) {
if ($v['st'] == 1) {
if ($t == 0 || $v['tp'] == $t) {
$tot = 0;
foreach ($v['it'] as $i) {
$tot += $i['q'] * $i['p'];
}
if ($tot > 0) {
$d2 = 0;
if ($tot > 1000) { $d2 = $tot * 0.1; }
else if ($tot > 500) { $d2 = $tot * 0.05; }
$r[] = array(
'id' => $v['id'],
'c' => $v['cli'],
't' => $tot,
'd' => $d2,
'f' => $tot - $d2,
'dt' => date('d/m/Y', strtotime($v['dt']))
);
}
}
}
}
}
if (count($r) > 0) {
usort($r, function($a, $b) { return $b['f'] - $a['f']; });
}
return $r;
}
// ---------- DEPOIS: as mesmas regras, agora legíveis --------------------
declare(strict_types=1);
namespace MeuProjeto\Faturamento;
// O QUE MUDOU, e por quê:
//
// 1. Tipos em tudo. `$d` podia ser array, null ou false; agora é array e
// ponto. O TypeError chega no boot, não no relatório do cliente.
// 2. Nomes por extenso. `$v['st'] == 1` virou `$pedido->estaConfirmado()` —
// a regra passou a caber na leitura, sem consultar o banco para
// descobrir o que é "st".
// 3. Early return e guard clause. As cinco indentações aninhadas viraram
// condições que saem cedo.
// 4. Uma responsabilidade por função. `proc()` filtrava, somava, calculava
// desconto, formatava data e ordenava. Cada uma dessas virou um nome.
// 5. Centavos em int. O `0.1` sobre float acumula erro de arredondamento —
// em relatório de faturamento isso aparece como um centavo que ninguém
// explica.
// 6. Comparação estrita. `==` com string do banco fazia "01" == 1 ser
// verdadeiro; com enum, o caso deixa de existir.
enum TipoDePedido: string
{
case Balcao = 'balcao';
case Online = 'online';
case Telefone = 'telefone';
}
enum StatusDoPedido: int
{
case Rascunho = 0;
case Confirmado = 1;
case Cancelado = 2;
}
final class ItemDoPedido
{
public function __construct(
public readonly int $quantidade,
public readonly int $precoUnitarioEmCentavos,
) {}
public function subtotalEmCentavos(): int
{
return $this->quantidade * $this->precoUnitarioEmCentavos;
}
}
final class Pedido
{
/** @param list<ItemDoPedido> $itens */
public function __construct(
public readonly int $id,
public readonly string $cliente,
public readonly StatusDoPedido $status,
public readonly TipoDePedido $tipo,
public readonly \DateTimeImmutable $data,
public readonly array $itens,
) {}
public function estaConfirmado(): bool
{
return $this->status === StatusDoPedido::Confirmado;
}
public function totalEmCentavos(): int
{
return array_sum(array_map(
static fn (ItemDoPedido $item): int => $item->subtotalEmCentavos(),
$this->itens,
));
}
}
final class TabelaDeDescontos
{
/** Faixas em centavos, da maior para a menor — a ordem é a regra. */
private const FAIXAS = [
100_000 => 10, // acima de R$ 1.000,00 → 10%
50_000 => 5, // acima de R$ 500,00 → 5%
];
public function paraTotal(int $totalEmCentavos): int
{
foreach (self::FAIXAS as $piso => $percentual) {
if ($totalEmCentavos > $piso) {
// intdiv em vez de /100: desconto é dinheiro, e dinheiro
// não tem fração de centavo.
return intdiv($totalEmCentavos * $percentual, 100);
}
}
return 0;
}
}
final class LinhaDoRelatorio
{
public function __construct(
public readonly int $pedidoId,
public readonly string $cliente,
public readonly int $totalEmCentavos,
public readonly int $descontoEmCentavos,
public readonly \DateTimeImmutable $data,
) {}
public function valorFinalEmCentavos(): int
{
return $this->totalEmCentavos - $this->descontoEmCentavos;
}
public function dataFormatada(): string
{
return $this->data->format('d/m/Y');
}
}
final class RelatorioDeFaturamento
{
public function __construct(private readonly TabelaDeDescontos $descontos) {}
/**
* @param list<Pedido> $pedidos
* @return list<LinhaDoRelatorio> do maior valor final para o menor
*/
public function gerar(array $pedidos, ?TipoDePedido $tipo = null): array
{
// O corpo lê como uma frase: filtra, transforma, ordena.
$linhas = array_map(
$this->emLinha(...),
array_filter($pedidos, fn (Pedido $p): bool => $this->entraNoRelatorio($p, $tipo)),
);
$linhas = array_values(array_filter(
$linhas,
static fn (LinhaDoRelatorio $l): bool => $l->totalEmCentavos > 0,
));
usort(
$linhas,
// <=> em vez de subtração: com valores grandes a subtração
// estoura o int, e com float ela devolve 0.4 — que usort()
// trunca para 0 e trata como empate.
static fn (LinhaDoRelatorio $a, LinhaDoRelatorio $b): int
=> $b->valorFinalEmCentavos() <=> $a->valorFinalEmCentavos(),
);
return $linhas;
}
private function entraNoRelatorio(Pedido $pedido, ?TipoDePedido $tipo): bool
{
// Guard clauses: cada motivo de exclusão, uma linha.
if (!$pedido->estaConfirmado()) {
return false;
}
return $tipo === null || $pedido->tipo === $tipo;
}
private function emLinha(Pedido $pedido): LinhaDoRelatorio
{
$total = $pedido->totalEmCentavos();
return new LinhaDoRelatorio(
pedidoId: $pedido->id,
cliente: $pedido->cliente,
totalEmCentavos: $total,
descontoEmCentavos: $this->descontos->paraTotal($total),
data: $pedido->data,
);
}
}
// ---------------------------------------- tests/RelatorioTest.php
declare(strict_types=1);
namespace MeuProjeto\Tests\Faturamento;
use MeuProjeto\Faturamento\ItemDoPedido;
use MeuProjeto\Faturamento\Pedido;
use MeuProjeto\Faturamento\RelatorioDeFaturamento;
use MeuProjeto\Faturamento\StatusDoPedido;
use MeuProjeto\Faturamento\TabelaDeDescontos;
use MeuProjeto\Faturamento\TipoDePedido;
use PHPUnit\Framework\TestCase;
final class RelatorioTest extends TestCase
{
private function pedido(int $id, StatusDoPedido $status, int $centavos): Pedido
{
return new Pedido(
id: $id,
cliente: "Cliente {$id}",
status: $status,
tipo: TipoDePedido::Online,
data: new \DateTimeImmutable('2026-03-15'),
itens: [new ItemDoPedido(1, $centavos)],
);
}
public function testSoEntramPedidosConfirmados(): void
{
$relatorio = new RelatorioDeFaturamento(new TabelaDeDescontos());
$linhas = $relatorio->gerar([
$this->pedido(1, StatusDoPedido::Confirmado, 20_000),
$this->pedido(2, StatusDoPedido::Cancelado, 90_000),
$this->pedido(3, StatusDoPedido::Rascunho, 90_000),
]);
$this->assertCount(1, $linhas);
$this->assertSame(1, $linhas[0]->pedidoId);
}
public function testDescontoRespeitaAFaixa(): void
{
$relatorio = new RelatorioDeFaturamento(new TabelaDeDescontos());
$linhas = $relatorio->gerar([
$this->pedido(1, StatusDoPedido::Confirmado, 150_000), // 10%
$this->pedido(2, StatusDoPedido::Confirmado, 70_000), // 5%
$this->pedido(3, StatusDoPedido::Confirmado, 10_000), // 0%
]);
// Ordenado por valor final decrescente.
$this->assertSame([15_000, 3_500, 0], array_map(
static fn ($linha): int => $linha->descontoEmCentavos,
$linhas,
));
}
public function testExatamenteNoPisoNaoGanhaDesconto(): void
{
// O caso de fronteira que o código antigo escondia atrás do `>`:
// 1000,00 exatos NÃO ganham 10%. Agora está documentado num teste.
$relatorio = new RelatorioDeFaturamento(new TabelaDeDescontos());
$linhas = $relatorio->gerar([$this->pedido(1, StatusDoPedido::Confirmado, 100_000)]);
$this->assertSame(5_000, $linhas[0]->descontoEmCentavos);
}
}
Exercício 2
Implemente uma hierarquia de exceções para um domínio de sua escolha: DomainException como base, e pelo menos três exceções específicas com dados contextuais nos construtores.
Ver resposta
✓ Resposta: A raiz comum é o que paga a hierarquia: um catch (ErroDeAssinatura $e) trata todas as regras de negócio de uma vez, sem varrer junto TypeError e PDOException — que são defeito e falha de infraestrutura, não decisão do domínio. As outras duas escolhas que se pagam sozinhas: duas mensagens por exceção (a técnica com dados, para o log; a segura, para o usuário) e previous: ao traduzir a falha do gateway — perder a causa original é perder a única pista que resolveria o chamado.
<?php
// ---------------------------------------- src/Assinatura/ErroDeAssinatura.php
declare(strict_types=1);
namespace MeuProjeto\Assinatura;
use DomainException;
/**
* A base do domínio. Existir uma raiz própria é o que permite ao controller
* escrever `catch (ErroDeAssinatura $e)` e tratar de uma vez tudo que é
* "regra de negócio violada" — sem varrer junto TypeError e PDOException,
* que são defeito de programação e falha de infraestrutura.
*
* Extende DomainException (SPL) porque é literalmente o que significa: o
* argumento é válido em tipo, mas inválido para as regras deste domínio.
*/
abstract class ErroDeAssinatura extends DomainException
{
/** Texto seguro para o usuário final — sem nome de tabela, sem id interno. */
abstract public function mensagemParaUsuario(): string;
/** Código estável para a API e para o front. */
abstract public function codigo(): string;
/**
* Contexto estruturado para o log. Fica separado da mensagem de propósito:
* a mensagem serve para agrupar ocorrências, o contexto para investigar
* uma delas.
*
* @return array<string, mixed>
*/
public function contexto(): array
{
return [];
}
public function statusHttp(): int
{
return 422;
}
}
// ---------------------------------------- src/Assinatura/PlanoIndisponivel.php
declare(strict_types=1);
namespace MeuProjeto\Assinatura;
final class PlanoIndisponivel extends ErroDeAssinatura
{
/** @param list<string> $planosDisponiveis */
public function __construct(
public readonly string $planoPedido,
public readonly array $planosDisponiveis,
) {
// A mensagem do construtor é a TÉCNICA — vai para o log, com dados.
parent::__construct(sprintf(
'Plano "%s" indisponível. Disponíveis: %s.',
$planoPedido,
implode(', ', $planosDisponiveis) ?: 'nenhum',
));
}
public function mensagemParaUsuario(): string
{
return 'Este plano não está mais disponível. Escolha outro para continuar.';
}
public function codigo(): string
{
return 'PLANO_INDISPONIVEL';
}
public function contexto(): array
{
return [
'plano_pedido' => $this->planoPedido,
'planos_disponiveis' => $this->planosDisponiveis,
];
}
}
// ---------------------------------------- src/Assinatura/AssinaturaJaAtiva.php
declare(strict_types=1);
namespace MeuProjeto\Assinatura;
use DateTimeImmutable;
final class AssinaturaJaAtiva extends ErroDeAssinatura
{
public function __construct(
public readonly int $clienteId,
public readonly string $planoAtual,
public readonly DateTimeImmutable $renovaEm,
) {
parent::__construct(sprintf(
'Cliente %d já assina o plano %s até %s.',
$clienteId, $planoAtual, $renovaEm->format('Y-m-d'),
));
}
public function mensagemParaUsuario(): string
{
// Dado do próprio cliente pode aparecer para ele; id interno, não.
return sprintf(
'Você já tem o plano %s ativo até %s. Para trocar, use a opção "Mudar de plano".',
$this->planoAtual,
$this->renovaEm->format('d/m/Y'),
);
}
public function codigo(): string
{
return 'ASSINATURA_JA_ATIVA';
}
public function contexto(): array
{
return [
'cliente_id' => $this->clienteId,
'plano_atual' => $this->planoAtual,
'renova_em' => $this->renovaEm->format(DATE_ATOM),
];
}
public function statusHttp(): int
{
// 409: o pedido está correto, o ESTADO do recurso é que conflita.
return 409;
}
}
// ---------------------------------------- src/Assinatura/PagamentoRecusado.php
declare(strict_types=1);
namespace MeuProjeto\Assinatura;
final class PagamentoRecusado extends ErroDeAssinatura
{
public function __construct(
public readonly int $clienteId,
public readonly string $motivoDoGateway,
public readonly int $valorEmCentavos,
public readonly ?string $codigoDoGateway = null,
?\Throwable $anterior = null,
) {
parent::__construct(
sprintf('Pagamento de %d centavos recusado: %s', $valorEmCentavos, $motivoDoGateway),
previous: $anterior, // encadeamento: a causa original não se perde
);
}
public function mensagemParaUsuario(): string
{
// O motivo cru do gateway ("AVS mismatch", "3DS challenge failed")
// não ajuda ninguém e às vezes entrega informação sobre o cartão.
return 'Não conseguimos processar o pagamento. Confira os dados do cartão ou tente outro.';
}
public function codigo(): string
{
return 'PAGAMENTO_RECUSADO';
}
public function contexto(): array
{
return [
'cliente_id' => $this->clienteId,
'valor_centavos' => $this->valorEmCentavos,
'motivo_gateway' => $this->motivoDoGateway,
'codigo_gateway' => $this->codigoDoGateway,
];
}
public function statusHttp(): int
{
return 402; // Payment Required
}
}
// ---------------------------------------- src/Assinatura/ContratarPlano.php
declare(strict_types=1);
namespace MeuProjeto\Assinatura;
use Throwable;
final class ContratarPlano
{
public function __construct(
private readonly CatalogoDePlanos $catalogo,
private readonly Assinaturas $assinaturas,
private readonly Gateway $gateway,
) {}
public function executar(int $clienteId, string $codigoDoPlano): Assinatura
{
$plano = $this->catalogo->porCodigo($codigoDoPlano)
// O caso de uso não sabe montar HTTP nem HTML: ele lança o
// erro do domínio e alguém lá fora decide o que fazer com ele.
?? throw new PlanoIndisponivel($codigoDoPlano, $this->catalogo->codigosAtivos());
$atual = $this->assinaturas->ativaDoCliente($clienteId);
if ($atual !== null) {
throw new AssinaturaJaAtiva($clienteId, $atual->plano, $atual->renovaEm);
}
try {
$cobranca = $this->gateway->cobrar($clienteId, $plano->precoEmCentavos);
} catch (Throwable $e) {
// Traduz a falha da infraestrutura para o vocabulário do
// domínio, sem PERDER a causa: ela vai em `previous`.
throw new PagamentoRecusado(
clienteId: $clienteId,
motivoDoGateway: $e->getMessage(),
valorEmCentavos: $plano->precoEmCentavos,
anterior: $e,
);
}
if (!$cobranca->aprovada) {
throw new PagamentoRecusado(
clienteId: $clienteId,
motivoDoGateway: $cobranca->motivo,
valorEmCentavos: $plano->precoEmCentavos,
codigoDoGateway: $cobranca->codigo,
);
}
return $this->assinaturas->criar($clienteId, $plano, $cobranca);
}
}
// ---------------------------------------- src/Http/TratadorDeErros.php
declare(strict_types=1);
namespace MeuProjeto\Http;
use MeuProjeto\Assinatura\ErroDeAssinatura;
use Psr\Log\LoggerInterface;
use Throwable;
final class TratadorDeErros
{
public function __construct(
private readonly LoggerInterface $logger,
private readonly bool $debug = false,
) {}
/** @return array{status: int, corpo: array<string, mixed>} */
public function tratar(Throwable $e): array
{
// UM catch para todo o domínio — o ganho concreto da raiz comum.
if ($e instanceof ErroDeAssinatura) {
$this->logger->warning($e->getMessage(), $e->contexto());
return [
'status' => $e->statusHttp(),
'corpo' => [
'erro' => $e->codigo(),
'mensagem' => $e->mensagemParaUsuario(),
],
];
}
// Qualquer outra coisa é defeito: log completo, resposta genérica.
$this->logger->error('Erro não tratado', ['exception' => $e]);
return [
'status' => 500,
'corpo' => [
'erro' => 'ERRO_INTERNO',
'mensagem' => 'Algo deu errado. Já fomos avisados.',
// getMessage() de exceção interna vaza caminho de arquivo,
// consulta SQL e às vezes credencial. Só em debug.
...($this->debug ? ['detalhe' => $e->getMessage()] : []),
],
];
}
}
Exercício 3
Crie uma classe Dinheiro completamente imutável com readonly, final, e sem setters. Implemente: somar(), subtrair(), multiplicar(), dividir(), aplicarDesconto(), equals(). Escreva testes para cada método.
Ver resposta
✓ Resposta: Dois detalhes que separam este Dinheiro de um float com nome bonito. O primeiro é o round() antes do cast: (int)(0.29 * 100) dá 28, porque 0,29 não tem representação exata em binário — é um centavo perdido por linha de pedido. O segundo é o repartir(): dividir R$ 10,00 por 3 tem de devolver 3,34 + 3,33 + 3,33, e não três parcelas de 3,33 que somam 9,99. É o tipo de centavo que só aparece no fechamento contábil, quando ninguém mais lembra de onde veio.
<?php
// ---------------------------------------- src/Suporte/Moeda.php
declare(strict_types=1);
namespace MeuProjeto\Suporte;
enum Moeda: string
{
case BRL = 'BRL';
case USD = 'USD';
case EUR = 'EUR';
public function simbolo(): string
{
return match ($this) {
self::BRL => 'R$',
self::USD => 'US$',
self::EUR => '€',
};
}
}
// ---------------------------------------- src/Suporte/Dinheiro.php
declare(strict_types=1);
namespace MeuProjeto\Suporte;
use DivisionByZeroError;
use InvalidArgumentException;
use JsonSerializable;
use Stringable;
/**
* `final` e `readonly` na classe inteira (PHP 8.2): nenhuma propriedade
* aceita escrita depois do construtor, nem por herança, nem por reflexão
* comum. Nasce válido e continua válido.
*
* Todo método que "modifica" devolve uma INSTÂNCIA NOVA. É o que torna
* seguro passar um Dinheiro adiante: quem recebe não consegue alterar o seu.
*/
final readonly class Dinheiro implements JsonSerializable, Stringable
{
public function __construct(
public int $centavos,
public Moeda $moeda = Moeda::BRL,
) {}
// ---- construtores nomeados: cada origem com sua conversão ----
public static function zero(Moeda $moeda = Moeda::BRL): self
{
return new self(0, $moeda);
}
public static function deReais(int|float|string $valor, Moeda $moeda = Moeda::BRL): self
{
if (is_string($valor)) {
// "1.234,56" (pt-BR) e "1234.56" (banco) chegam aqui.
$valor = (float) str_replace(',', '.', str_replace('.', '', $valor));
}
// round ANTES do cast: (int) trunca, e (int)(0.29 * 100) dá 28 —
// o 0.29 não é exato em binário. Um centavo perdido por linha.
return new self((int) round((float) $valor * 100), $moeda);
}
// ---- operações: todas devolvem instância nova ----
public function somar(self $outro): self
{
return new self($this->centavos + $this->mesmaMoeda($outro)->centavos, $this->moeda);
}
public function subtrair(self $outro): self
{
return new self($this->centavos - $this->mesmaMoeda($outro)->centavos, $this->moeda);
}
public function multiplicar(int|float $fator): self
{
return new self((int) round($this->centavos * $fator), $this->moeda);
}
public function dividir(int|float $divisor): self
{
if ((float) $divisor === 0.0) {
throw new DivisionByZeroError('Divisão de dinheiro por zero.');
}
return new self((int) round($this->centavos / $divisor), $this->moeda);
}
public function aplicarDesconto(float $percentual): self
{
if ($percentual < 0 || $percentual > 100) {
throw new InvalidArgumentException(
"Percentual fora da faixa 0–100: {$percentual}"
);
}
$desconto = (int) round($this->centavos * $percentual / 100);
return new self($this->centavos - $desconto, $this->moeda);
}
/**
* Divisão SEM perder centavo: o resto é distribuído nas primeiras
* partes. Dividir R$ 10,00 por 3 devolve 3,34 + 3,33 + 3,33 — soma
* exata. Um `dividir(3)` ingênuo devolveria 3,33 três vezes e sumiria
* com um centavo, que em relatório contábil ninguém consegue explicar.
*
* @return list<self>
*/
public function repartir(int $partes): array
{
if ($partes < 1) {
throw new InvalidArgumentException('Número de partes tem de ser ≥ 1.');
}
$base = intdiv($this->centavos, $partes);
$resto = $this->centavos % $partes;
return array_map(
fn (int $i): self => new self($base + ($i < $resto ? 1 : 0), $this->moeda),
range(0, $partes - 1),
);
}
// ---- comparação: por VALOR, nunca por identidade ----
public function equals(self $outro): bool
{
// A moeda faz parte da identidade: 100 centavos de BRL não são
// 100 centavos de USD.
return $this->centavos === $outro->centavos && $this->moeda === $outro->moeda;
}
public function maiorQue(self $outro): bool
{
return $this->centavos > $this->mesmaMoeda($outro)->centavos;
}
public function menorQue(self $outro): bool
{
return $this->centavos < $this->mesmaMoeda($outro)->centavos;
}
public function ehZero(): bool
{
return $this->centavos === 0;
}
public function ehNegativo(): bool
{
return $this->centavos < 0;
}
// ---- saída ----
public function formatado(): string
{
return sprintf(
'%s %s',
$this->moeda->simbolo(),
number_format($this->centavos / 100, 2, ',', '.'),
);
}
public function __toString(): string
{
return $this->formatado();
}
/** @return array{centavos: int, moeda: string, formatado: string} */
public function jsonSerialize(): array
{
// Na API vai o inteiro, não o float: JSON com 19.99 volta como
// 19.989999999999998 em alguns clientes.
return [
'centavos' => $this->centavos,
'moeda' => $this->moeda->value,
'formatado' => $this->formatado(),
];
}
private function mesmaMoeda(self $outro): self
{
if ($outro->moeda !== $this->moeda) {
throw new InvalidArgumentException(sprintf(
'Operação entre moedas diferentes: %s e %s. Converta antes.',
$this->moeda->value,
$outro->moeda->value,
));
}
return $outro;
}
}
// ---------------------------------------- tests/DinheiroTest.php
declare(strict_types=1);
namespace MeuProjeto\Tests\Suporte;
use DivisionByZeroError;
use InvalidArgumentException;
use MeuProjeto\Suporte\Dinheiro;
use MeuProjeto\Suporte\Moeda;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
final class DinheiroTest extends TestCase
{
public function testSomarNaoAlteraOsOperandos(): void
{
$a = Dinheiro::deReais(10);
$b = Dinheiro::deReais(5);
$soma = $a->somar($b);
// O coração da imutabilidade: o teste que falharia se alguém
// trocasse `return new self(...)` por `$this->centavos += ...`.
$this->assertSame(1000, $a->centavos);
$this->assertSame(500, $b->centavos);
$this->assertSame(1500, $soma->centavos);
}
public function testSubtrairPodeFicarNegativo(): void
{
// Saldo negativo é legítimo (estorno, crédito). Quem proíbe é a
// regra de negócio, não o objeto de valor.
$resultado = Dinheiro::deReais(10)->subtrair(Dinheiro::deReais(25));
$this->assertSame(-1500, $resultado->centavos);
$this->assertTrue($resultado->ehNegativo());
}
#[DataProvider('multiplicacoes')]
public function testMultiplicarArredondaCorretamente(int $centavos, float $fator, int $esperado): void
{
$this->assertSame($esperado, (new Dinheiro($centavos))->multiplicar($fator)->centavos);
}
/** @return iterable<string, array{int, float, int}> */
public static function multiplicacoes(): iterable
{
yield 'inteiro' => [1000, 3.0, 3000];
yield 'meio centavo p/ cima' => [ 101, 1.5, 152]; // 151,5 → 152
yield 'meio centavo p/ baixo'=> [ 100, 1.005, 101]; // 100,5 → 101
yield 'zero' => [1000, 0.0, 0];
}
public function testDividirPorZeroExplode(): void
{
$this->expectException(DivisionByZeroError::class);
Dinheiro::deReais(10)->dividir(0);
}
public function testRepartirNaoPerdeCentavo(): void
{
$partes = Dinheiro::deReais(10)->repartir(3);
$this->assertSame([334, 333, 333], array_map(
static fn (Dinheiro $d): int => $d->centavos,
$partes,
));
// A propriedade que realmente importa: a soma volta ao original.
$this->assertSame(1000, array_sum(array_map(
static fn (Dinheiro $d): int => $d->centavos,
$partes,
)));
}
public function testAplicarDesconto(): void
{
$this->assertSame(9000, Dinheiro::deReais(100)->aplicarDesconto(10)->centavos);
$this->assertSame( 0, Dinheiro::deReais(100)->aplicarDesconto(100)->centavos);
$this->assertSame(10000, Dinheiro::deReais(100)->aplicarDesconto(0)->centavos);
}
public function testDescontoForaDaFaixaERecusado(): void
{
$this->expectException(InvalidArgumentException::class);
Dinheiro::deReais(100)->aplicarDesconto(120);
}
public function testEqualsComparaValorEMoeda(): void
{
$this->assertTrue(Dinheiro::deReais(10)->equals(new Dinheiro(1000)));
$this->assertFalse(
(new Dinheiro(1000, Moeda::BRL))->equals(new Dinheiro(1000, Moeda::USD))
);
}
public function testOperacaoEntreMoedasDiferentesERecusada(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessageMatches('/moedas diferentes/');
(new Dinheiro(100, Moeda::BRL))->somar(new Dinheiro(100, Moeda::USD));
}
#[DataProvider('entradasDeTexto')]
public function testDeReaisAceitaOsFormatosDoMundoReal(string $entrada, int $esperado): void
{
$this->assertSame($esperado, Dinheiro::deReais($entrada)->centavos);
}
/** @return iterable<string, array{string, int}> */
public static function entradasDeTexto(): iterable
{
yield 'pt-BR com milhar' => ['1.234,56', 123456];
yield 'pt-BR simples' => ['19,90', 1990];
yield 'ponto decimal' => ['19.90', 1990];
yield 'inteiro' => ['20', 2000];
}
public function testOClassicoDoFloat(): void
{
// Em float: 0.1 + 0.2 === 0.3 é FALSE. Em centavos, não há dúvida.
$soma = Dinheiro::deReais('0,10')->somar(Dinheiro::deReais('0,20'));
$this->assertSame(30, $soma->centavos);
$this->assertTrue($soma->equals(Dinheiro::deReais('0,30')));
}
public function testFormatacaoBrasileira(): void
{
$this->assertSame('R$ 1.234,56', (new Dinheiro(123456))->formatado());
$this->assertSame('US$ 19,90', (new Dinheiro(1990, Moeda::USD))->formatado());
}
}
Exercício 4
Desafio: refatore uma função longa (20+ linhas) extraindo subfunções nomeadas que expliquem cada etapa do processo. O objetivo é que o corpo da função original leia como uma sequência de frases em português: $this->validarDados($dados); $usuario = $this->criarUsuario($dados); $this->notificar($usuario);
Ver resposta
✓ Resposta: O teste da refatoração é ler executar() em voz alta: "recusar se o e-mail já existe, criar o usuário com preferências, enviar a confirmação, registrar no log". Quatro linhas contam o processo inteiro. Duas decisões escondidas no meio valem tanto quanto a extração: o e-mail sai fora da transação — efeito externo não volta com rollback, e confirmação de conta inexistente é pior que cadastro sem e-mail — e a validação virou o construtor de DadosDeCadastro, o que faz "dados inválidos" deixar de ser um estado possível daí para a frente.
<?php
// ---------- ANTES: 58 linhas, sete responsabilidades, um nome ------------
declare(strict_types=1);
namespace MeuProjeto\Cadastro;
final class CadastroAntes
{
public function registrar(array $dados): array
{
if (empty($dados['nome']) || strlen($dados['nome']) < 3) {
return ['ok' => false, 'erro' => 'Nome inválido'];
}
if (empty($dados['email']) || !filter_var($dados['email'], FILTER_VALIDATE_EMAIL)) {
return ['ok' => false, 'erro' => 'E-mail inválido'];
}
if (empty($dados['senha']) || strlen($dados['senha']) < 8) {
return ['ok' => false, 'erro' => 'Senha muito curta'];
}
$stmt = $this->pdo->prepare('SELECT id FROM usuarios WHERE email = ?');
$stmt->execute([$dados['email']]);
if ($stmt->fetch()) {
return ['ok' => false, 'erro' => 'E-mail já cadastrado'];
}
$hash = password_hash($dados['senha'], PASSWORD_DEFAULT);
$token = bin2hex(random_bytes(32));
$this->pdo->beginTransaction();
try {
$ins = $this->pdo->prepare(
'INSERT INTO usuarios (nome, email, senha_hash, token_confirmacao, criado_em)
VALUES (?, ?, ?, ?, NOW())'
);
$ins->execute([$dados['nome'], $dados['email'], $hash, $token]);
$id = (int) $this->pdo->lastInsertId();
$pref = $this->pdo->prepare(
'INSERT INTO preferencias (usuario_id, newsletter, tema) VALUES (?, 1, ?)'
);
$pref->execute([$id, $dados['tema'] ?? 'claro']);
$this->pdo->commit();
} catch (\Throwable $e) {
$this->pdo->rollBack();
return ['ok' => false, 'erro' => 'Falha ao salvar'];
}
$link = 'https://app.exemplo.com/confirmar?token=' . $token;
mail($dados['email'], 'Confirme seu cadastro', "Acesse: {$link}");
$log = fopen('/var/log/app/cadastros.log', 'a');
fwrite($log, date('c') . " cadastro {$id}\n");
fclose($log);
return ['ok' => true, 'id' => $id];
}
}
// ---------- DEPOIS: o corpo lê como uma sequência de frases -------------
declare(strict_types=1);
namespace MeuProjeto\Cadastro;
use MeuProjeto\Suporte\Relogio;
use Psr\Log\LoggerInterface;
use Throwable;
final class RegistrarUsuario
{
public function __construct(
private readonly UsuarioRepositorio $usuarios,
private readonly PreferenciaRepositorio $preferencias,
private readonly EnviadorDeEmail $email,
private readonly LoggerInterface $logger,
private readonly Relogio $relogio,
) {}
/**
* O objetivo da refatoração está aqui: este corpo se lê em voz alta.
* Cada linha é uma etapa nomeada, e quem quiser o detalhe desce um
* nível — mas ninguém é obrigado a descer para entender o processo.
*/
public function executar(DadosDeCadastro $dados): Usuario
{
$this->recusarSeEmailJaExiste($dados->email);
$usuario = $this->criarUsuarioComPreferencias($dados);
$this->enviarConfirmacao($usuario);
$this->registrarNoLog($usuario);
return $usuario;
}
private function recusarSeEmailJaExiste(string $email): void
{
if ($this->usuarios->existePorEmail($email)) {
// Sem valor de retorno de erro: quem falha, lança. O caminho
// feliz do método fica sem um único `if` de controle de fluxo.
throw new EmailJaCadastrado($email);
}
}
private function criarUsuarioComPreferencias(DadosDeCadastro $dados): Usuario
{
$this->usuarios->iniciarTransacao();
try {
$usuario = $this->usuarios->criar(
nome: $dados->nome,
email: $dados->email,
senhaHash: password_hash($dados->senha, PASSWORD_DEFAULT),
tokenDeConfirmacao: bin2hex(random_bytes(32)),
criadoEm: $this->relogio->agora(),
);
$this->preferencias->criarPadrao($usuario->id, $dados->tema);
$this->usuarios->confirmarTransacao();
return $usuario;
} catch (Throwable $e) {
$this->usuarios->desfazerTransacao();
throw new FalhaAoCadastrar('Não foi possível concluir o cadastro.', previous: $e);
}
}
private function enviarConfirmacao(Usuario $usuario): void
{
// Fora da transação de propósito: e-mail é efeito externo e não se
// desfaz com rollback. Melhor cadastro salvo sem e-mail (o usuário
// pede outro) do que e-mail de confirmação para conta inexistente.
$this->email->enviarConfirmacaoDeCadastro($usuario);
}
private function registrarNoLog(Usuario $usuario): void
{
$this->logger->info('Usuário {id} cadastrado', [
'id' => $usuario->id,
'email' => $usuario->email,
]);
}
}
// ---------------------------------------- src/Cadastro/DadosDeCadastro.php
declare(strict_types=1);
namespace MeuProjeto\Cadastro;
/**
* A validação saiu do caso de uso e virou o construtor de um tipo. Depois
* daqui, "dados inválidos" deixa de ser um estado possível — não há como
* ter um DadosDeCadastro com e-mail quebrado.
*/
final readonly class DadosDeCadastro
{
public function __construct(
public string $nome,
public string $email,
public string $senha,
public string $tema = 'claro',
) {
$erros = [];
if (mb_strlen(trim($nome)) < 3) {
$erros['nome'] = 'O nome precisa de pelo menos 3 caracteres.';
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
$erros['email'] = 'E-mail inválido.';
}
// Comprimento em BYTES: o bcrypt trunca em 72 e, acima disso, duas
// senhas diferentes passam a abrir a mesma conta.
if (strlen($senha) < 8 || strlen($senha) > 72) {
$erros['senha'] = 'A senha precisa ter de 8 a 72 caracteres.';
}
if (!in_array($tema, ['claro', 'escuro'], true)) {
$erros['tema'] = 'Tema inválido.';
}
if ($erros !== []) {
// TODOS os erros de uma vez: o formulário mostra a lista
// completa em vez de devolver um problema por tentativa.
throw new DadosInvalidos($erros);
}
}
/** @param array<string, mixed> $post */
public static function doFormulario(array $post): self
{
return new self(
nome: trim((string) ($post['nome'] ?? '')),
email: strtolower(trim((string) ($post['email'] ?? ''))),
senha: (string) ($post['senha'] ?? ''),
tema: (string) ($post['tema'] ?? 'claro'),
);
}
}
// ---------------------------------------- src/Cadastro/DadosInvalidos.php
declare(strict_types=1);
namespace MeuProjeto\Cadastro;
use DomainException;
final class DadosInvalidos extends DomainException
{
/** @param array<string, string> $erros campo → mensagem */
public function __construct(public readonly array $erros)
{
parent::__construct('Dados de cadastro inválidos: ' . implode('; ', $erros));
}
}
// ---------------------------------------- public/cadastro.php (controller)
declare(strict_types=1);
use MeuProjeto\Cadastro\DadosDeCadastro;
use MeuProjeto\Cadastro\DadosInvalidos;
use MeuProjeto\Cadastro\EmailJaCadastrado;
use MeuProjeto\Cadastro\FalhaAoCadastrar;
use MeuProjeto\Cadastro\RegistrarUsuario;
$container = require __DIR__ . '/../config/container.php';
// O controller também virou uma sequência de frases: traduzir a entrada,
// executar o caso de uso, traduzir a saída. Nenhuma regra de negócio aqui.
try {
$usuario = $container->get(RegistrarUsuario::class)
->executar(DadosDeCadastro::doFormulario($_POST));
header('Location: /confirme-seu-email', true, 303);
exit;
} catch (DadosInvalidos $e) {
$erros = $e->erros;
} catch (EmailJaCadastrado $e) {
$erros = ['email' => 'Este e-mail já está cadastrado.'];
} catch (FalhaAoCadastrar $e) {
$erros = ['geral' => 'Não conseguimos concluir agora. Tente novamente em instantes.'];
}
require __DIR__ . '/../templates/cadastro.php';
Exercício 5
O artigo abre mandando escrever declare(strict_types=1) em todo arquivo, mas não diz o que a diretiva verifica — e o que ela não verifica. Monte dois arquivos: lib.php, com a diretiva e uma função somar(int $a, int $b): int; e chamador.php, sem a diretiva, que dá require 'lib.php' e chama somar("5", "7"). O que acontece? Em seguida, ainda em modo estrito, descubra o que o PHP faz com media(3) numa função que pede float, com g(5.0) numa que pede int, e com "10" + 5. Execute tudo antes de responder.
Ver resposta
✓ Resposta: somar("5", "7") devolve int(12), sem erro nenhum — e essa é a resposta que surpreende. A diretiva vale para o arquivo que faz a chamada, não para o arquivo que declara a função. Ela é uma propriedade do call site: lib.php pode estar coberto de declare(strict_types=1) que, se quem chama não declarou, o PHP coage os argumentos como sempre fez. Daí a regra prática que o artigo deixa implícita: a diretiva é a primeira linha de todo arquivo, sem exceção, porque um único arquivo esquecido abre a porta para a base inteira.
Movendo a diretiva para o chamador, a mesma linha lança TypeError: somar(): Argument #1 ($a) must be of type int, string given. E aí aparecem os três casos que costumam ser lidos errado:
<?php
// ---------------------------------------- lib.php
declare(strict_types=1);
function somar(int $a, int $b): int { return $a + $b; }
function media(float $x): float { return $x; }
function g(int $n): int { return $n; }
// ---------------------------------------- chamador_frouxo.php
<?php
// SEM declare — é ESTE arquivo que decide
require 'lib.php';
var_dump(somar("5", "7")); // int(12) ← a diretiva do lib.php não vale aqui
// ---------------------------------------- chamador_estrito.php
<?php
declare(strict_types=1);
require 'lib.php';
somar("5", "7"); // TypeError: Argument #1 ($a) must be of type int, string given
// 1. int → float AINDA é aceito no modo estrito. É a única coerção que
// sobrevive, e é proposital: todo int cabe num float sem perda.
var_dump(media(3)); // double(3)
media("3"); // TypeError — numérica ou não, string é string
// 2. O caminho inverso NÃO existe, nem com valor exato.
g(5.0); // TypeError: must be of type int, float given
// 3. strict_types governa ARGUMENTOS e RETORNOS. Não governa operadores.
var_dump("10" + 5); // int(15) — aritmética coage como sempre
// 4. Funções internas entram no acordo junto com as suas.
str_repeat("ab", "3"); // TypeError
// 5. Propriedade tipada é verificada SEMPRE, com ou sem a diretiva —
// a mensagem nem é de TypeError de argumento.
class C { public int $n = 0; }
(new C)->n = "7"; // TypeError: Cannot assign string to property C::$n of type int
Resumindo o que o modo estrito entrega: ele fecha a coerção de escalares em argumentos e valores de retorno, no arquivo que declarou a diretiva, preservando apenas o alargamento int → float. Fora disso — aritmética, concatenação, comparação frouxa com ==, chave de array — o PHP continua convertendo como antes. É por isso que declare(strict_types=1) é o começo da conversa sobre tipos, e não o fim dela: os value objects que o artigo defende existem exatamente para cobrir o que a diretiva não alcança.