Motivação do estudo
O objetivo do PhpModernGuide é desmontar um preconceito comum em quem vem de .NET ou Node: a ideia de
que PHP ainda é require_once em cascata, array associativo sem forma garantida e HTML misturado com
lógica de negócio. Esse PHP existiu, mas não é o PHP de 2026 — o ecossistema profissional evoluiu para
tipagem estática levada a sério, um gerenciador de pacotes à altura do NuGet/npm, padrões formais de
interoperabilidade (PSR), containers de DI de verdade e análise estática comparável a um tsc --strict.
Em vez de só descrever isso em prosa, o repositório constrói uma Task API completa e funcional que
aplica cada peça de verdade: Composer com autoload PSR-4, mensagens HTTP PSR-7 imutáveis, middleware
PSR-15, injeção de dependência via container PSR-11, declare(strict_types=1) em todo arquivo,
readonly, enum nativo com máquina de estados, PHPStan no nível mais alto do modo clássico e testes
Pest cobrindo domínio e integração.
O que foi feito
- Task API real: CRUD completo (
GET/POST /tasks,GET/PATCH/DELETE /tasks/{id},GET /health) sobre PostgreSQL, com arquitetura em camadas —Domain/Application/Infrastructure/Http— uma versão enxuta de arquitetura hexagonal onde o domínio não sabe que PostgreSQL, PDO ou HTTP existem; ele só conheceTaskRepositoryInterface. - Slim 4 + PHP-DI escolhidos de propósito didático: o roteamento, o container de DI e o middleware
de erro são montados manualmente em
bootstrap.php, sem nenhum deles escondido atrás de convenção de framework — ótimo pra aprender a mecânica, mas não é a recomendação para começar um projeto PHP real do zero. O próprio README é explícito: para produto de verdade a recomendação é Laravel (ou Symfony para times que querem controle arquitetural total), justamente porque eles resolvem por você o que aqui é montado à mão para ficar visível. - Domínio com invariantes protegidas: o agregado
Tasksó muta via métodos que revelam intenção (rename,changeStatus), nunca por setter público, e o enumTaskStatuscarrega sua própria máquina de estados (canTransitionTo) — uma transição inválida (done → pending) é rejeitada com 422 antes de qualquer SQL rodar. - DI real via PSR-11:
dependencies.phpé o único lugar do código que conhece a implementação concreta (PdoTaskRepository); todo o resto — controller, testes — depende só da interfaceTaskRepositoryInterface, o que permite trocar a implementação sem tocar em nenhuma outra classe. - PHPStan nível 8 limpo: análise estática pega o que o “compilador” do PHP não pega em tempo de
execução. Um exemplo real documentado no README:
PDO::query()retornaPDOStatement|false— o PHPStan recusou até ofalseser tratado explicitamente emPdoTaskRepository::findAll(). - 20 testes Pest passando: testes de unidade puros de domínio (
Task,TaskStatus, sem I/O) e testes de feature que sobem a aplicação Slim real (o mesmobuildApp()usado em produção) e disparam requisições PSR-7 de verdade através de toda a pilha — roteamento → parsing de body → controller → repositório PDO → SQL → PostgreSQL de teste. - Migrations com Phinx: schema versionado e reversível (
composer migrate/migrate:rollback), independente de framework — o equivalente PHP de uma migration do Entity Framework ou do Knex/Prisma.
Como funciona
Visão geral do sistema
flowchart TB
Client["🌐 Cliente HTTP\ncurl / navegador"]
subgraph SlimApp["Slim 4 App — public/index.php → bootstrap.php"]
PSR7["Mensagens PSR-7\nServerRequestInterface / ResponseInterface\nimutáveis"]
BodyMW["addBodyParsingMiddleware()\nPSR-15"]
RouteMW["addRoutingMiddleware()\nPSR-15"]
ErrorMW["addErrorMiddleware()\nPSR-15"]
Router["Router Slim\nroutes.php — /health, /tasks/*"]
end
Container[("Container PHP-DI\nPSR-11 — dependencies.php")]
Controller["TaskController\nHttp/Controller — adapter HTTP fino"]
subgraph Domain["Domínio — não conhece HTTP nem PostgreSQL"]
Task["Task (aggregate)\nrename · changeStatus · invariantes"]
TaskStatus["TaskStatus (enum)\nmáquina de estados"]
RepoIface["TaskRepositoryInterface\n(porta)"]
end
Repo["PdoTaskRepository\n(adapter — Infrastructure)"]
PG[("PostgreSQL\ntabela tasks")]
Phinx["Phinx\nmigrations versionadas"]
Client -->|HTTP request| PSR7
PSR7 --> BodyMW --> RouteMW --> ErrorMW --> Router
Router -->|resolve rota| Container
Container -->|injeta TaskRepositoryInterface| Controller
Controller --> Task
Controller -->|via porta| RepoIface
RepoIface -.implementada por.-> Repo
Task --> TaskStatus
Repo -->|SQL puro, prepared stmt| PG
Phinx -->|aplica schema versionado| PG
Controller -->|Response PSR-7| Client
Fluxo de request real: criar uma tarefa (POST /tasks)
sequenceDiagram
actor C as Cliente (curl)
participant App as Slim App
participant MW as Middleware PSR-15\n(body parsing → routing → error)
participant Ctrl as TaskController
participant Task as Task (domínio)
participant Repo as PdoTaskRepository
participant DB as PostgreSQL
C->>App: POST /tasks {"title": "...", "description": "..."}
App->>MW: addBodyParsingMiddleware()
MW->>MW: JSON → $request->getParsedBody()
MW->>MW: addRoutingMiddleware() resolve /tasks → TaskController::create
App->>Ctrl: create(Request, Response) via container PHP-DI
Ctrl->>Ctrl: CreateTaskInput::fromArray($body)
Ctrl->>Task: Task::create(title, description)
alt título vazio
Task-->>Ctrl: throw DomainException
Ctrl-->>C: 422 {"error": "Task title cannot be empty."}
else válido
Task-->>Ctrl: Task (status=Pending, id=uuid gerado)
Ctrl->>Repo: save(task) — via TaskRepositoryInterface
Repo->>DB: INSERT ... ON CONFLICT (id) DO UPDATE (upsert)
DB-->>Repo: ok
Repo-->>Ctrl: void
Ctrl->>Ctrl: TaskData::fromTask(task)->toArray()
Ctrl-->>C: 201 Created + JSON da tarefa
end
Modelo de dados
erDiagram
TASKS {
uuid id PK
string title
string description "nullable"
string status "pending | in_progress | done"
timestamptz created_at
timestamptz updated_at
}
Schema deliberadamente simples — o ponto de estudo não é modelagem complexa, é a fiação em volta dela:
como o id (UUID gerado por TaskId::generate()) nasce no domínio, não no banco, e como status
persiste como string mas trafega como enum tipado (TaskStatus) em todo o código PHP.
Stack
- ◆ BACKEND: PHP 8.4, Slim 4 (PSR-7/15), PHP-DI 7 (PSR-11),
ramsey/uuid,vlucas/phpdotenv, Monolog. - ▤ PERSISTÊNCIA: PostgreSQL via PDO puro (sem ORM — SQL explícito, prepared statements), Phinx pra migrations.
- ✓ QUALIDADE: PHPStan nível 8 (análise estática), Pest 3 (20 testes: unit de domínio + feature via app Slim real).
- ⌂ INFRA: Docker Compose (API + PostgreSQL).
Como rodar
# 1. Subir os containers (API + PostgreSQL)
docker compose up -d --build
# 2. Instalar dependências PHP dentro do container
docker compose exec api composer install
# 3. Aplicar as migrations Phinx no banco de desenvolvimento
docker compose exec api php vendor/bin/phinx migrate -c migrations.php
# API em http://localhost:8098
# PostgreSQL em localhost:55432 (postgres/postgres, banco php_modern_guide)
# Análise estática (PHPStan nível 8)
docker compose exec api composer analyse
# Suíte de testes (Pest — precisa do banco de teste)
docker compose exec db psql -U postgres -c "CREATE DATABASE php_modern_guide_test;"
docker compose exec -e DB_NAME=php_modern_guide_test api php vendor/bin/phinx migrate -c migrations.php
docker compose exec -e DB_NAME=php_modern_guide_test api composer test # Código aberto
O PhpModernGuide é open source, feito pra estudar e servir de referência de PHP moderno.