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.
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, neverdeclare(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
staticcom estado entre requests — o worker pode reaproveitar o processo, masstaticem método não é cache distribuído - Usar
catch (\Exception $e)e silenciarError— em PHP 7+ você precisa deThrowablepara pegar tudo - Acreditar que
OPcacherecarrega arquivos automaticamente em produção — por padrão,opcache.validate_timestamps=0exige 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 matchdeclare(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.
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
readonlyimpede 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 + DIinterface 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
ifresolva - 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
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-Keyfinal 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.
// 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=0em 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-stageFROM 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_childrenalto demais: workers brigam por CPU, latência sobe, OOM mata o container- Dockerfile sem
--no-devnocomposer 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
.envcommitado: 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
OrderPaiddispara 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
Ordercom tudo (itens, pagamentos, envios) em um único query — desenhe os boundaries deliberadamente
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.