Boas Práticas e Código Limpo em PHP

Boas Práticas e Código Limpo em PHP

Tipos declarados, nomes que dispensam comentário, funções curtas, early return no lugar do aninhamento e imutabilidade onde couber — cada princípio com o antes e o depois em código que roda, mais a demonstração de que strict_types vale para quem chama, não para quem declara.
PHP

• • 32 min de leitura

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

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.

Comentários

Mais em PHP

Funções
Funções

Uma função guarda um trecho de lógica sob um nome e um contrato. Como declarar…

Introdução ao Laravel
Introdução ao Laravel

Instalação, estrutura de diretórios, rotas, controllers, Form Requests e o…

Estruturas de Controle
Estruturas de Controle

Decidir é o que separa um script de um programa. Como o PHP encadeia condições…