Conteúdo gerado com IA Este artigo foi produzido com assistência de IA a partir do roadmap em github.com/Fabriciope/php-studies. A estrutura e a sequência das fases refletem o plano de estudos do autor; as explicações foram expandidas para servir de referência prática. Cheque a documentação oficial e RFCs antes de aplicar qualquer trecho em produção.

PHP virou outra linguagem entre a 7.0 e a 8.4. Se a sua noção de PHP ainda é "script com $_GET e mysql_query", você está perdendo tipos estritos, enums, readonly, property hooks, asymmetric visibility, fibers, JIT, FrankenPHP e um ecossistema de ferramentas estáticas que coloca o ambiente em paridade com Java moderno e Kotlin. Este artigo é a versão expandida de um currículo de 16 semanas para sair de "PHP que funciona" para PHP de produção, com profundidade real em cada fase.

O recorte é deliberado. Não tenta cobrir Laravel ou Symfony framework por framework; foca nos fundamentos da linguagem, nos padrões de design, na arquitetura de aplicações e na infraestrutura que sustentam serviços sérios. Frameworks são ferramentas que entregam decisões prontas — entender o que está por baixo é o que diferencia quem escreve código de quem desenha sistemas.

Filosofia

Quatro princípios guiam o roadmap. Eles não são escolhas estéticas: cada um tenta corrigir um vício comum de quem aprende PHP em ordem cronológica (do tutorial de blog até o framework full-stack).

Tipos primeiro

Tipos não são decoração. São o primeiro mecanismo de feedback rápido que você tem antes de rodar teste algum. declare(strict_types=1); no topo de todo arquivo, tipos em parâmetros e retornos sempre, e quando a forma de um valor importa, um Value Object — não um array anônimo. Isso muda a sensação de escrever PHP: o IDE passa a saber o que você quer, o PHPStan passa a achar bugs no --save em vez de em produção, e revisões de código passam a falar de design em vez de "isso aqui é string ou int?".

Build > consume

Você só entende um padrão quando o implementa do zero, com seus próprios trade-offs. Ler um capítulo sobre Repository é diferente de escrever um UserRepository, errar a abstração na primeira tentativa, perceber que a interface vazou detalhes do banco e refatorar. Toda fase do roadmap tem um entregável de código — não um exercício de memória. Implementar mal e ajustar é melhor do que nunca implementar.

Profundidade antes de superfície

É tentador correr atrás do shape novo: framework do ano, ORM da moda, bundler que prometeu acabar com o anterior. O retorno cai rápido. Saber profundamente como o PHP-FPM gerencia processos, como o OPcache invalida bytecode, como o autoloader resolve namespaces — esse conhecimento é o que paga em três anos. O resto rotaciona.

Revisão espaçada

Aprendeu Strategy na semana 5? Reescreva uma variação na semana 7, em outro contexto. O cérebro só consolida o que ele recupera, não o que ele lê. O método de revisão no fim deste artigo é o motor dessa parte.

Notas no repositório 00-Index.md 01-Roadmap.md

Fase 1 — Fundamentos sólidos

Duas semanas. Antes de tocar em qualquer padrão de arquitetura, é preciso destravar o tipo de PHP moderno e entender o ciclo de vida do runtime. Quase todo bug "estranho" em produção vira óbvio quando se sabe o que o FPM faz com a request entre o accept e o response.

Conceito

Quatro pilares: (1) tipos estritos com declare(strict_types=1);, (2) sistema de tipos completo (escalares, union, intersection, never, void, mixed, nullables), (3) hierarquia de exceções (Throwable, Error, Exception) e (4) ciclo de vida do PHP-FPM (master → worker → request → shutdown).

Por que importa

Sem strict_types, PHP faz coerção implícita: passar a string "7" para uma função que espera int simplesmente funciona, e o bug aparece em uma comparação três camadas abaixo. Sem entender o ciclo do FPM, você não sabe por que uma variável estática "vaza" entre requests do mesmo worker, por que um require depois do response causa memória crescente, ou por que o OPcache serve bytecode obsoleto até reload.

Exemplos

strict_types — diferença prática
// arquivo A: SEM strict_types
<?php
function add(int $a, int $b): int {
    return $a + $b;
}
echo add("7", 3); // 10 — PHP coage "7" para 7

// arquivo B: COM strict_types
<?php
declare(strict_types=1);
function add(int $a, int $b): int {
    return $a + $b;
}
echo add("7", 3); // TypeError: Argument #1 ($a) must be of type int, string given

A diferença parece pedante até você herdar 200 mil linhas de código sem strict_types e descobrir que metade das funções "aceitam" tipos errados em silêncio.

Tipos modernos: union, intersection, never
declare(strict_types=1);

// Union: aceita várias formas
function format(int|float $value): string {
    return number_format($value, 2, ',', '.');
}

// Intersection: precisa implementar AMBAS as interfaces
function persist(Countable&Iterator $items): void {
    foreach ($items as $item) { /* ... */ }
}

// Never: a função NUNCA retorna (lança ou sai)
function abort(string $reason): never {
    throw new RuntimeException($reason);
}

// readonly + nullable + tipo escalar
final class Money {
    public function __construct(
        public readonly int    $amountInCents,
        public readonly string $currency,
        public readonly ?string $note = null,
    ) {}
}
Hierarquia de exceções
// Throwable é a raiz — interface, não classe
//   Error      → erros de runtime do engine (TypeError, ParseError, AssertionError)
//   Exception  → erros do seu domínio (RuntimeException, LogicException, etc.)

try {
    $service->process($payload);
} catch (DomainException $e) {
    // erro de regra de negócio — responde 422
    return Response::unprocessable($e->getMessage());
} catch (Throwable $e) {
    // qualquer coisa não tratada — log + 500
    $logger->error('unhandled', ['err' => $e]);
    return Response::serverError();
}

Quando usar / quando evitar

strict_types é não-negociável em código novo. Em código legado, ative arquivo a arquivo conforme refatora — ativar global pode quebrar centenas de chamadas implícitas. Use never para funções que sempre lançam ou saem (ajuda análise estática). Evite mixed a menos que o ponto seja realmente "qualquer coisa" — tipo de retorno de json_decode(), por exemplo. Error nunca deve ser capturada para recuperação, só para logging de último nível.

Armadilhas comuns

  • Esquecer declare(strict_types=1); no topo do arquivo (afeta apenas o arquivo onde está, não o processo todo)
  • Confundir variáveis static com estado entre requests — o worker pode reaproveitar o processo, mas static em método não é cache distribuído
  • Usar catch (\Exception $e) e silenciar Error — em PHP 7+ você precisa de Throwable para pegar tudo
  • Acreditar que OPcache recarrega arquivos automaticamente em produção — por padrão, opcache.validate_timestamps=0 exige reload do FPM

Fase 2 — OOP moderno

Duas semanas. PHP 8.1, 8.2, 8.3 e 8.4 trouxeram um conjunto de features que só fazem sentido quando tomadas juntas: enums, readonly, constructor promotion, first-class callables, attributes, property hooks (8.4) e asymmetric visibility (8.4). Esta fase é sobre internalizar esse vocabulário.

Conceito

OOP moderno em PHP gira em torno de imutabilidade por padrão (readonly), modelagem rica de domínio (enums com métodos, value objects), expressividade no construtor (promotion) e metaprogramação leve (attributes lidos por Reflection). Property hooks e asymmetric visibility eliminam quase todo o boilerplate de getters/setters que PHP arrastou desde 2004.

Por que importa

Boa parte dos bugs vem de mutação acidental: um objeto passado para outra camada e modificado sem rastro. readonly resolve isso no compilador. Enums acabam com a praga das "string constants" usadas como estado. Property hooks permitem expor uma propriedade e ainda ter lógica na leitura/escrita — sem o circo de getName()/setName().

Exemplos

Enum com método e match
declare(strict_types=1);

enum OrderStatus: string {
    case Pending   = 'pending';
    case Paid      = 'paid';
    case Shipped   = 'shipped';
    case Delivered = 'delivered';
    case Cancelled = 'cancelled';

    public function isFinal(): bool {
        return match ($this) {
            self::Delivered, self::Cancelled => true,
            default => false,
        };
    }

    public function canTransitionTo(self $next): bool {
        return match ([$this, $next]) {
            [self::Pending,  self::Paid],
            [self::Pending,  self::Cancelled],
            [self::Paid,     self::Shipped],
            [self::Paid,     self::Cancelled],
            [self::Shipped,  self::Delivered] => true,
            default => false,
        };
    }
}

O enum carrega comportamento. Antes do 8.1, isso seria uma classe com constantes const PENDING = 'pending' + um array de transições válidas em outro lugar — duas coisas que precisavam estar sincronizadas e nunca estavam.

Property hooks (PHP 8.4)
final class User {
    public string $email {
        set (string $value) {
            $value = strtolower(trim($value));
            if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
                throw new InvalidArgumentException("E-mail inválido: {$value}");
            }
            $this->email = $value;
        }
    }

    public string $fullName {
        get => "{$this->firstName} {$this->lastName}";
    }

    public function __construct(
        public string $firstName,
        public string $lastName,
        string        $email,
    ) {
        $this->email = $email;
    }
}

$u = new User('Ana', 'Silva', '  ANA@EXAMPLE.COM ');
echo $u->email;    // ana@example.com
echo $u->fullName; // Ana Silva
Asymmetric visibility (PHP 8.4)
final class Cart {
    public private(set) int $totalCents = 0;

    public function add(Money $price): void {
        $this->totalCents += $price->amountInCents;
    }
}

$cart = new Cart();
echo $cart->totalCents; // ok — leitura pública
$cart->totalCents = 0;  // erro — escrita privada
Attribute + Reflection
#[Attribute(Attribute::TARGET_METHOD)]
final class Route {
    public function __construct(
        public string $method,
        public string $path,
    ) {}
}

final class UserController {
    #[Route('GET', '/users/{id}')]
    public function show(int $id): Response { /* ... */ }
}

// Em algum bootstrap:
$rc = new ReflectionClass(UserController::class);
foreach ($rc->getMethods() as $method) {
    foreach ($method->getAttributes(Route::class) as $attr) {
        $route = $attr->newInstance();
        $router->register($route->method, $route->path, [$rc->getName(), $method->getName()]);
    }
}

Quando usar / quando evitar

Use readonly sempre que um objeto não precisa mutar depois de construído (Value Objects, DTOs, eventos de domínio). Use enums para qualquer conjunto fechado de estados, papéis ou tipos. Property hooks brilham quando há validação ou normalização leve — não jogue regras de negócio complexas dentro deles. Attributes são ótimos para metadados estáticos (rotas, validação, ORM); evite-os para lógica que mude por ambiente.

Armadilhas comuns

  • readonly impede atribuição, mas não impede mutação interna se a propriedade for um array ou objeto mutável — para imutabilidade real, use clone-on-write
  • Enums não podem ter constantes em modo "puro" (sem backing); para constantes use enums com backing string/int
  • Property hooks só funcionam em PHP 8.4+ — confirme a versão do produção antes de adotar
  • Attributes são lidos via Reflection, que é cara em loops — cacheie o resultado em produção (OPcache + arquivo serializado, por exemplo)

Fase 3 — SOLID + Padrões

Duas semanas. SOLID já foi tratado em outro artigo do blog (veja a nota relacionada na barra lateral). Aqui o foco é o conjunto de padrões de design que aparece com mais frequência em código PHP de produção: Strategy, Repository, Observer, Decorator, Specification, Pipeline e Factory. Não é uma lista exaustiva — é o subset que paga dividendos imediatos.

Conceito

Padrões resolvem três classes de problema: (1) variar comportamento sem ramificar ifs (Strategy, Decorator, Pipeline), (2) desacoplar acesso a dados do domínio (Repository, Specification) e (3) propagar mudanças sem acoplamento direto (Observer, Domain Events). Container DI (com PSR-11) é o cimento que segura tudo.

Por que importa

Sem padrões nomeados, a comunicação no time vira "aquela classe que faz aquilo". Com padrões nomeados, "vamos extrair um Strategy para a regra de desconto" é uma frase que produz código previsível em qualquer codebase. Mais importante: padrões são abstrações que você pode recusar — saber quando não aplicar Repository é tão importante quanto saber aplicar.

Exemplos

Strategy + DI
interface DiscountStrategy {
    public function apply(Money $total, Customer $customer): Money;
}

final class FirstPurchaseDiscount implements DiscountStrategy {
    public function apply(Money $total, Customer $customer): Money {
        if ($customer->orderCount === 0) {
            return $total->multiplyPercent(0.9); // 10% off
        }
        return $total;
    }
}

final class CouponDiscount implements DiscountStrategy {
    public function __construct(private CouponRepository $coupons) {}

    public function apply(Money $total, Customer $customer): Money {
        $coupon = $this->coupons->activeFor($customer);
        return $coupon ? $total->subtract($coupon->amount) : $total;
    }
}

final class CompositeDiscount implements DiscountStrategy {
    /** @param DiscountStrategy[] $strategies */
    public function __construct(private array $strategies) {}

    public function apply(Money $total, Customer $customer): Money {
        return array_reduce(
            $this->strategies,
            fn (Money $running, DiscountStrategy $s) => $s->apply($running, $customer),
            $total,
        );
    }
}
Repository com Specification
interface Specification {
    public function isSatisfiedBy(object $candidate): bool;
    public function toCriteria(): array; // tradução para query
}

final class ActiveUsersSpec implements Specification {
    public function isSatisfiedBy(object $candidate): bool {
        return $candidate instanceof User && $candidate->active;
    }
    public function toCriteria(): array {
        return ['active' => true];
    }
}

interface UserRepository {
    public function find(UserId $id): ?User;
    public function matching(Specification $spec): array;
    public function save(User $user): void;
}
Pipeline (middleware-style)
interface Stage {
    public function handle(mixed $payload, callable $next): mixed;
}

final class Pipeline {
    /** @param Stage[] $stages */
    public function __construct(private array $stages) {}

    public function send(mixed $payload): mixed {
        $pipe = array_reduce(
            array_reverse($this->stages),
            fn (callable $next, Stage $stage) =>
                fn ($p) => $stage->handle($p, $next),
            fn ($p) => $p,
        );
        return $pipe($payload);
    }
}

Quando usar / quando evitar

Strategy é a primeira tentativa óbvia para qualquer switch/match que cresceu. Repository é útil quando há mais de uma origem de dados ou quando o domínio precisa ser testável sem banco — para CRUD simples sobre uma tabela única, geralmente é overengineering. Pipeline é excelente para fluxos lineares (validação → autorização → execução → resposta); péssimo para grafos.

Armadilhas comuns

  • Repository "vazando" o ORM — métodos como findByQueryBuilder() destroem a abstração
  • Strategy com 2 implementações eternamente — se nunca passa de duas, talvez um if resolva
  • Container DI usado como Service Locator ($container->get(...) espalhado pelo código) — perde a vantagem da inversão
  • Observer assíncrono sem fila — eventos em memória se perdem no shutdown do request

Fase 4 — Arquitetura DDD/Clean

Três semanas. O salto da Fase 3 para a 4 é o salto da escala de classe para a escala de aplicação. Aqui entram camadas, hexagonal, value objects, agregados, eventos de domínio, consistência eventual e CQRS. É também onde o roadmap fica mais opinativo — DDD não é obrigatório, mas o vocabulário ajuda mesmo que você nunca escreva um Aggregate Root.

Conceito

Arquitetura limpa separa o domínio (regras de negócio que existem com ou sem framework) das camadas técnicas (HTTP, banco, fila, cache). O domínio fala em entidades, value objects, agregados e serviços de domínio. As camadas externas adaptam — Controllers traduzem HTTP em Use Cases, Repositories traduzem agregados em SQL, Listeners traduzem eventos em side effects.

Por que importa

Sem essa separação, qualquer mudança de framework é reescrita. Com ela, trocar Symfony por Laravel afeta os adaptadores, não o coração da aplicação. Mais imediato: testes ficam rápidos (mockar 1 repository > subir banco), bugs de regra são reproduzidos sem HTTP, e a evolução do schema do banco não força mudança em todas as classes que tocam usuário.

Exemplos

Estrutura de pastas (Clean Architecture)
  • src/
  • ├── Domain/ — regras puras, sem framework
  • │ ├── Order/
  • │ │ ├── Order.php — Aggregate Root
  • │ │ ├── OrderItem.php
  • │ │ ├── OrderStatus.php — enum
  • │ │ ├── OrderRepository.php — interface
  • │ │ └── Events/OrderPaid.php
  • │ └── Shared/
  • │ └── Money.php — Value Object
  • ├── Application/ — casos de uso
  • │ └── Order/
  • │ ├── PlaceOrder/
  • │ │ ├── Handler.php
  • │ │ └── Command.php
  • │ └── PayOrder/...
  • ├── Infrastructure/ — adaptadores
  • │ ├── Persistence/Doctrine/DoctrineOrderRepository.php
  • │ ├── Http/Controllers/OrderController.php
  • │ └── Events/SymfonyEventDispatcher.php
  • └── Presentation/ — UI / CLI
Aggregate Root + evento de domínio
final class Order {
    /** @var DomainEvent[] */
    private array $events = [];

    private function __construct(
        public readonly OrderId    $id,
        public readonly CustomerId $customerId,
        private OrderStatus        $status,
        /** @var OrderItem[] */
        private array              $items,
    ) {}

    public static function place(CustomerId $customer, array $items): self {
        if (count($items) === 0) {
            throw new DomainException('Order needs at least one item');
        }
        $order = new self(
            OrderId::generate(),
            $customer,
            OrderStatus::Pending,
            $items,
        );
        $order->events[] = new OrderPlaced($order->id, $customer);
        return $order;
    }

    public function pay(): void {
        if (!$this->status->canTransitionTo(OrderStatus::Paid)) {
            throw new DomainException("Cannot pay order in {$this->status->value}");
        }
        $this->status   = OrderStatus::Paid;
        $this->events[] = new OrderPaid($this->id);
    }

    /** @return DomainEvent[] */
    public function pullEvents(): array {
        $events = $this->events;
        $this->events = [];
        return $events;
    }
}
Use Case (Command Handler)
final class PayOrderHandler {
    public function __construct(
        private OrderRepository $orders,
        private EventDispatcher $events,
    ) {}

    public function __invoke(PayOrderCommand $cmd): void {
        $order = $this->orders->find($cmd->orderId)
            ?? throw new OrderNotFoundException($cmd->orderId);

        $order->pay();

        $this->orders->save($order);
        foreach ($order->pullEvents() as $event) {
            $this->events->dispatch($event);
        }
    }
}

Quando usar / quando evitar

Camadas valem a pena quando a aplicação tem regras de negócio reais, vai durar anos e múltiplos times tocam. Para um CRUD interno de 4 telas, é ginástica gratuita — Active Record com validação no controller resolve. CQRS faz sentido quando leitura e escrita têm requisitos muito diferentes (relatórios pesados vs. transações curtas); evite separar tudo só por estética.

Armadilhas comuns

  • Anemia: entidades viram só getters/setters e a "lógica" mora em OrderService — você reinventou Transaction Script com mais arquivos
  • Repositories que retornam DTOs em vez de agregados — quebra invariantes do domínio
  • Eventos de domínio dispachados de dentro do agregado, antes do save() — se o save falha, side effects já foram disparados
  • Camadas que não respeitam a direção da dependência (Domain importando da Infrastructure) — derruba todo o ponto da arquitetura

Fase 5 — APIs maduras

Duas semanas. PSRs (Recommendations da PHP-FIG) são o motivo de você poder trocar de framework sem reescrever middlewares — PSR-7 (HTTP messages), PSR-15 (middleware), PSR-17 (factories), PSR-3 (logging), PSR-11 (containers), PSR-18 (HTTP client). Em cima disso vêm os tópicos de API real: paginação, versionamento, idempotência, autenticação, OpenAPI.

Conceito

Uma API madura responde quatro perguntas: (1) como o cliente fala com você (HTTP semântico, códigos certos, payloads consistentes), (2) como você não quebra o cliente quando muda (versionamento, deprecation), (3) como você sobrevive a redes ruins (retries seguros via Idempotency-Key) e (4) como o cliente descobre o que você faz (OpenAPI, contratos).

Por que importa

REST mal implementado é causa raiz de bugs caros. Endpoints que respondem 200 com erro no body, paginação por offset que diverge sob inserções, retry sem idempotência que duplica cobranças — todos esses são problemas operacionais que viram página inteira no postmortem. Os PSRs e as convenções desta fase resolvem 80% deles.

Exemplos

Middleware PSR-15: Idempotency-Key
final class IdempotencyMiddleware implements MiddlewareInterface {
    public function __construct(
        private CacheInterface  $cache,
        private ResponseFactory $responses,
    ) {}

    public function process(
        ServerRequestInterface $request,
        RequestHandlerInterface $handler,
    ): ResponseInterface {
        if (!in_array($request->getMethod(), ['POST', 'PATCH', 'DELETE'])) {
            return $handler->handle($request);
        }

        $key = $request->getHeaderLine('Idempotency-Key');
        if ($key === '') {
            return $handler->handle($request);
        }

        $cacheKey = "idem:{$request->getMethod()}:{$request->getUri()->getPath()}:{$key}";
        $cached   = $this->cache->get($cacheKey);
        if ($cached !== null) {
            return $this->responses->fromArray($cached);
        }

        $response = $handler->handle($request);

        if ($response->getStatusCode() < 500) {
            $this->cache->set($cacheKey, $this->responses->toArray($response), 86400);
        }
        return $response;
    }
}
Paginação por cursor
// Offset (ruim sob inserções concorrentes):
//   GET /orders?page=2&size=20  → SELECT ... LIMIT 20 OFFSET 20
//
// Cursor (estável):
//   GET /orders?after=01HX8...&size=20
//   → SELECT ... WHERE id > '01HX8...' ORDER BY id LIMIT 20

final class CursorPaginator {
    public function paginate(QueryBuilder $qb, ?string $after, int $size): array {
        if ($after !== null) {
            $qb->andWhere('o.id > :after')->setParameter('after', $after);
        }
        $rows = $qb->orderBy('o.id', 'ASC')
                   ->setMaxResults($size + 1)
                   ->getQuery()
                   ->getResult();

        $hasMore = count($rows) > $size;
        $rows    = array_slice($rows, 0, $size);

        return [
            'data'        => $rows,
            'next_cursor' => $hasMore ? end($rows)->id : null,
        ];
    }
}
Resposta padronizada (Problem Details — RFC 9457)
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json

{
  "type":   "https://api.example.com/problems/validation",
  "title":  "Dados inválidos",
  "status": 422,
  "detail": "O campo 'email' não é um e-mail válido.",
  "instance": "/orders/01HX8K7Z9R",
  "errors": [
    { "field": "email", "code": "invalid_format" }
  ]
}

Quando usar / quando evitar

Cursor é a escolha padrão para listas que mudam; offset só sobrevive em datasets pequenos e estáticos. Idempotency-Key é obrigatória em qualquer endpoint que cobre dinheiro ou comunique com sistema externo (e-mail, SMS). JWT é ótimo para autenticação stateless entre serviços; em sessão de usuário web, cookie + sessão server-side ainda é mais simples e seguro contra XSS.

Armadilhas comuns

  • Versionar pela URL (/v1/...) e nunca aposentar a v1 — viraram duas APIs para sempre
  • Retornar 200 com { "error": "..." } em vez do código HTTP correto — quebra clientes que dependem do status
  • Cachear Idempotency-Key sem incluir o payload no hash — chave repetida com payload diferente vira "resposta legítima do cliente certo"
  • OpenAPI escrito à mão, divergindo do código — gere a partir do código (attributes ou anotações) ou rode contract tests

Fase 6 — Performance & Runtime

Duas semanas. Aqui o foco muda da clareza do código para o comportamento operacional: como o PHP roda, como medir, como reduzir latência sem escrever assembly. Tópicos: OPcache, JIT, preloading, profiling (Xdebug, Blackfire, SPX), cache (PSR-6/16), filas (Redis, RabbitMQ), runtimes alternativos (FrankenPHP, RoadRunner, Swoole), Fibers, e o famigerado N+1.

Conceito

PHP tradicional é "share-nothing": cada request começa do zero. OPcache cacheia bytecode compilado entre requests, mas o estado do programa ainda morre ao fim da request. Runtimes alternativos (FrankenPHP, RoadRunner, Swoole) mantêm o processo vivo entre requests — ganho absurdo de latência, mas você precisa saber lidar com vazamento de estado entre requests. Fibers permitem concorrência cooperativa dentro de uma request.

Por que importa

80% da otimização de PHP de produção é OPcache configurado, queries N+1 eliminadas e cache de leitura barato em cima de chaves bem desenhadas. Os outros 20% são profiling de fato (achar o gargalo, não chutar) e considerar trocar o runtime quando o overhead de bootstrap dominar a request.

Exemplos

php.ini — config OPcache de produção
[opcache]
opcache.enable=1
opcache.enable_cli=0
opcache.memory_consumption=256        ; MB
opcache.interned_strings_buffer=16    ; MB
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0         ; PROD: nunca revalidar
opcache.revalidate_freq=0
opcache.save_comments=1               ; necessário para attributes/Reflection
opcache.preload=/var/www/preload.php
opcache.preload_user=www-data
opcache.jit=tracing
opcache.jit_buffer_size=128M

validate_timestamps=0 exige que o deploy faça kill -USR2 no master do FPM (graceful reload) — se você esquecer, o código novo nunca vai carregar.

N+1: o problema mais comum
// RUIM: 1 + N queries
$users = $repo->findAll();      // 1 query
foreach ($users as $user) {
    $orders = $user->getOrders(); // 1 query por user
    // ...
}

// BOM: 2 queries (eager loading com Doctrine)
$users = $em->createQueryBuilder()
    ->select('u', 'o')
    ->from(User::class, 'u')
    ->leftJoin('u.orders', 'o')
    ->getQuery()
    ->getResult();
Cache PSR-16 com fallback ao banco
final class CachedProductRepository implements ProductRepository {
    public function __construct(
        private ProductRepository $inner,
        private CacheInterface    $cache,
        private int               $ttl = 300,
    ) {}

    public function find(ProductId $id): ?Product {
        $key    = "product:{$id->value}";
        $cached = $this->cache->get($key);
        if ($cached !== null) {
            return $cached;
        }
        $product = $this->inner->find($id);
        if ($product !== null) {
            $this->cache->set($key, $product, $this->ttl);
        }
        return $product;
    }
}
Fibers (concorrência cooperativa)
$fiber = new Fiber(function (): void {
    echo "antes\n";
    $value = Fiber::suspend('pausando');
    echo "voltei com {$value}\n";
});

echo $fiber->start(), "\n"; // antes / pausando
echo $fiber->resume('continua'), "\n"; // voltei com continua

// Uso real: clientes HTTP que aguardam I/O em paralelo dentro da mesma request

Quando usar / quando evitar

OPcache + preload em produção sempre. JIT compensa em workloads CPU-bound (parsing, cálculo); em I/O-bound típico de web, ganho marginal. FrankenPHP/RoadRunner valem a pena quando o bootstrap do framework dominar a latência (Laravel/Symfony cold start ~30-80ms — em runtime persistente, vira microssegundos). Cuidado: estado entre requests precisa ser limpo explicitamente.

Armadilhas comuns

  • Otimizar antes de medir — quase sempre você está otimizando o lugar errado
  • opcache.save_comments=0 em produção quebra qualquer framework que use docblocks (Doctrine, Symfony Validator antigo)
  • Cache sem invalidação — o cache certo na chave errada é pior que cache nenhum
  • Migrar para FrankenPHP sem auditar singletons globais — variáveis estáticas viram cache compartilhado entre requests, com vazamentos sutis
  • Usar Fibers achando que é multithread — é cooperativo, um único core, e bloqueio síncrono ainda bloqueia tudo

Fase 7 — Infra & Quality

Uma semana intensa. Empacotar, deployar, observar, garantir qualidade automaticamente. Tópicos: Dockerfile multi-stage, FPM tuning, CI/CD com GitHub Actions, análise estática (PHPStan, Psalm, Rector), logs estruturados, OpenTelemetry e OWASP Top 10.

Conceito

Qualidade em produção é resultado de quatro coisas automatizadas: (1) build determinístico (Docker multi-stage), (2) gates de qualidade no CI (lint + estática + testes), (3) observação do runtime (logs estruturados + métricas + traces) e (4) defesa em profundidade (OWASP, headers de segurança, secrets fora do repo).

Por que importa

Análise estática acha bugs que testes nunca acharão (chamada com tipo errado em código não coberto, propriedade nunca atribuída). Logs estruturados (JSON) fazem a diferença entre 30 minutos com grep e 30 segundos com Loki/Elastic. Multi-stage build separa o ambiente de build (composer, ferramentas) do ambiente de runtime — imagem final pequena, sem compilador, sem dev dependencies.

Exemplos

Dockerfile multi-stage
FROM composer:2 AS deps
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --optimize-autoloader --no-interaction

FROM php:8.4-fpm-alpine AS runtime
RUN apk add --no-cache icu-libs && \
    docker-php-ext-install -j$(nproc) opcache pdo_mysql intl bcmath
COPY --from=mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/local/bin/
RUN install-php-extensions redis

COPY docker/php/php.ini       /usr/local/etc/php/php.ini
COPY docker/php/opcache.ini   /usr/local/etc/php/conf.d/opcache.ini
COPY docker/php/www.conf      /usr/local/etc/php-fpm.d/www.conf

WORKDIR /app
COPY --from=deps /app/vendor ./vendor
COPY . .
RUN php artisan config:cache && \
    php artisan route:cache  && \
    php artisan view:cache   && \
    chown -R www-data:www-data storage bootstrap/cache

USER www-data
EXPOSE 9000
HEALTHCHECK --interval=30s --timeout=3s \
    CMD php-fpm -t || exit 1
CMD ["php-fpm", "--nodaemonize"]
FPM tuning (www.conf)
[www]
user  = www-data
group = www-data
listen = 9000

pm = dynamic
pm.max_children      = 40   ; mem por worker × 40 ≤ RAM disponível
pm.start_servers     = 8
pm.min_spare_servers = 4
pm.max_spare_servers = 12
pm.max_requests      = 500  ; recicla worker para evitar memory leak

request_terminate_timeout = 30s
slowlog = /var/log/php-fpm/slow.log
request_slowlog_timeout = 5s

clear_env = no
catch_workers_output = yes
decorate_workers_output = no
.github/workflows/ci.yml
name: ci
on: [push, pull_request]

jobs:
  quality:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.4'
          coverage: xdebug
          tools: composer:v2

      - name: Install
        run: composer install --no-progress --prefer-dist

      - name: Lint
        run: vendor/bin/parallel-lint src tests

      - name: PHPStan (level 8)
        run: vendor/bin/phpstan analyse --no-progress

      - name: Rector (dry-run)
        run: vendor/bin/rector process --dry-run

      - name: Tests
        run: vendor/bin/phpunit --coverage-text --coverage-clover=coverage.xml
Logs estruturados (Monolog → JSON)
use Monolog\Logger;
use Monolog\Handler\StreamHandler;
use Monolog\Formatter\JsonFormatter;

$handler = new StreamHandler('php://stdout', Logger::INFO);
$handler->setFormatter(new JsonFormatter());

$logger = new Logger('app');
$logger->pushHandler($handler);

$logger->info('order.paid', [
    'order_id'    => $order->id->value,
    'customer_id' => $order->customerId->value,
    'amount'      => $order->total->amountInCents,
    'trace_id'    => $tracer->currentTraceId(),
]);
// stdout:
// {"message":"order.paid","level":200,"context":{"order_id":"01HX...","customer_id":"...","amount":12990,"trace_id":"..."}}

Quando usar / quando evitar

PHPStan nível 8 é a meta — abaixo disso, há zonas de cegueira. Rector é mágico para upgrades de versão (PHP 7.4 → 8.x), mas configure regras específicas em vez de aplicar todas. Multi-stage é padrão em qualquer imagem com dependências de build. pm = static só faz sentido com tráfego super estável e RAM sobrando — em geral, dynamic ou ondemand.

Armadilhas comuns

  • pm.max_children alto demais: workers brigam por CPU, latência sobe, OOM mata o container
  • Dockerfile sem --no-dev no composer install: imagem com phpunit, faker e 200MB de fixtures em produção
  • Logs em texto livre: impossível agregar ou filtrar por campo — sempre JSON
  • Secrets em .env commitado: use vault, secret manager ou variáveis de ambiente do orquestrador
  • Esquecer headers de segurança (Strict-Transport-Security, Content-Security-Policy, X-Content-Type-Options)

Fase 8 — Projeto integrador

Duas semanas. Tudo o que foi estudado vira um produto: um e-commerce com catálogo, carrinho, checkout, pagamentos, eventos de domínio, workers assíncronos, JWT, Idempotency-Key, OpenAPI, Docker e CI completo. Sem framework — ou minimalmente, com Slim 4 ou Mezzio para HTTP, pra não reinventar o mundo. O ponto é exercitar as decisões, não a entrega.

Conceito

O projeto integrador força você a juntar três tipos de decisão: (1) modelagem de domínio (entidades, value objects, agregados), (2) limites técnicos (síncrono vs. assíncrono, consistência forte vs. eventual) e (3) operação (deploy, observabilidade, recuperação). Não é sobre ter um e-commerce que ninguém vai usar — é sobre fazer dezenas de escolhas pequenas que normalmente ninguém te explica.

Por que importa

Conhecimento isolado por fase desfaz se não amarra. O integrador é onde Strategy + Repository + Idempotency + Observabilidade convergem em um único request. É também onde você descobre que decisões boas no microcosmo (uma classe perfeita) podem ser ruins no macro (um agregado gigante).

Escopo sugerido

  • Catálogo: produtos, categorias, busca paginada por cursor
  • Carrinho: sessão server-side ou cookie assinado, idempotente em add/remove
  • Checkout: command handlers, máquina de estados em enum, validação em VOs
  • Pagamento: integração mockada (Stripe/Pagar.me), Idempotency-Key obrigatória, retry com backoff
  • Pós-pagamento: evento OrderPaid dispara worker que envia e-mail e atualiza estoque
  • API: documentada com OpenAPI gerada via attributes, contract tests no CI
  • Infra: docker-compose com app + MySQL + Redis + worker + nginx, logs JSON, health check

Armadilhas comuns

  • Tentar "fazer perfeito" antes de fazer end-to-end — termine o caminho feliz primeiro, refatore depois
  • Não escrever testes desde o início — debugar manualmente um e-commerce inteiro vira pesadelo na semana 2
  • Acoplar pagamento síncrono ao request HTTP — qualquer instabilidade no gateway derruba checkout
  • Carregar o agregado Order com tudo (itens, pagamentos, envios) em um único query — desenhe os boundaries deliberadamente
Notas no repositório 01-Roadmap.md

Recursos

Material de apoio que sustenta o roadmap. A lista é curada para ter densidade alta — cada item merece tempo, não consumo passivo.

Livros

  • Modern PHP — Josh Lockhart. Curto, atualizado, ótimo ponto de entrada para quem vem de PHP antigo
  • PHP 8 in a Nutshell — referência rápida de features 8.x, útil como manual aberto na mesa
  • Domain-Driven Design Distilled — Vaughn Vernon. Versão de 200 páginas do livro azul; absorvível em uma semana
  • Patterns of Enterprise Application Architecture — Martin Fowler. Referência permanente para Repository, Unit of Work, Service Layer
  • Implementing Domain-Driven Design — Vaughn Vernon. Quando precisar de profundidade real em agregados e bounded contexts

Docs & RFCs

  • php.net/manual — leia as seções de tipos, classes e namespaces na íntegra
  • wiki.php.net/rfc — RFCs aceitas explicam o "por quê" de cada feature; leia as do 8.4
  • php-fig.org/psr — todos os PSRs aceitos; PSR-7, 15, 17, 11 e 3 são essenciais
  • OWASP Top 10 — vulnerabilidades; cada item da lista deve disparar uma checagem mental ao escrever código que toca dado externo

Ferramentas

  • PHPStan — análise estática, comece no nível 5 e suba até 8
  • Psalm — alternativa/complemento; especialmente bom em inferência de tipos genéricos
  • Rector — refatoração automatizada, salva semanas em upgrades de versão
  • PHP CS Fixer / PHP_CodeSniffer — formatação automática, evita debate de estilo no PR
  • PHPUnit / Pest — Pest é mais ergonômico se você gosta de syntax tipo Jest/RSpec
  • Xdebug + Blackfire / SPX — profiling; SPX é gratuito e excelente para começar
  • FrankenPHP — runtime moderno baseado em Caddy; experiência de deploy simplificada

Comunidades

  • r/PHP — sinal/ruído melhor do que parece; ótimo para descobrir features e bibliotecas novas
  • PHP Internals News — newsletter sobre as RFCs em discussão
  • Symfony / Laravel official forums e Discords — para quando você for usar framework
  • Stitcher.io (Brent) — blog sobre PHP moderno, qualidade alta e atualizado

Método de revisão

Aprender é metade do trabalho. Reter exige um método explícito. As quatro práticas abaixo são o motor de longo prazo do roadmap — sem elas, o conteúdo das 16 semanas evapora em três meses.

Spaced repetition

Revisitar o tópico em intervalos crescentes: dia 1, dia 3, dia 7, dia 21, dia 60. Anki ou cartões em qualquer ferramenta funcionam. As perguntas devem ser específicas — não "o que é Strategy" mas "qual a desvantagem de Strategy quando há só duas implementações?".

Build > consume

A cada conceito novo, escreva pelo menos uma variação que não está nos exemplos do material. Implementou Repository para User na semana 4? Escreva um para Product na semana 6, em outro contexto, com outra abstração. A diferença entre lembrar de algo e saber algo é construída no segundo build.

Releitura ativa

Reler PR antigo seu três meses depois é um dos exercícios mais educativos. Você vai achar decisões que hoje não tomaria, comentários que não fazem sentido, abstrações que não pagaram. Reescreva mentalmente — ou de fato, em um branch — e note o que mudou.

Notas atômicas

Cada conceito vira uma nota Markdown curta com seis seções fixas: Conceito, Por que importa, Exemplo de código, Quando usar, Quando evitar, Links relacionados. O repositório php-studies usa esse formato no Obsidian — wiki-links entre notas viram um grafo de conhecimento que cresce com o tempo. Releitura aleatória do grafo (uma nota por dia, escolhida ao acaso) é spaced repetition de baixo custo.

Última nota Esse roadmap é uma fotografia do que faz sentido em maio de 2026, com PHP 8.4 estável e o ecossistema convergindo em torno de tipos estritos, runtimes persistentes (FrankenPHP/RoadRunner) e ferramentas estáticas. Em dois anos, partes vão estar desatualizadas — a ordem (fundamentos → OOP → padrões → arquitetura → APIs → performance → infra → projeto) deve continuar válida.