v1.0

Bem-vindo à Vupi.us API

Uma plataforma modular, segura e moderna desenvolvida em PHP puro. Simplicidade por fora, poder por dentro.

O que é a Vupi.us API?

A Vupi.us API é um framework PHP modular de alta performance, projetado para construir APIs REST robustas sem a complexidade de frameworks pesados. Cada funcionalidade vive em um módulo independente — uma pasta em src/Modules/.

Filosofia Zero Config

Crie uma pasta em src/Modules/ e o sistema a detecta automaticamente. Sem registro manual, sem arquivos de configuração centralizados.

Recursos principais

Arquitetura Modular

Cada módulo é independente. Adicione, remova ou desative sem afetar o restante do sistema.

Segurança por Padrão

JWT com rotação de chaves, rate limiting, ThreatScorer, CSP, HSTS e proteção contra OWASP Top 10.

Multi-banco

Suporte nativo a PostgreSQL e MySQL. Dois bancos simultâneos: core e módulos externos.

Injeção de Dependência

Container automático com resolução recursiva. Declare no construtor, o sistema entrega.

CLI Poderosa

Setup automático, geração de módulos, migrations, seeds e gerenciamento de plugins via terminal.

Dashboard Admin

Interface web para monitoramento, gerenciamento de módulos e configuração do ambiente em tempo real.

Stack tecnológica

ComponenteTecnologia
LinguagemPHP 8.2+
Banco de dadosPostgreSQL 15+ / MySQL 8+
Cache / Rate LimitRedis (opcional) / File storage
Proxy reversoCaddy (TLS automático)
AutenticaçãoJWT (Firebase JWT)
Gerenciador de depsComposer

Instalação

Do zero ao servidor rodando em minutos.

Pré-requisitos

Antes de começar, certifique-se de ter instalado:

RequisitoVersão mínimaVerificar
PHP8.2+php -v
Composer2.xcomposer -V
Git2.xgit --version
PostgreSQL ou MySQL15+ / 8+psql -V / mysql -V

Instalar PHP 8.2 (Ubuntu)

bash
sudo apt update
sudo apt install -y software-properties-common
sudo add-apt-repository ppa:ondrej/php
sudo apt update
sudo apt install -y php8.2 php8.2-cli php8.2-common php8.2-curl php8.2-mbstring php8.2-xml php8.2-zip

Instalar Composer

bash
curl -sS https://getcomposer.org/installer | php
sudo mv composer.phar /usr/local/bin/composer

Instalar extensões de banco de dados

Para PostgreSQL:

bash
sudo apt-get install php8.2-pgsql
# Windows: habilite 'extension=pgsql' e 'extension=pdo_pgsql' no php.ini

Para MySQL:

bash
sudo apt-get install php8.2-mysql
# Windows: habilite 'extension=mysqli' e 'extension=pdo_mysql' no php.ini

Banco de dados com Docker (alternativa recomendada)

Se não quiser instalar PostgreSQL ou MySQL localmente, use o Docker. O projeto já vem com um docker-compose.yml pronto.

Instalar Docker + Compose (Ubuntu)

bash
# Remove versões antigas
sudo apt-get remove -y docker docker-engine docker.io containerd runc

# Instala dependências
sudo apt-get update
sudo apt-get install -y ca-certificates curl gnupg

# Adiciona chave GPG oficial do Docker
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg

# Adiciona repositório
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

# Instala Docker Engine + Compose plugin
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

# Permite rodar sem sudo (requer logout/login)
sudo usermod -aG docker $USER

Windows / macOS

Instale o Docker Desktop — já inclui o Compose.

Subir o banco com docker-compose.yml

Com o repositório clonado, escolha o banco que preferir:

bash
# Apenas PostgreSQL (recomendado)
docker compose up -d postgres

# Apenas MySQL
docker compose up -d mysql

# PostgreSQL + Adminer (interface web na porta 8080)
docker compose up -d postgres adminer

# Tudo (PostgreSQL + MySQL + Adminer)
docker compose up -d
Adminer

Com o Adminer rodando, acesse http://localhost:8080 para gerenciar o banco via interface web. Servidor: postgres, usuário: admin, senha: conforme DB_SENHA no .env.

Parar os containers

bash
docker compose down          # para e remove containers (dados preservados)
docker compose down -v       # para e remove containers + volumes (apaga dados)
Configuração automática

O Docker Compose lê as variáveis DB_NOME, DB_USUARIO, DB_SENHA e APP_TIMEZONE diretamente do seu .env. Configure-as antes de subir os containers.

Clonar o repositório

bash
git clone https://github.com/vupi.us/api_vupi.us_php.git
cd api_vupi.us_php

Instalar dependências

bash
composer install

Setup automático (recomendado)

O projeto possui um comando de setup completo com menu interativo:

bash
php vupi setup

O menu interativo permite:

  • Gerar secrets JWT automaticamente
  • Criar banco de dados via Docker
  • Executar migrations e seeders
  • Subir o servidor PHP em background
  • Configurar Caddy para HTTPS automático

Ou tudo de uma vez sem interação:

bash
php vupi setup --auto --db-mode=docker --server=php --jwt=if-empty
Ver todas as opções

php vupi setup --help lista todas as flags disponíveis.

Setup manual (passo a passo)

1

Copiar o arquivo de ambiente

bash
cp EXEMPLO.env .env
# Windows:
copy EXEMPLO.env .env
2

Editar o .env com suas configurações

Preencha pelo menos: DB_HOST, DB_NOME, DB_USUARIO, DB_SENHA, JWT_SECRET e JWT_API_SECRET.

Veja a seção Configuração do Ambiente para detalhes de cada variável.

3

Executar migrations

bash
php vupi migrate --seed
4

Iniciar o servidor

bash
php -S localhost:3005 index.php

Acesse: http://localhost:3005

Instalação rápida (Ubuntu)

Para Ubuntu 22.04/24.04, um único script instala tudo:

bash
git clone https://github.com/vupi.us/api_vupi.us_php.git
cd api_vupi.us_php
sudo bash install.sh

Instala PHP 8.2, Docker, drivers de banco, Composer, sobe o banco, roda migrations e seeders, e inicia o servidor — tudo automaticamente.

Configuração do Ambiente

Todas as configurações ficam no arquivo .env. Nunca edite o código para mudar senhas ou chaves.

Criar o arquivo .env

bash
cp EXEMPLO.env .env
Nunca suba o .env para o Git

O arquivo .env já está no .gitignore. Ele contém senhas e secrets — nunca deve ser versionado.

Aplicação

VariávelExemploDescrição
APP_NAME"Vupi.us API"Nome da aplicação exibido no dashboard e e-mails.
APP_ENVproductionproduction, development ou testing. Em produção ativa validações extras de segurança.
APP_DEBUGfalsetrue exibe stack traces. Sempre false em produção.
APP_URLhttps://api.vupi.usURL base da API. Usada em e-mails, CORS e sitemap.
APP_URL_FRONTENDhttps://meusite.comURL do frontend. Adicionada automaticamente ao CORS.
APP_PORT3005Porta do servidor PHP.
APP_TIMEZONEAmerica/BahiaFuso horário para datas e logs.
CORS_ALLOWED_ORIGINShttps://meusite.comOrigens permitidas para CORS, separadas por vírgula.
TRUST_PROXYtruetrue quando há proxy reverso (Caddy/Nginx). Confia em X-Forwarded-Proto.

Banco de dados (core)

VariávelExemploDescrição
DB_CONEXAOpostgresqlDriver: postgresql ou mysql.
DB_HOSTlocalhostHost do banco de dados.
DB_PORT5432Porta. PostgreSQL: 5432, MySQL: 3306.
DB_NOMEvupi_dbNome do banco de dados.
DB_USUARIOadminUsuário do banco.
DB_SENHAsenha_forteSenha do banco. Mínimo 16 caracteres em produção com banco remoto.

Banco de dados (modules) — opcional

Permite que módulos externos usem um banco separado do core. Deixe DB2_NOME vazio para usar o mesmo banco do core.

VariávelDescrição
DB2_CONEXAODriver do segundo banco (postgresql ou mysql).
DB2_HOST, DB2_PORTHost e porta do segundo banco.
DB2_NOMENome do banco. Se vazio, usa o banco core.
DB2_USUARIO, DB2_SENHACredenciais do segundo banco.

JWT e Segurança

Secrets obrigatórios em produção

JWT_SECRET e JWT_API_SECRET devem ter no mínimo 32 caracteres. O sistema recusa iniciar em produção com valores fracos.

VariávelExemploDescrição
JWT_SECRETabc123...64charsSecret para tokens de usuários. Gere com openssl rand -hex 32.
JWT_API_SECRETxyz789...64charsSecret para tokens de admin_system. Deve ser diferente do JWT_SECRET.
JWT_ISSUERhttps://api.vupi.usIdentificador do emissor do token (claim iss). Validado em toda requisição.
JWT_AUDIENCEhttps://api.vupi.usAudiência do token (claim aud). Validado em toda requisição.
JWT_EXPIRATION_TIME900Expiração do access token em segundos (padrão: 15 min).
REFRESH_TOKEN_EXPIRATION_SECONDS2592000Expiração do refresh token (padrão: 30 dias).
COOKIE_SECUREtruetrue em produção com HTTPS. Cookies só enviados via HTTPS.
COOKIE_SAMESITELaxLax (mesmo domínio) ou None (domínios diferentes, requer Secure=true).

E-mail (SMTP)

VariávelExemploDescrição
MAILER_HOSTsmtp.gmail.comServidor SMTP.
MAILER_PORT587Porta SMTP. 587 para TLS, 465 para SSL.
MAILER_USERNAME[email protected]Usuário SMTP.
MAILER_PASSWORDapp_passwordSenha SMTP. Para Gmail, use uma App Password.
MAILER_FROM_EMAIL[email protected]E-mail remetente.
MAILER_FROM_NAME"Vupi.us API"Nome do remetente.

Redis (opcional)

Configure Redis para habilitar rate limiting distribuído entre múltiplos containers. Sem Redis, o sistema usa armazenamento em arquivo (servidor único).

VariávelPadrãoDescrição
REDIS_HOSTvazioHost do Redis. Deixe vazio para usar file storage.
REDIS_PORT6379Porta do Redis.
REDIS_PASSWORDvazioSenha do Redis (se configurada).
REDIS_PREFIXvupi:Prefixo das chaves no Redis.

Gerar secrets JWT

bash
# Gera JWT_SECRET (64 chars hex)
openssl rand -hex 32

# Gera JWT_API_SECRET (64 chars hex)
openssl rand -hex 32

# Ou via CLI do projeto (gera automaticamente se vazios)
php vupi setup --auto --jwt=if-empty

Arquitetura do Sistema

Entenda como as peças se encaixam.

Visão geral

A Vupi.us API é organizada em duas camadas principais:

api_vupi.us_php/ ├── src/ │ ├── Kernel/ # Núcleo do sistema — não modifique │ │ ├── Nucleo/ # Container, Router, Application, ModuleLoader, PluginManager │ │ ├── Middlewares/ # Auth, RateLimit, Security, BotBlocker, CircuitBreaker... │ │ ├── Contracts/ # Interfaces (contratos do kernel) │ │ ├── Http/ # Request, Response, RequestFactory │ │ ├── Support/ # JWT, AuditLogger, ThreatScorer, IdempotencyLock, Storage... │ │ ├── Database/ # PdoFactory, migrations do core, ModuleConnectionResolver │ │ ├── Controllers/ # Dashboard, Home, Capabilities, EnvController... │ │ ├── Configs/ # EnvConfig │ │ ├── Exceptions/ # Handler global, ApplicationException, RouteException │ │ ├── Routes/ # Rotas do kernel (se houver) │ │ ├── Utils/ # Sanitizer, ImageProcessor, RelogioTimeZone │ │ ├── Views/ # Templates PHP do dashboard e páginas HTML │ │ ├── Nonce.php # Geração de nonce CSP por request │ │ └── View.php # Renderizador de views PHP │ ├── Modules/ # Sua aplicação vive aqui │ │ ├── Auth/ # Autenticação JWT (nativo) │ │ └── Usuario/ # Gerenciamento de usuários (nativo) │ ├── CLI/ # Comandos da CLI (vupi.us) │ │ ├── CommandRunner.php # Dispatcher de comandos │ │ ├── SetupCommand.php # php vupi setup │ │ ├── MigrateCommand.php # php vupi migrate │ │ ├── MakeModuleCommand.php # php vupi make:module │ │ ├── MakePluginCommand.php # php vupi make:plugin │ │ └── Plugin*Command.php # install, validate, inspect, migrate... │ └── storage/ # Storage interno do src (usado por módulos) ├── public/ # Arquivos estáticos servidos diretamente │ ├── assets/ # CSS, JS, imagens do dashboard │ └── 404.html # Página de erro 404 ├── storage/ # Estado em tempo de execução │ ├── circuit/ # Estado do CircuitBreaker por serviço │ ├── ratelimit/ # Contadores de rate limit (file storage) │ ├── threat/ # ThreatScorer por IP │ ├── backups/ # Backups de banco gerados pelo setup │ ├── modules_state.json # Estado ativo/inativo dos módulos │ ├── capabilities_registry.json # Providers de capacidades registrados │ └── plugins_registry.json # Plugins instalados ├── tests/ # Testes automatizados │ ├── Unit/ # Testes unitários (PHPUnit) │ ├── Feature/ # Testes de feature │ ├── SecurityTest.php # OWASP API Top 10 │ ├── FuzzTest.php # Fuzzing de endpoints │ ├── LoadAttackTest.php # Carga + ataque combinado │ └── PerformanceTest.php # Testes de performance ├── ci/ # Integração contínua │ ├── github-actions.yml # Pipeline GitHub Actions │ ├── gitlab-ci.yml # Pipeline GitLab CI │ └── fail2ban/ # Configurações Fail2Ban para produção ├── docker/ # Configurações Docker │ └── mysql/conf.d/ # Configuração customizada do MySQL ├── scripts/ # Scripts de instalação e configuração │ ├── install-ubuntu.sh # Instalação completa no Ubuntu │ └── nginx/ # Configs de exemplo para Nginx ├── Documentacao/ # Esta documentação (HTML) ├── index.php # Entry point — bootstrap da aplicação ├── vupi.us # CLI principal: php vupi <comando> ├── db # Runner de banco: php db migrate|seed|rollback ├── docker-compose.yml # PostgreSQL + MySQL + Adminer ├── Caddyfile # Proxy reverso com TLS automático (produção) ├── Caddyfile.dev # Proxy reverso local (desenvolvimento) ├── Makefile # Atalhos: make caddy-start, make test... ├── install.sh # Instalação rápida Ubuntu (um comando) ├── composer.json # Dependências PHP └── EXEMPLO.env # Template de configuração

Fluxo de uma requisição

1

index.php — Entry point

Toda requisição chega aqui. O arquivo carrega o .env, monta o Container de DI, registra os bindings e inicializa a Application.

2

Application::run()

Executa o pipeline de segurança global: HttpsEnforcerBotBlockerSecurityHeaders → dispatch da rota.

3

Router::dispatch()

Encontra a rota correspondente e executa os middlewares em cadeia (onion model). Cada middleware pode bloquear ou passar adiante.

4

Controller → Service → Repository

O Controller recebe a Request, delega ao Service (regras de negócio), que usa o Repository (acesso ao banco). Retorna um Response.

5

Response::Enviar()

A resposta percorre o pipeline de volta, headers de segurança são injetados, e o JSON é enviado ao cliente.

Módulos nativos

O sistema vem com dois módulos pré-instalados em src/Modules/:

MóduloResponsabilidade
AuthLogin, logout, refresh token, recuperação de senha, verificação de e-mail.
UsuarioRegistro, perfil, gerenciamento de usuários (admin), upload de avatar.

Camada Kernel

O núcleo do sistema. Entenda cada componente de src/Kernel/.

Regra de ouro

Você raramente precisará modificar o Kernel. Ele foi projetado para ser estável e extensível via módulos.

Nucleo/ — O coração

Container.php

Container de Injeção de Dependência. Resolve classes automaticamente via Reflection. Suporta singletons, bindings e resolução recursiva.

Router.php

Roteador HTTP. Suporta GET, POST, PUT, PATCH, DELETE. Parâmetros dinâmicos ({uuid}), middlewares por rota e pipeline em cadeia.

Application.php

Orquestra o boot: carrega módulos, registra rotas, executa BotBlocker e SecurityHeaders globalmente, despacha a requisição.

ModuleLoader.php

Descobre e carrega módulos automaticamente de src/Modules/. Chama boot() e registerRoutes() de cada provider.

PluginManager.php

Gerencia plugins externos (instalação, ativação, desativação, remoção). Persiste estado em storage/.

CapabilityResolver.php

Sistema de capacidades. Permite que módulos declarem e consumam serviços (ex: email-sender) sem acoplamento direto.

Middlewares/

Todos os middlewares implementam MiddlewareInterface e seguem o padrão onion (cebola).

MiddlewareFunção
AuthHybridMiddlewareAutentica via JWT (cookie ou header Authorization). Valida assinatura, claims, blacklist e UUID.
AdminOnlyMiddlewareRestringe acesso a admin_system com token assinado por JWT_API_SECRET.
AuthCookieMiddlewareAutentica exclusivamente via cookie auth_token. Valida JWT e blacklist.
AuthPageMiddlewareAutenticação para páginas HTML do dashboard. Redireciona para / se não autenticado.
OptionalAuthHybridMiddlewareAutentica se token presente, mas não obriga. Útil para rotas públicas com dados extras para autenticados.
ApiTokenMiddlewareAutenticação via token de API (tipo api no JWT, assinado com JWT_API_SECRET).
RateLimitMiddlewareRate limiting por IP + usuário. Redis (distribuído) ou File (servidor único). Configurável por rota.
BotBlockerMiddlewareBloqueia User-Agents de scanners (sqlmap, nikto, nmap...). Aplica delay progressivo por ThreatScore.
SecurityHeadersMiddlewareInjeta CSP, HSTS, X-Frame-Options, CORP, COEP, COOP em todas as respostas globalmente.
CircuitBreakerMiddlewareProtege contra falhas em cascata. Estados: CLOSED → OPEN → HALF. Persiste em Redis ou File.
RouteProtectionMiddlewareProteção genérica de rotas com validação de JWT e roles. Aceita token de usuário ou de API.
HttpsEnforcerMiddlewareRedireciona HTTP → HTTPS quando COOKIE_SECURE=true.

Como usar um middleware em uma rota

php
// Rota pública — sem middleware
$router->get('/api/status', [StatusController::class, 'index']);

// Rota autenticada
$router->get('/api/perfil', [PerfilController::class, 'index'], [
    AuthHybridMiddleware::class,
]);

// Rota admin com rate limit
$router->post('/api/usuarios', [UsuarioController::class, 'criar'], [
    AuthHybridMiddleware::class,
    AdminOnlyMiddleware::class,
    [RateLimitMiddleware::class, ['limit' => 5, 'window' => 60, 'key' => 'usuario.criar']],
]);

Support/ — Utilitários

ArquivoFunção
JwtDecoder.phpDecodifica e valida tokens JWT. Suporta key rotation via kid, validação de iss e aud.
ThreatScorer.phpAcumula pontos de comportamento suspeito por IP. Threshold 150 = bloqueio. TTL 1h.
AuditLogger.phpRegistra eventos de segurança no banco e stderr (Fail2Ban). Detecta brute force automaticamente.
SecurityEventLogger.phpLogger estruturado JSON para THREAT, AUTH, BUSINESS_LOGIC, ABUSE. Integra com Datadog/CloudWatch.
IdempotencyLock.phpDistributed lock via Redis SET NX ou flock. Previne race conditions em operações críticas.
IpResolver.phpResolve IP real do cliente respeitando TRUST_PROXY e X-Forwarded-For.
OwnershipGuard.phpVerifica se o usuário autenticado é dono do recurso. Previne IDOR.
MailerService.phpEnvio de e-mails via SMTP (PHPMailer). Suporta HTML, templates e throttling.
EmailHistory.phpPersiste historico de e-mails enviados. Busca, filtra e deleta registros.
EmailThrottle.phpControla throttle de envio por tipo e e-mail. Cooldown configuravel (padrao 120s).
CookieConfig.phpCentraliza configuracao de cookies. Detecta HTTPS via porta, header e TRUST_PROXY.
TokenExtractor.phpExtrai token JWT do header Authorization: Bearer ou X-API-KEY.
RequestContext.phpContexto da requisicao: request_id, IP, user_agent. Injetado em Logger e AuditLogger.
Logger.phpLogger estruturado JSON em stderr. Suprime logs em testing. Compativel com Docker/K8s.
Storage/RateLimitStorageFactory, RedisRateLimitStorage, FileRateLimitStorage.
DB/Migrator, PluginMigrator - execucao de migrations do core e plugins.

Utils/ - Utilitarios de dominio

ArquivoFuncao
Sanitizer.phpSanitizacao de inputs: string, email, username, positiveInt, nivelAcesso, uuid, search, url, text, password.
ImageProcessor.phpRedimensiona e salva imagens (JPEG, PNG, WebP). Preserva transparencia. Fallback se GD indisponivel.
RelogioTimeZone.phpSingleton para timezone. Le APP_TIMEZONE do .env. Retorna DateTimeImmutable com timezone correto.

Http/ — Request e Response

php
// Response JSON
return Response::json(['status' => 'ok', 'data' => $dados]);
return Response::json(['error' => 'Não encontrado'], 404);

// Response HTML
return Response::html($html);

// Acessar dados da requisição
$body    = $request->body;          // array do JSON body
$query   = $request->query;         // query string params
$usuario = $request->attribute('auth_user'); // injetado pelo AuthMiddleware
$uuid    = $request->param('uuid');  // parâmetro de rota {uuid}

Contracts/ — Interfaces

As interfaces definem contratos que o Kernel usa internamente. Módulos podem implementá-las para integrar com o sistema:

InterfaceImplementação padrão
UserRepositoryInterfaceModules/Usuario/Repositories/UsuarioRepository
TokenBlacklistInterfaceModules/Auth/Repositories/AccessTokenBlacklistRepository
EmailSenderInterfaceKernel/Support/MailerService
RateLimitStorageInterfaceRedisRateLimitStorage ou FileRateLimitStorage
ModuleProviderInterfaceImplementada por cada módulo em seu ServiceProvider
RouterInterfaceKernel/Nucleo/Router
ContainerInterfaceKernel/Nucleo/Container
MiddlewareInterfaceTodos os middlewares em Kernel/Middlewares/
TenantResolverInterfaceResolução de tenant (subdomínio, path, header, JWT)

Módulos

Onde sua aplicação vive. Cada pasta em src/Modules/ é um módulo independente.

O conceito Zero Config

No Vupi.us, uma pasta é um módulo. Não existe registro manual. O ModuleLoader varre src/Modules/ automaticamente e carrega tudo que encontrar.

Regra simples

Crie a pasta → crie o ServiceProvider → crie o Routes/web.php. O sistema faz o resto.

Estrutura de um módulo

src/Modules/Usuario/ ├── Controllers/ # Endpoints HTTP — recebe Request, retorna Response │ └── UsuarioController.php ├── Services/ # Regras de negócio — o coração do módulo │ ├── UsuarioService.php │ └── UsuarioServiceInterface.php ├── Repositories/ # Acesso ao banco de dados │ ├── UsuarioRepository.php │ ├── UsuarioRepositoryInterface.php │ └── UsuarioAbstractRepository.php ├── Entities/ # Modelos de domínio (sem dependência de framework) │ └── Usuario.php ├── Exceptions/ # Exceções específicas do módulo │ ├── InvalidEmailException.php │ └── InvalidPasswordException.php ├── Database/ │ ├── Migrations/ # Criação e alteração de tabelas │ ├── Seeders/ # Dados iniciais │ └── connection.php # Declara qual banco usar (core ou modules) └── Routes/ └── web.php # ⚠ Obrigatório para ter rotas HTTP

Criando um Módulo

Guia completo para construir o módulo Usuario do zero — idêntico ao módulo nativo do sistema.

O que você vai construir

Ao seguir todos os passos, você terá o módulo Usuario completo: registro, perfil, gerenciamento admin, alteração de senha/e-mail, upload de avatar, verificação de e-mail e perfil público — exatamente como o módulo nativo.

Arquivos que serão criados

src/Modules/Usuario/ ├── Controllers/ │ └── UsuarioController.php # Todos os endpoints HTTP ├── Services/ │ ├── UsuarioServiceInterface.php # Contrato do service │ └── UsuarioService.php # Regras de negócio ├── Repositories/ │ ├── UsuarioRepositoryInterface.php # Contrato do repository │ ├── UsuarioAbstractRepository.php # Base com operações comuns │ └── UsuarioRepository.php # Implementação concreta ├── Entities/ │ └── Usuario.php # Domínio com validações ├── Exceptions/ │ ├── DomainException.php │ ├── InvalidEmailException.php │ ├── InvalidPasswordException.php │ └── InvalidUsernameException.php ├── Database/ │ ├── Migrations/ │ │ └── 001_create_usuarios.php │ ├── Seeders/ │ │ └── 001_admin_user.php │ └── connection.php # Declara qual banco usar └── Routes/ └── web.php # ⚠ Obrigatório para ter rotas HTTP

Passo 1 — Criar a estrutura de pastas

bash
mkdir -p src/Modules/Usuario/{Controllers,Services,Repositories,Entities,Exceptions,Database/Migrations,Database/Seeders,Routes}

Ou via CLI:

bash
php vupi make:module Usuario

Passo 2 — Criar as Exceptions

O módulo usa exceções próprias para erros de domínio. Crie os quatro arquivos abaixo.

Arquivo: src/Modules/Usuario/Exceptions/DomainException.php

php
<?php
namespace Src\Modules\Usuario\Exceptions;

class DomainException extends \DomainException {}

Arquivo: src/Modules/Usuario/Exceptions/InvalidEmailException.php

php
<?php
namespace Src\Modules\Usuario\Exceptions;

class InvalidEmailException extends DomainException {}

Arquivo: src/Modules/Usuario/Exceptions/InvalidPasswordException.php

php
<?php
namespace Src\Modules\Usuario\Exceptions;

class InvalidPasswordException extends DomainException {}

Arquivo: src/Modules/Usuario/Exceptions/InvalidUsernameException.php

php
<?php
namespace Src\Modules\Usuario\Exceptions;

class InvalidUsernameException extends DomainException {}

Passo 3 — Criar a Entity

A Entity representa o domínio. Contém todas as validações de negócio: formato de e-mail, regras de username, complexidade de senha e níveis de acesso válidos.

Arquivo: src/Modules/Usuario/Entities/Usuario.php

php
<?php
namespace Src\Modules\Usuario\Entities;

use DateTimeImmutable;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;
use Src\Kernel\Utils\RelogioTimeZone;
use Src\Modules\Usuario\Exceptions\InvalidEmailException;
use Src\Modules\Usuario\Exceptions\InvalidPasswordException;
use Src\Modules\Usuario\Exceptions\InvalidUsernameException;

final class Usuario
{
    private const NIVEIS_VALIDOS = ['usuario', 'admin', 'moderador', 'admin_system'];

    private function __construct(
        private UuidInterface      $uuid,
        private string             $nomeCompleto,
        private string             $username,
        private string             $email,
        private string             $senhaHash,
        private string             $nivelAcesso,
        private bool               $ativo,
        private bool               $verificado_email,
        private DateTimeImmutable  $criadoEm,
        private ?string            $urlAvatar             = null,
        private ?string            $urlCapa               = null,
        private ?string            $biografia             = null,
        private ?string            $tokenRecuperacaoSenha = null,
        private ?string            $tokenVerificacaoEmail = null,
        private ?DateTimeImmutable $atualizadoEm          = null,
        private string             $statusVerificacao     = 'Não verificado'
    ) {}

    // ── Factories ────────────────────────────────────────────────────────

    /** Cria um novo usuário com validações completas. */
    public static function registrar(
        string  $nomeCompleto,
        string  $username,
        string  $email,
        string  $senha,
        ?string $urlAvatar         = null,
        ?string $urlCapa           = null,
        ?string $biografia         = null,
        string  $nivelAcesso       = 'usuario',
        bool    $verificado_email  = false,
        string  $statusVerificacao = 'Não verificado'
    ): self {
        $username = strtolower(trim($username));
        self::validarUsername($username);
        self::validarEmail($email);
        self::validarSenha($senha);
        self::validarNivelAcesso($nivelAcesso);

        return new self(
            Uuid::uuid4(),
            $nomeCompleto,
            $username,
            $email,
            password_hash($senha, PASSWORD_ARGON2ID),
            $nivelAcesso,
            true,
            $verificado_email,
            RelogioTimeZone::agora(),
            $urlAvatar,
            $urlCapa,
            $biografia,
            null,
            null,
            null,
            $statusVerificacao
        );
    }

    /** Reconstitui um usuário a partir dos dados do banco. */
    public static function reconstituir(
        UuidInterface      $uuid,
        string             $nomeCompleto,
        string             $username,
        string             $email,
        string             $senhaHash,
        string             $nivelAcesso,
        bool               $ativo,
        bool               $verificado_email,
        DateTimeImmutable  $criadoEm,
        ?string            $urlAvatar             = null,
        ?string            $urlCapa               = null,
        ?string            $biografia             = null,
        ?string            $tokenRecuperacaoSenha = null,
        ?string            $tokenVerificacaoEmail = null,
        ?DateTimeImmutable $atualizadoEm          = null,
        string             $statusVerificacao     = 'Não verificado'
    ): self {
        return new self(
            $uuid, $nomeCompleto, strtolower(trim($username)), $email, $senhaHash,
            $nivelAcesso, $ativo, $verificado_email, $criadoEm,
            $urlAvatar, $urlCapa, $biografia,
            $tokenRecuperacaoSenha, $tokenVerificacaoEmail, $atualizadoEm, $statusVerificacao
        );
    }

    // ── Validações privadas ───────────────────────────────────────────────

    private static function validarEmail(string $email): void
    {
        if (trim($email) === '') throw new InvalidEmailException('E-mail não informado.');
        if (!filter_var($email, FILTER_VALIDATE_EMAIL))
            throw new InvalidEmailException('Formato de e-mail inválido.');
    }

    private static function validarUsername(string $u): void
    {
        if (trim($u) === '') throw new InvalidUsernameException('Username não informado.');
        if (preg_match('/^[._]/', $u))
            throw new InvalidUsernameException('Username não pode iniciar com ponto ou underline.');
        if (strlen($u) < 3)
            throw new InvalidUsernameException('Username deve ter ao menos 3 caracteres.');
        if (!preg_match('/^[a-z0-9._]+$/', $u))
            throw new InvalidUsernameException('Username só pode conter letras minúsculas, números, ponto ou underline.');
        if (preg_match_all('/[._]/', $u) > 1)
            throw new InvalidUsernameException('Username pode conter apenas um caractere especial (ponto ou underline).');
    }

    private static function validarSenha(string $s): void
    {
        if (trim($s) === '') throw new InvalidPasswordException('Senha não informada.');
        if (strlen($s) < 8)  throw new InvalidPasswordException('Senha muito curta. Mínimo 8 caracteres.');
        if (!preg_match('/[A-Z]/', $s)) throw new InvalidPasswordException('Senha deve conter ao menos uma letra maiúscula.');
        if (!preg_match('/[a-z]/', $s)) throw new InvalidPasswordException('Senha deve conter ao menos uma letra minúscula.');
        if (!preg_match('/[0-9]/', $s)) throw new InvalidPasswordException('Senha deve conter ao menos um número.');
        if (!preg_match('/[^a-zA-Z0-9]/', $s)) throw new InvalidPasswordException('Senha deve conter ao menos um caractere especial.');
    }

    private static function validarNivelAcesso(string $nivel): void
    {
        if (!in_array($nivel, self::NIVEIS_VALIDOS, true))
            throw new \InvalidArgumentException('Nível de acesso inválido.');
    }

    // ── Comportamentos ────────────────────────────────────────────────────

    public function verificarSenha(string $senhaPlana): bool
    {
        return password_verify($senhaPlana, $this->senhaHash);
    }

    public function alterarSenha(string $senhaPlana): void
    {
        self::validarSenha($senhaPlana);
        $this->senhaHash  = password_hash($senhaPlana, PASSWORD_ARGON2ID);
        $this->atualizadoEm = RelogioTimeZone::agora();
    }

    public function promoverPara(string $nivelAcesso): void
    {
        self::validarNivelAcesso($nivelAcesso);
        $this->nivelAcesso  = $nivelAcesso;
        $this->atualizadoEm = RelogioTimeZone::agora();
    }

    public function ativar(): void   { $this->ativo = true;  $this->atualizadoEm = RelogioTimeZone::agora(); }
    public function desativar(): void { $this->ativo = false; $this->atualizadoEm = RelogioTimeZone::agora(); }

    public function gerarTokenVerificacaoEmail(string $token): void
    {
        $this->tokenVerificacaoEmail = $token;
        $this->atualizadoEm = RelogioTimeZone::agora();
    }

    public function gerarTokenRecuperacaoSenha(string $token): void
    {
        $this->tokenRecuperacaoSenha = $token;
        $this->atualizadoEm = RelogioTimeZone::agora();
    }

    // ── Getters ───────────────────────────────────────────────────────────

    public function getUuid(): UuidInterface            { return $this->uuid; }
    public function getNomeCompleto(): string           { return $this->nomeCompleto; }
    public function getUsername(): string               { return $this->username; }
    public function getEmail(): string                  { return $this->email; }
    public function getSenhaHash(): string              { return $this->senhaHash; }
    public function getNivelAcesso(): string            { return $this->nivelAcesso; }
    public function isAtivo(): bool                     { return $this->ativo; }
    public function isEmailVerificado(): bool           { return $this->verificado_email; }
    public function getStatusVerificacao(): string      { return $this->statusVerificacao; }
    public function getUrlAvatar(): ?string             { return $this->urlAvatar; }
    public function getUrlCapa(): ?string               { return $this->urlCapa; }
    public function getBiografia(): ?string             { return $this->biografia; }
    public function getCriadoEm(): DateTimeImmutable    { return $this->criadoEm; }
    public function getAtualizadoEm(): ?DateTimeImmutable { return $this->atualizadoEm; }
    public function getTokenVerificacaoEmail(): ?string { return $this->tokenVerificacaoEmail; }
    public function getTokenRecuperacaoSenha(): ?string { return $this->tokenRecuperacaoSenha; }

    // ── Setters ───────────────────────────────────────────────────────────

    public function setNomeCompleto(string $v): void    { $this->nomeCompleto = $v; }
    public function setUsername(string $v): void        { self::validarUsername(strtolower(trim($v))); $this->username = strtolower(trim($v)); }
    public function setEmail(string $v): void           { self::validarEmail($v); $this->email = $v; }
    public function setUrlAvatar(?string $v): void      { $this->urlAvatar = $v; }
    public function setUrlCapa(?string $v): void        { $this->urlCapa = $v; }
    public function setBiografia(?string $v): void      { $this->biografia = $v; }
    public function setEmailVerificado(bool $v): void   { $this->verificado_email = $v; }
    public function setStatusVerificacao(string $v): void { $this->statusVerificacao = $v; }
    public function setAtualizadoEm(?DateTimeImmutable $v): void { $this->atualizadoEm = $v; }
    public function setCriadoEm(DateTimeImmutable $v): void      { $this->criadoEm = $v; }
}
Regras de senha

A senha deve ter ao menos 8 caracteres, uma maiúscula, uma minúscula, um número e um caractere especial. O hash usa PASSWORD_ARGON2ID.

Regras de username

Mínimo 3 caracteres, apenas letras minúsculas, números, ponto ou underline. Não pode iniciar com ponto/underline. Máximo de um caractere especial.

Passo 4 — Criar as Interfaces

As interfaces definem o contrato que o Container usa para injeção de dependência. O UsuarioRepositoryInterface estende UserRepositoryInterface do Kernel — isso é obrigatório para que o AuthHybridMiddleware consiga buscar usuários.

Arquivo: src/Modules/Usuario/Repositories/UsuarioRepositoryInterface.php

php
<?php
namespace Src\Modules\Usuario\Repositories;

use Src\Kernel\Contracts\UserRepositoryInterface;
use Src\Modules\Usuario\Entities\Usuario;

interface UsuarioRepositoryInterface extends UserRepositoryInterface
{
    public function salvar(Usuario $usuario): void;
    public function deletar(string $uuid): void;
    public function buscarPorUuid(string $uuid): ?Usuario;
    public function buscarPorEmail(string $email): ?Usuario;
    public function buscarPorUsername(string $username): ?Usuario;
    public function buscarTodos(int $limite = 100, int $offset = 0): array;
    public function contar(): int;
    public function emailExiste(string $email, ?string $excluirUuid = null): bool;
    public function usernameExiste(string $username, ?string $excluirUuid = null): bool;
    public function salvarTokenVerificacaoEmail(string $uuid, string $token): void;
    public function buscarPorTokenVerificacaoEmail(string $token): ?Usuario;
    public function marcarEmailComoVerificado(string $uuid, bool $verificado = true): void;
    public function salvarTokenRecuperacaoSenha(string $uuid, string $token): void;
    public function buscarPorTokenRecuperacaoSenha(string $token): ?Usuario;
    public function limparTokenRecuperacaoSenha(string $uuid): void;
    public function buscarPorNomePaginado(string $nome, int $pagina = 1, int $porPagina = 10): array;
    public function buscarComFiltro(int $pagina, int $porPagina, string $busca = '', string $nivel = ''): array;
    public function listarUsernamesAtivos(int $limite = 50000, int $offset = 0): array;
}

Arquivo: src/Modules/Usuario/Services/UsuarioServiceInterface.php

php
<?php
namespace Src\Modules\Usuario\Services;

use Src\Modules\Usuario\Entities\Usuario;

interface UsuarioServiceInterface
{
    public function emailExiste(string $email): bool;
    public function usernameExiste(string $username): bool;
    public function criar(Usuario $usuario): void;
    public function atualizar(string $uuid, array $dados): void;
    public function buscarPorUuid(string $uuid): ?Usuario;
    public function buscarPorUsername(string $username): ?Usuario;
    public function buscarPorEmail(string $email): ?Usuario;
    public function salvarTokenVerificacaoEmail(string $uuid, string $token): void;
    public function buscarPorTokenVerificacaoEmail(string $token): ?Usuario;
    public function marcarEmailComoVerificado(string $uuid): void;
    public function resetarVerificacaoEmail(string $uuid): void;
    public function salvarTokenRecuperacaoSenha(string $uuid, string $token): void;
    public function buscarPorTokenRecuperacaoSenha(string $token): ?Usuario;
    public function limparTokenRecuperacaoSenha(string $uuid): void;
    public function verificarSenha(string $uuid, string $senha): bool;
    public function alterarSenha(string $uuid, string $novaSenha, bool $logoutAll = false): void;
    public function listar(int $pagina = 1, int $porPagina = 20): array;
    public function listarComFiltro(int $pagina, int $porPagina, string $busca = '', string $nivel = ''): array;
    public function desativar(string $uuid): void;
    public function ativar(string $uuid): void;
    public function deletar(string $uuid): void;
    public function listarUsernamesAtivosParaSitemap(int $limite = 50000, int $offset = 0): array;
}

Passo 5 — Criar o AbstractRepository

A classe abstrata centraliza as operações comuns de banco (busca, delete, contagem) e o mapeamento de rows para Entity. O UsuarioRepository concreto herda dela e só precisa implementar o salvar().

Arquivo: src/Modules/Usuario/Repositories/UsuarioAbstractRepository.php

php
<?php
namespace Src\Modules\Usuario\Repositories;

use PDO;
use PDOException;
use DateTimeImmutable;
use Ramsey\Uuid\Uuid;
use Src\Modules\Usuario\Entities\Usuario;

abstract class UsuarioAbstractRepository implements UsuarioRepositoryInterface
{
    protected string $tabela   = 'usuarios';
    protected string $colunaId = 'uuid';
    protected array  $colunasPermitidas = [
        'email'              => 'email',
        'username'           => 'username',
        'nivel_acesso'       => 'nivel_acesso',
        'ativo'              => 'ativo',
        'status_verificacao' => 'status_verificacao',
    ];

    public function __construct(protected PDO $pdo) {}

    protected function executarQuery(callable $op, string $msg = 'Erro no banco')
    {
        try {
            return $op();
        } catch (PDOException $e) {
            throw new \RuntimeException($msg . ': ' . $e->getMessage(), (int) $e->getCode(), $e);
        }
    }

    public function buscarTodos(int $limite = 100, int $offset = 0): array
    {
        return $this->executarQuery(function () use ($limite, $offset) {
            $stmt = $this->pdo->prepare(
                "SELECT * FROM {$this->tabela} ORDER BY criado_em DESC LIMIT :l OFFSET :o"
            );
            $stmt->bindValue(':l', $limite, PDO::PARAM_INT);
            $stmt->bindValue(':o', $offset, PDO::PARAM_INT);
            $stmt->execute();
            return array_map([$this, 'mapearParaEntity'], $stmt->fetchAll(PDO::FETCH_ASSOC));
        }, 'Erro ao buscar usuários');
    }

    public function buscarPorUuid(string $uuid): ?Usuario    { return $this->buscarUmPor($this->colunaId, $uuid); }
    public function buscarPorUsername(string $u): ?Usuario   { return $this->buscarUmPor('username', $u); }
    public function buscarPorEmail(string $e): ?Usuario      { return $this->buscarUmPor('email', $e); }

    public function deletar(string $uuid): void
    {
        $this->executarQuery(function () use ($uuid) {
            $stmt = $this->pdo->prepare("DELETE FROM {$this->tabela} WHERE {$this->colunaId} = :uuid");
            $stmt->bindValue(':uuid', $uuid);
            $stmt->execute();
        }, 'Erro ao deletar usuário');
    }

    public function contar(): int
    {
        return $this->executarQuery(function () {
            return (int) $this->pdo->query("SELECT COUNT(*) FROM {$this->tabela}")->fetchColumn();
        }, 'Erro ao contar usuários');
    }

    public function emailExiste(string $email, ?string $excluirUuid = null): bool
    {
        $sql    = "SELECT 1 FROM {$this->tabela} WHERE email = :email";
        $params = [':email' => $email];
        if ($excluirUuid) { $sql .= " AND {$this->colunaId} != :uuid"; $params[':uuid'] = $excluirUuid; }
        $stmt = $this->pdo->prepare($sql);
        $stmt->execute($params);
        return $stmt->fetchColumn() !== false;
    }

    public function usernameExiste(string $username, ?string $excluirUuid = null): bool
    {
        $sql    = "SELECT 1 FROM {$this->tabela} WHERE username = :username";
        $params = [':username' => $username];
        if ($excluirUuid) { $sql .= " AND {$this->colunaId} != :uuid"; $params[':uuid'] = $excluirUuid; }
        $stmt = $this->pdo->prepare($sql);
        $stmt->execute($params);
        return $stmt->fetchColumn() !== false;
    }

    public function salvarTokenVerificacaoEmail(string $uuid, string $token): void
    {
        $stmt = $this->pdo->prepare(
            "UPDATE {$this->tabela} SET token_verificacao_email = :token WHERE {$this->colunaId} = :uuid"
        );
        $stmt->execute([':token' => $token, ':uuid' => $uuid]);
    }

    public function buscarPorTokenVerificacaoEmail(string $token): ?Usuario
    {
        if (empty($token)) return null;
        $stmt = $this->pdo->prepare(
            "SELECT * FROM {$this->tabela} WHERE token_verificacao_email = :token LIMIT 1"
        );
        $stmt->execute([':token' => $token]);
        $row = $stmt->fetch(PDO::FETCH_ASSOC);
        return $row ? $this->mapearParaEntity($row) : null;
    }

    public function marcarEmailComoVerificado(string $uuid, bool $verificado = true): void
    {
        $sql = $verificado
            ? "UPDATE {$this->tabela} SET verificado_email = :v, token_verificacao_email = NULL WHERE {$this->colunaId} = :uuid"
            : "UPDATE {$this->tabela} SET verificado_email = :v WHERE {$this->colunaId} = :uuid";
        $stmt = $this->pdo->prepare($sql);
        $stmt->bindValue(':v', $verificado, PDO::PARAM_BOOL);
        $stmt->bindValue(':uuid', $uuid);
        $stmt->execute();
    }

    public function salvarTokenRecuperacaoSenha(string $uuid, string $token): void
    {
        $stmt = $this->pdo->prepare(
            "UPDATE {$this->tabela} SET token_recuperacao_senha = :token WHERE {$this->colunaId} = :uuid"
        );
        $stmt->execute([':token' => $token, ':uuid' => $uuid]);
    }

    public function buscarPorTokenRecuperacaoSenha(string $token): ?Usuario
    {
        $stmt = $this->pdo->prepare(
            "SELECT * FROM {$this->tabela} WHERE token_recuperacao_senha = :token LIMIT 1"
        );
        $stmt->execute([':token' => $token]);
        $row = $stmt->fetch(PDO::FETCH_ASSOC);
        return $row ? $this->mapearParaEntity($row) : null;
    }

    public function limparTokenRecuperacaoSenha(string $uuid): void
    {
        $stmt = $this->pdo->prepare(
            "UPDATE {$this->tabela} SET token_recuperacao_senha = NULL WHERE {$this->colunaId} = :uuid"
        );
        $stmt->execute([':uuid' => $uuid]);
    }

    public function buscarPorNomePaginado(string $nome, int $pagina = 1, int $porPagina = 10): array
    {
        $offset = ($pagina - 1) * $porPagina;
        $stmt   = $this->pdo->prepare(
            "SELECT * FROM {$this->tabela} WHERE nome_completo LIKE :nome ORDER BY criado_em DESC LIMIT :l OFFSET :o"
        );
        $stmt->bindValue(':nome', "%{$nome}%");
        $stmt->bindValue(':l', $porPagina, PDO::PARAM_INT);
        $stmt->bindValue(':o', $offset, PDO::PARAM_INT);
        $stmt->execute();
        return array_map([$this, 'mapearParaEntity'], $stmt->fetchAll(PDO::FETCH_ASSOC));
    }

    public function buscarComFiltro(int $pagina, int $porPagina, string $busca = '', string $nivel = ''): array
    {
        $offset = ($pagina - 1) * $porPagina;
        $params = [];
        $where  = [];
        if ($busca !== '') { $where[] = "(username LIKE :busca OR email LIKE :busca)"; $params[':busca'] = '%' . $busca . '%'; }
        if ($nivel !== '') { $where[] = "nivel_acesso = :nivel"; $params[':nivel'] = $nivel; }
        $wc = $where ? 'WHERE ' . implode(' AND ', $where) : '';

        $stmtCount = $this->pdo->prepare("SELECT COUNT(*) FROM {$this->tabela} {$wc}");
        $stmtCount->execute($params);
        $total = (int) $stmtCount->fetchColumn();

        $stmtRows = $this->pdo->prepare(
            "SELECT * FROM {$this->tabela} {$wc} ORDER BY criado_em DESC LIMIT :l OFFSET :o"
        );
        foreach ($params as $k => $v) $stmtRows->bindValue($k, $v);
        $stmtRows->bindValue(':l', $porPagina, PDO::PARAM_INT);
        $stmtRows->bindValue(':o', $offset, PDO::PARAM_INT);
        $stmtRows->execute();

        return [
            'usuarios'      => array_map([$this, 'mapearParaEntity'], $stmtRows->fetchAll(PDO::FETCH_ASSOC) ?: []),
            'total'         => $total,
            'total_paginas' => max(1, (int) ceil($total / $porPagina)),
        ];
    }

    public function listarUsernamesAtivos(int $limite = 50000, int $offset = 0): array
    {
        $stmt = $this->pdo->prepare(
            "SELECT username, atualizado_em, criado_em FROM {$this->tabela}
             WHERE ativo = :ativo AND username IS NOT NULL AND username <> ''
             ORDER BY criado_em DESC LIMIT :l OFFSET :o"
        );
        $stmt->bindValue(':ativo', true, PDO::PARAM_BOOL);
        $stmt->bindValue(':l', $limite, PDO::PARAM_INT);
        $stmt->bindValue(':o', $offset, PDO::PARAM_INT);
        $stmt->execute();
        return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
    }

    protected function buscarUmPor(string $coluna, mixed $valor): ?Usuario
    {
        $col  = $this->resolverColuna($coluna);
        $sql  = $col === 'username'
            ? "SELECT * FROM {$this->tabela} WHERE LOWER({$col}) = LOWER(:valor) LIMIT 1"
            : "SELECT * FROM {$this->tabela} WHERE {$col} = :valor LIMIT 1";
        $stmt = $this->pdo->prepare($sql);
        $stmt->bindValue(':valor', $valor);
        $stmt->execute();
        $row = $stmt->fetch(PDO::FETCH_ASSOC);
        return $row ? $this->mapearParaEntity($row) : null;
    }

    protected function resolverColuna(string $coluna): string
    {
        if ($coluna === $this->colunaId) return $this->colunaId;
        if (isset($this->colunasPermitidas[$coluna])) return $this->colunasPermitidas[$coluna];
        throw new \InvalidArgumentException('Coluna inválida para busca');
    }

    protected function mapearParaEntity(array $dados): Usuario
    {
        return Usuario::reconstituir(
            Uuid::fromString($dados['uuid']),
            $dados['nome_completo'],
            $dados['username'],
            $dados['email'],
            $dados['senha_hash'],
            $dados['nivel_acesso'],
            (bool) $dados['ativo'],
            (bool) $dados['verificado_email'],
            new DateTimeImmutable($dados['criado_em']),
            $dados['url_avatar']             ?? null,
            $dados['url_capa']               ?? null,
            $dados['biografia']              ?? null,
            $dados['token_recuperacao_senha'] ?? null,
            $dados['token_verificacao_email'] ?? null,
            isset($dados['atualizado_em']) ? new DateTimeImmutable((string) $dados['atualizado_em']) : null,
            $dados['status_verificacao']     ?? 'Não verificado'
        );
    }

    abstract public function salvar(Usuario $usuario): void;
}

Passo 6 — Criar o Repository concreto

O UsuarioRepository herda do abstract e implementa apenas o salvar(), que usa upsert para criar ou atualizar com uma única query.

Arquivo: src/Modules/Usuario/Repositories/UsuarioRepository.php

php
<?php
namespace Src\Modules\Usuario\Repositories;

use PDO;
use Src\Kernel\Contracts\UserRepositoryInterface;
use Src\Modules\Usuario\Entities\Usuario;
use Src\Kernel\Utils\RelogioTimeZone;

class UsuarioRepository extends UsuarioAbstractRepository implements UserRepositoryInterface
{
    public function __construct(PDO $pdo)
    {
        parent::__construct($pdo);
    }

    public function salvar(Usuario $usuario): void
    {
        $this->executarQuery(function () use ($usuario) {
            $agora  = RelogioTimeZone::agora()->format('Y-m-d H:i:s');
            $uuid   = $usuario->getUuid()->toString();
            $driver = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME);

            if ($driver === 'pgsql') {
                $sql = "INSERT INTO {$this->tabela}
                            (uuid, nome_completo, email, username, senha_hash, url_avatar, url_capa,
                             biografia, nivel_acesso, ativo, status_verificacao, token_verificacao_email, criado_em)
                        VALUES
                            (:uuid, :nome_completo, :email, :username, :senha_hash, :url_avatar, :url_capa,
                             :biografia, :nivel_acesso, :ativo, :status_verificacao, :token_verificacao_email, :criado_em)
                        ON CONFLICT ({$this->colunaId}) DO UPDATE SET
                            nome_completo           = EXCLUDED.nome_completo,
                            email                   = EXCLUDED.email,
                            username                = EXCLUDED.username,
                            senha_hash              = EXCLUDED.senha_hash,
                            url_avatar              = EXCLUDED.url_avatar,
                            url_capa                = EXCLUDED.url_capa,
                            biografia               = EXCLUDED.biografia,
                            nivel_acesso            = EXCLUDED.nivel_acesso,
                            ativo                   = EXCLUDED.ativo,
                            status_verificacao      = EXCLUDED.status_verificacao,
                            token_verificacao_email = EXCLUDED.token_verificacao_email,
                            atualizado_em           = NOW()";
            } else {
                $sql = "INSERT INTO {$this->tabela}
                            (uuid, nome_completo, email, username, senha_hash, url_avatar, url_capa,
                             biografia, nivel_acesso, ativo, status_verificacao, token_verificacao_email, criado_em)
                        VALUES
                            (:uuid, :nome_completo, :email, :username, :senha_hash, :url_avatar, :url_capa,
                             :biografia, :nivel_acesso, :ativo, :status_verificacao, :token_verificacao_email, :criado_em)
                        ON DUPLICATE KEY UPDATE
                            nome_completo           = VALUES(nome_completo),
                            email                   = VALUES(email),
                            username                = VALUES(username),
                            senha_hash              = VALUES(senha_hash),
                            url_avatar              = VALUES(url_avatar),
                            url_capa                = VALUES(url_capa),
                            biografia               = VALUES(biografia),
                            nivel_acesso            = VALUES(nivel_acesso),
                            ativo                   = VALUES(ativo),
                            status_verificacao      = VALUES(status_verificacao),
                            token_verificacao_email = VALUES(token_verificacao_email),
                            atualizado_em           = :atualizado_em";
            }

            $stmt = $this->pdo->prepare($sql);
            $stmt->bindValue(':uuid',                    $uuid);
            $stmt->bindValue(':nome_completo',           $usuario->getNomeCompleto());
            $stmt->bindValue(':email',                   $usuario->getEmail());
            $stmt->bindValue(':username',                $usuario->getUsername());
            $stmt->bindValue(':senha_hash',              $usuario->getSenhaHash());
            $stmt->bindValue(':url_avatar',              $usuario->getUrlAvatar());
            $stmt->bindValue(':url_capa',                $usuario->getUrlCapa());
            $stmt->bindValue(':biografia',               $usuario->getBiografia());
            $stmt->bindValue(':nivel_acesso',            $usuario->getNivelAcesso());
            $stmt->bindValue(':ativo',                   $usuario->isAtivo(), PDO::PARAM_BOOL);
            $stmt->bindValue(':status_verificacao',      $usuario->getStatusVerificacao());
            $stmt->bindValue(':token_verificacao_email', $usuario->getTokenVerificacaoEmail());
            $stmt->bindValue(':criado_em',               $agora);
            if ($driver !== 'pgsql') {
                $stmt->bindValue(':atualizado_em', $agora);
            }
            $stmt->execute();
        }, 'Erro ao salvar usuário');
    }
}

Passo 7 — Criar o Service

O Service contém todas as regras de negócio. Recebe a interface do repository (não a implementação concreta) — o Container resolve automaticamente.

Arquivo: src/Modules/Usuario/Services/UsuarioService.php

php
<?php
namespace Src\Modules\Usuario\Services;

use DomainException;
use DateTimeImmutable;
use Src\Modules\Usuario\Entities\Usuario;
use Src\Modules\Usuario\Repositories\UsuarioRepositoryInterface;

class UsuarioService implements UsuarioServiceInterface
{
    public function __construct(
        private UsuarioRepositoryInterface $repository
    ) {}

    public function emailExiste(string $email): bool    { return $this->repository->emailExiste($email); }
    public function usernameExiste(string $u): bool     { return $this->repository->usernameExiste($u); }
    public function buscarPorUuid(string $uuid): ?Usuario    { return $this->repository->buscarPorUuid($uuid); }
    public function buscarPorUsername(string $u): ?Usuario   { return $this->repository->buscarPorUsername($u); }
    public function buscarPorEmail(string $e): ?Usuario      { return $this->repository->buscarPorEmail($e); }

    public function criar(Usuario $usuario): void
    {
        if ($this->repository->emailExiste($usuario->getEmail()))
            throw new DomainException('E-mail já cadastrado.', 409);
        if ($this->repository->usernameExiste($usuario->getUsername()))
            throw new DomainException('Username já cadastrado.', 409);
        $this->repository->salvar($usuario);
    }

    public function atualizar(string $uuid, array $data): void
    {
        $usuario = $this->repository->buscarPorUuid($uuid);
        if (!$usuario) throw new DomainException('Usuário não encontrado.', 404);

        if (isset($data['nome_completo'])) $usuario->setNomeCompleto($data['nome_completo']);
        if (isset($data['username']))      $usuario->setUsername($data['username']);
        if (isset($data['email']))         $usuario->setEmail($data['email']);
        if (isset($data['senha']))         $usuario->alterarSenha($data['senha']);
        if (isset($data['url_avatar']))    $usuario->setUrlAvatar($data['url_avatar']);
        if (isset($data['url_capa']))      $usuario->setUrlCapa($data['url_capa']);
        if (isset($data['biografia']))     $usuario->setBiografia($data['biografia']);
        if (isset($data['nivel_acesso']))  $usuario->promoverPara($data['nivel_acesso']);
        $usuario->setAtualizadoEm(new DateTimeImmutable());

        if ($this->repository->emailExiste($usuario->getEmail(), $uuid))
            throw new DomainException('E-mail já cadastrado.', 409);
        if ($this->repository->usernameExiste($usuario->getUsername(), $uuid))
            throw new DomainException('Username já cadastrado.', 409);

        $this->repository->salvar($usuario);
    }

    public function alterarSenha(string $uuid, string $novaSenha, bool $logoutAll = false): void
    {
        $usuario = $this->repository->buscarPorUuid($uuid);
        if (!$usuario) throw new DomainException('Usuário não encontrado.', 404);
        $usuario->alterarSenha($novaSenha);
        $this->repository->salvar($usuario);
    }

    public function verificarSenha(string $uuid, string $senha): bool
    {
        $usuario = $this->repository->buscarPorUuid($uuid);
        return $usuario ? $usuario->verificarSenha($senha) : false;
    }

    public function desativar(string $uuid): void
    {
        $usuario = $this->repository->buscarPorUuid($uuid);
        if (!$usuario) throw new DomainException('Usuário não encontrado.', 404);
        $usuario->desativar();
        $this->repository->salvar($usuario);
    }

    public function ativar(string $uuid): void
    {
        $usuario = $this->repository->buscarPorUuid($uuid);
        if (!$usuario) throw new DomainException('Usuário não encontrado.', 404);
        $usuario->ativar();
        $this->repository->salvar($usuario);
    }

    public function deletar(string $uuid): void
    {
        $usuario = $this->repository->buscarPorUuid($uuid);
        if (!$usuario) throw new DomainException('Usuário não encontrado.', 404);
        $this->repository->deletar($uuid);
    }

    public function listar(int $pagina = 1, int $porPagina = 20): array
    {
        return $this->repository->buscarPorNomePaginado('', $pagina, $porPagina);
    }

    public function listarComFiltro(int $pagina, int $porPagina, string $busca = '', string $nivel = ''): array
    {
        return $this->repository->buscarComFiltro($pagina, $porPagina, $busca, $nivel);
    }

    public function salvarTokenVerificacaoEmail(string $uuid, string $token): void
    {
        $usuario = $this->repository->buscarPorUuid($uuid);
        if (!$usuario) throw new DomainException('Usuário não encontrado.', 404);
        $usuario->gerarTokenVerificacaoEmail($token);
        $this->repository->salvarTokenVerificacaoEmail($uuid, $token);
    }

    public function buscarPorTokenVerificacaoEmail(string $token): ?Usuario
    {
        return $this->repository->buscarPorTokenVerificacaoEmail($token);
    }

    public function marcarEmailComoVerificado(string $uuid): void
    {
        if (!$this->repository->buscarPorUuid($uuid))
            throw new DomainException('Usuário não encontrado.', 404);
        $this->repository->marcarEmailComoVerificado($uuid);
    }

    public function resetarVerificacaoEmail(string $uuid): void
    {
        $this->repository->marcarEmailComoVerificado($uuid, false);
    }

    public function salvarTokenRecuperacaoSenha(string $uuid, string $token): void
    {
        $usuario = $this->repository->buscarPorUuid($uuid);
        if (!$usuario) throw new DomainException('Usuário não encontrado.', 404);
        $usuario->gerarTokenRecuperacaoSenha($token);
        $this->repository->salvarTokenRecuperacaoSenha($uuid, $token);
    }

    public function buscarPorTokenRecuperacaoSenha(string $token): ?Usuario
    {
        return $this->repository->buscarPorTokenRecuperacaoSenha($token);
    }

    public function limparTokenRecuperacaoSenha(string $uuid): void
    {
        $this->repository->limparTokenRecuperacaoSenha($uuid);
    }

    public function listarUsernamesAtivosParaSitemap(int $limite = 50000, int $offset = 0): array
    {
        return $this->repository->listarUsernamesAtivos($limite, $offset);
    }
}

Passo 8 — Criar o Controller

O Controller recebe as dependências via construtor. O EmailSenderInterface é opcional — se não estiver configurado, o envio de e-mail é silenciosamente ignorado. O método pdo() usa ModuleConnectionResolver para obter a conexão correta do módulo.

Arquivo: src/Modules/Usuario/Controllers/UsuarioController.php

php
<?php

namespace Src\Modules\Usuario\Controllers;

use DomainException;
use Src\Kernel\Http\Request\Request;
use Src\Kernel\Http\Response\Response;
use Src\Kernel\Utils\ImageProcessor;
use Src\Kernel\Utils\Sanitizer;
use Src\Modules\Usuario\Entities\Usuario;
use Src\Modules\Usuario\Exceptions\DomainException as ModuleDomainException;
use Src\Modules\Usuario\Services\UsuarioServiceInterface;

class UsuarioController
{
    private ?\PDO $pdo = null;

    public function __construct(
        private UsuarioServiceInterface $service,
        private ?\Src\Kernel\Contracts\EmailSenderInterface $emailSender = null
    ) {}

    private function pdo(): \PDO
    {
        if ($this->pdo === null) {
            $this->pdo = \Src\Kernel\Database\ModuleConnectionResolver::forModule('Usuario');
        }
        return $this->pdo;
    }

    // ── Registro público ──────────────────────────────────────────────────

    public function criar(Request $request): Response
    {
        try {
            $body     = $request->body ?? [];
            $nome     = Sanitizer::string($body['nome_completo'] ?? $body['nome'] ?? '', 150);
            $username = Sanitizer::username($body['username'] ?? '');
            $email    = Sanitizer::email($body['email'] ?? '');
            $senha    = Sanitizer::password($body['senha'] ?? $body['password'] ?? '');
            // nivel_acesso nunca vem do body em registro público — sempre 'usuario'
            $nivel    = 'usuario';

            if ($nome === '' || $username === '' || $email === '' || $senha === '') {
                return Response::json(['status' => 'error', 'message' => 'Campos obrigatórios: nome_completo, username, email, senha.'], 422);
            }

            $usuario = Usuario::registrar($nome, $username, $email, $senha, null, null, null, $nivel);
            $this->service->criar($usuario);

            // Gera token e envia e-mail de confirmação se o módulo estiver disponível
            $this->enviarEmailConfirmacaoRegistro($usuario);

            return Response::json([
                'status'  => 'success',
                'message' => 'Usuário criado com sucesso. Verifique seu e-mail para confirmar o cadastro.',
                'usuario' => $this->serializar($usuario),
            ], 201);
        } catch (DomainException | ModuleDomainException $e) {
            $status = $e->getCode() >= 400 && $e->getCode() <= 599 ? $e->getCode() : 422;
            return Response::json(['status' => 'error', 'message' => $e->getMessage()], $status);
        } catch (\Throwable $e) {
            return Response::json(['status' => 'error', 'message' => 'Erro ao criar usuário.', 'details' => $this->debug($e)], 500);
        }
    }

    /**
     * Endpoint público: reenviar e-mail de verificação.
     * POST /api/auth/reenviar-verificacao  { "email": "..." }
     */
    public function reenviarVerificacaoEmail(Request $request): Response
    {
        try {
            $body  = $request->body ?? [];
            $email = Sanitizer::email($body['email'] ?? '');

            if ($email === '') {
                return Response::json(['status' => 'error', 'message' => 'E-mail inválido ou não informado.'], 422);
            }

            $usuario = $this->service->buscarPorEmail($email);

            // Resposta genérica em todos os casos para não revelar se o e-mail existe
            $msgGenerica = 'Se o e-mail existir e não estiver verificado, um novo link será enviado em breve.';

            if (!$usuario) {
                // Registra throttle mesmo assim para evitar enumeração por timing
                $this->registrarReenvioVerificacao($email);
                return Response::json(['status' => 'success', 'message' => $msgGenerica]);
            }

            if ($usuario->isEmailVerificado()) {
                return Response::json(['status' => 'success', 'message' => 'E-mail já verificado. Você pode fazer login normalmente.']);
            }

            // enviarEmailConfirmacaoRegistro já verifica throttle e e-mail verificado internamente
            $this->enviarEmailConfirmacaoRegistro($usuario);

            return Response::json(['status' => 'success', 'message' => $msgGenerica]);
        } catch (\Throwable $e) {
            return Response::json(['status' => 'error', 'message' => 'Erro ao reenviar verificação.'], 500);
        }
    }

    private function podeReenviarVerificacao(string $email): bool
    {
        try {
            return (new \Src\Kernel\Support\EmailThrottle($this->pdo()))->canSend('verification', $email);
        } catch (\Throwable) {
            return true;
        }
    }

    private function registrarReenvioVerificacao(string $email): void
    {
        try {
            (new \Src\Kernel\Support\EmailThrottle($this->pdo()))->record('verification', $email);
        } catch (\Throwable $e) {
            error_log('[UsuarioController] throttle record failed: ' . $e->getMessage());
        }
    }

    /**
     * Gera token de verificação e envia e-mail de confirmação.
     * Não envia se o e-mail já estiver verificado.
     * Não envia se o throttle de 120s ainda estiver ativo.
     */
    private function enviarEmailConfirmacaoRegistro(\Src\Modules\Usuario\Entities\Usuario $usuario): void
    {
        if ($this->emailSender === null) {
            return;
        }

        // Não envia se já verificado
        if ($usuario->isEmailVerificado()) {
            return;
        }

        // Throttle: evita disparos duplicados em menos de 120s
        if (!$this->podeReenviarVerificacao($usuario->getEmail())) {
            return;
        }

        try {
            $token = bin2hex(random_bytes(32));
            $this->service->salvarTokenVerificacaoEmail($usuario->getUuid()->toString(), $token);

            // Registra throttle antes de enviar para evitar race condition
            $this->registrarReenvioVerificacao($usuario->getEmail());

            $base = rtrim($_ENV['APP_URL_FRONTEND'] ?? $_ENV['APP_URL'] ?? '', '/');
            if ($base === '') {
                $scheme = $_SERVER['REQUEST_SCHEME'] ?? 'http';
                $host   = $_SERVER['HTTP_HOST'] ?? 'localhost';
                $base   = $scheme . '://' . $host;
            }
            // Link aponta para o frontend — o frontend chama a API com o token
            $link = $base . '/verificar-email?token=' . urlencode($token);

            $this->emailSender->sendConfirmation(
                $usuario->getEmail(),
                $usuario->getNomeCompleto(),
                $link,
                $_ENV['APP_LOGO_URL'] ?? null
            );
        } catch (\Throwable $e) {
            error_log('[UsuarioController] Falha ao enviar e-mail de confirmação: ' . $e->getMessage());
        }
    }

    // ── Admin: listagem e gerenciamento ───────────────────────────────────

    public function listar(Request $request): Response
    {
        try {
            $pagina    = Sanitizer::positiveInt($request->query['pagina'] ?? $request->query['page'] ?? 1, 1, 10000);
            $porPagina = Sanitizer::positiveInt($request->query['por_pagina'] ?? $request->query['per_page'] ?? 20, 1, 100);
            $busca     = Sanitizer::search($request->query['q'] ?? '');
            $nivel     = Sanitizer::nivelAcesso($request->query['nivel'] ?? '');

            $resultado = $this->service->listarComFiltro($pagina, $porPagina, $busca, $nivel);

            return Response::json([
                'status'        => 'success',
                'pagina'        => $pagina,
                'por_pagina'    => $porPagina,
                'total'         => $resultado['total'],
                'total_paginas' => $resultado['total_paginas'],
                'usuarios'      => array_map([$this, 'serializar'], $resultado['usuarios']),
            ]);
        } catch (\Throwable $e) {
            return Response::json(['status' => 'error', 'message' => 'Erro ao listar usuários.', 'details' => $this->debug($e)], 500);
        }
    }

    public function buscar(Request $request, string $uuid): Response
    {
        try {
            $usuario = $this->service->buscarPorUuid($uuid);
            if (!$usuario) {
                return Response::json(['status' => 'error', 'message' => 'Usuário não encontrado.'], 404);
            }
            return Response::json(['status' => 'success', 'usuario' => $this->serializar($usuario)]);
        } catch (\Throwable $e) {
            return Response::json(['status' => 'error', 'message' => 'Erro ao buscar usuário.', 'details' => $this->debug($e)], 500);
        }
    }

    public function atualizar(Request $request, string $uuid): Response
    {
        try {
            $uuid = Sanitizer::uuid($uuid);
            if ($uuid === '') {
                return Response::json(['status' => 'error', 'message' => 'UUID inválido.'], 422);
            }
            $body = $request->body ?? [];
            if (empty($body)) {
                return Response::json(['status' => 'error', 'message' => 'Nenhum dado enviado.'], 422);
            }
            $dados = $this->sanitizarCamposUsuario($body);
            if (isset($body['nivel_acesso'])) {
                // Impede que o usuário logado altere seu próprio nível de acesso
                $authUser = $request->attribute('auth_user');
                if ($authUser && $authUser->getUuid()->toString() === $uuid) {
                    return Response::json(['status' => 'error', 'message' => 'Você não pode alterar seu próprio nível de acesso.'], 403);
                }
                $nivelError = $this->sanitizarNivelAcesso($body['nivel_acesso'], $dados);
                if ($nivelError !== null) {
                    return $nivelError;
                }
            }
            if (empty($dados)) {
                return Response::json(['status' => 'error', 'message' => 'Nenhum campo válido enviado.'], 422);
            }
            $this->service->atualizar($uuid, $dados);
            $usuario = $this->service->buscarPorUuid($uuid);
            return Response::json([
                'status'  => 'success',
                'message' => 'Usuário atualizado.',
                'usuario' => $usuario ? $this->serializar($usuario) : null,
            ]);
        } catch (DomainException | ModuleDomainException $e) {
            $status = $e->getCode() >= 400 && $e->getCode() <= 599 ? $e->getCode() : 422;
            return Response::json(['status' => 'error', 'message' => $e->getMessage()], $status);
        } catch (\Throwable $e) {
            return Response::json(['status' => 'error', 'message' => 'Erro ao atualizar usuário.', 'details' => $this->debug($e)], 500);
        }
    }

    /** Sanitiza os campos comuns de usuário a partir do body da requisição. */
    private function sanitizarCamposUsuario(array $body): array
    {
        $map = [
            'nome_completo' => static fn($v) => Sanitizer::string($v, 150),
            'username'      => static fn($v) => Sanitizer::username($v),
            'email'         => static fn($v) => Sanitizer::email($v),
            'senha'         => static fn($v) => Sanitizer::password($v),
            'url_avatar'    => static fn($v) => Sanitizer::url($v),
            'url_capa'      => static fn($v) => Sanitizer::url($v),
            'biografia'     => static fn($v) => Sanitizer::text($v, 500),
        ];
        $dados = [];
        foreach ($map as $campo => $sanitize) {
            if (isset($body[$campo])) {
                $dados[$campo] = $sanitize($body[$campo]);
            }
        }
        return $dados;
    }

    /** Valida e adiciona nivel_acesso em $dados. Retorna Response de erro ou null se ok. */
    private function sanitizarNivelAcesso(mixed $valor, array &$dados): ?Response
    {
        $nivel = Sanitizer::nivelAcesso($valor);
        if ($nivel === '') {
            return Response::json(['status' => 'error', 'message' => 'Nível de acesso inválido.'], 422);
        }
        $dados['nivel_acesso'] = $nivel;
        return null;
    }

    public function deletar(Request $request, string $uuid): Response
    {
        try {
            $authUser = $request->attribute('auth_user');
            if ($authUser && $authUser->getUuid()->toString() === $uuid) {
                return Response::json(['status' => 'error', 'message' => 'Você não pode excluir sua própria conta por aqui.'], 403);
            }
            $this->service->deletar($uuid);
            return Response::json(['status' => 'success', 'message' => 'Usuário removido.']);
        } catch (DomainException | ModuleDomainException $e) {
            $status = $e->getCode() >= 400 && $e->getCode() <= 599 ? $e->getCode() : 422;
            return Response::json(['status' => 'error', 'message' => $e->getMessage()], $status);
        } catch (\Throwable $e) {
            return Response::json(['status' => 'error', 'message' => 'Erro ao remover usuário.', 'details' => $this->debug($e)], 500);
        }
    }

    public function desativar(Request $request, string $uuid): Response
    {
        try {
            $this->service->desativar($uuid);
            return Response::json(['status' => 'success', 'message' => 'Usuário desativado.']);
        } catch (DomainException | ModuleDomainException $e) {
            $status = $e->getCode() >= 400 && $e->getCode() <= 599 ? $e->getCode() : 422;
            return Response::json(['status' => 'error', 'message' => $e->getMessage()], $status);
        } catch (\Throwable $e) {
            return Response::json(['status' => 'error', 'message' => 'Erro ao desativar usuário.', 'details' => $this->debug($e)], 500);
        }
    }

    public function ativar(Request $request, string $uuid): Response
    {
        try {
            $this->service->ativar($uuid);
            return Response::json(['status' => 'success', 'message' => 'Usuário ativado.']);
        } catch (DomainException | ModuleDomainException $e) {
            $status = $e->getCode() >= 400 && $e->getCode() <= 599 ? $e->getCode() : 422;
            return Response::json(['status' => 'error', 'message' => $e->getMessage()], $status);
        } catch (\Throwable $e) {
            return Response::json(['status' => 'error', 'message' => 'Erro ao ativar usuário.', 'details' => $this->debug($e)], 500);
        }
    }

    // ── Perfil do usuário autenticado ─────────────────────────────────────

    public function perfil(Request $request): Response
    {
        try {
            $authUser = $request->attribute('auth_user');
            if (!$authUser) {
                return Response::json(['status' => 'error', 'message' => 'Não autenticado.'], 401);
            }
            return Response::json(['status' => 'success', 'usuario' => $this->serializar($authUser)]);
        } catch (\Throwable $e) {
            return Response::json(['status' => 'error', 'message' => 'Erro ao buscar perfil.', 'details' => $this->debug($e)], 500);
        }
    }

    public function atualizarPerfil(Request $request): Response
    {
        try {
            $authUser = $request->attribute('auth_user');
            if (!$authUser) {
                return Response::json(['status' => 'error', 'message' => 'Não autenticado.'], 401);
            }

            $body  = $request->body ?? [];
            $dados = [];
            if (isset($body['nome_completo'])) $dados['nome_completo'] = Sanitizer::string($body['nome_completo'], 150);
            if (isset($body['username']))       $dados['username']      = Sanitizer::username($body['username']);
            if (isset($body['url_avatar']))     $dados['url_avatar']    = Sanitizer::url($body['url_avatar']);
            if (isset($body['url_capa']))       $dados['url_capa']      = Sanitizer::url($body['url_capa']);
            if (isset($body['biografia']))      $dados['biografia']     = Sanitizer::text($body['biografia'], 500);

            if (empty($dados)) {
                return Response::json(['status' => 'error', 'message' => 'Nenhum campo válido enviado.'], 422);
            }

            $uuid = $authUser->getUuid()->toString();
            $this->service->atualizar($uuid, $dados);
            $usuario = $this->service->buscarPorUuid($uuid);

            return Response::json([
                'status'  => 'success',
                'message' => 'Perfil atualizado.',
                'usuario' => $usuario ? $this->serializar($usuario) : null,
            ]);
        } catch (DomainException | ModuleDomainException $e) {
            $status = $e->getCode() >= 400 && $e->getCode() <= 599 ? $e->getCode() : 422;
            return Response::json(['status' => 'error', 'message' => $e->getMessage()], $status);
        } catch (\Throwable $e) {
            return Response::json(['status' => 'error', 'message' => 'Erro ao atualizar perfil.', 'details' => $this->debug($e)], 500);
        }
    }

    public function alterarEmail(Request $request): Response
    {
        try {
            $authUser = $request->attribute('auth_user');
            if (!$authUser) {
                return Response::json(['status' => 'error', 'message' => 'Não autenticado.'], 401);
            }

            $body  = $request->body ?? [];
            $email = Sanitizer::email($body['email'] ?? '');
            $senha = Sanitizer::password($body['senha'] ?? $body['password'] ?? '');

            if ($email === '') {
                return Response::json(['status' => 'error', 'message' => 'E-mail inválido ou não informado.'], 422);
            }
            if ($senha === '' || !$authUser->verificarSenha($senha)) {
                return Response::json(['status' => 'error', 'message' => 'Senha incorreta.'], 403);
            }

            $uuid = $authUser->getUuid()->toString();
            $this->service->atualizar($uuid, ['email' => $email]);

            // Reseta verificação e envia novo e-mail de confirmação para o novo endereço
            $usuarioAtualizado = $this->service->buscarPorUuid($uuid);
            if ($usuarioAtualizado) {
                $this->service->resetarVerificacaoEmail($uuid);
                $usuarioAtualizado->setEmailVerificado(false);
                $this->enviarEmailConfirmacaoRegistro($usuarioAtualizado);
            }

            return Response::json(['status' => 'success', 'message' => 'E-mail atualizado. Verifique sua caixa de entrada para confirmar o novo endereço.']);
        } catch (DomainException | ModuleDomainException $e) {
            $status = $e->getCode() >= 400 && $e->getCode() <= 599 ? $e->getCode() : 422;
            return Response::json(['status' => 'error', 'message' => $e->getMessage()], $status);
        } catch (\Throwable $e) {
            return Response::json(['status' => 'error', 'message' => 'Erro ao alterar e-mail.', 'details' => $this->debug($e)], 500);
        }
    }

    public function alterarSenha(Request $request): Response
    {
        try {
            $authUser = $request->attribute('auth_user');
            if (!$authUser) {
                return Response::json(['status' => 'error', 'message' => 'Não autenticado.'], 401);
            }

            $body        = $request->body ?? [];
            $senhaAtual  = Sanitizer::password($body['senha_atual'] ?? $body['current_password'] ?? '');
            $novaSenha   = Sanitizer::password($body['nova_senha'] ?? $body['new_password'] ?? '');

            if ($senhaAtual === '' || $novaSenha === '') {
                return Response::json(['status' => 'error', 'message' => 'senha_atual e nova_senha são obrigatórios.'], 422);
            }
            if (!$authUser->verificarSenha($senhaAtual)) {
                return Response::json(['status' => 'error', 'message' => 'Senha atual incorreta.'], 403);
            }

            $this->service->alterarSenha($authUser->getUuid()->toString(), $novaSenha);
            return Response::json(['status' => 'success', 'message' => 'Senha alterada com sucesso.']);
        } catch (DomainException | ModuleDomainException $e) {
            $status = $e->getCode() >= 400 && $e->getCode() <= 599 ? $e->getCode() : 422;
            return Response::json(['status' => 'error', 'message' => $e->getMessage()], $status);
        } catch (\Throwable $e) {
            return Response::json(['status' => 'error', 'message' => 'Erro ao alterar senha.', 'details' => $this->debug($e)], 500);
        }
    }

    public function uploadProfileImage(Request $request): Response
    {
        try {
            $authUser = $request->attribute('auth_user');
            if (!$authUser) {
                return Response::json(['status' => 'error', 'message' => 'Não autenticado.'], 401);
            }

            $tipo  = $this->resolverTipoImagem($request->body['tipo'] ?? 'avatar');
            $file  = $_FILES['imagem'] ?? $_FILES['file'] ?? null;

            $validationError = $this->validarArquivoImagem($file);
            if ($validationError !== null) {
                return $validationError;
            }

            $mime = mime_content_type($file['tmp_name']) ?: '';
            $mimeError = $this->validarMimeImagem($mime);
            if ($mimeError !== null) {
                return $mimeError;
            }

            $uuid      = $authUser->getUuid()->toString();
            $filename  = $this->gerarNomeArquivo($uuid, $tipo, $mime);
            $uploadDir = dirname(__DIR__, 5) . '/public/uploads/perfil/';

            if (!is_dir($uploadDir) && !mkdir($uploadDir, 0755, true) && !is_dir($uploadDir)) {
                return Response::json(['status' => 'error', 'message' => 'Erro ao criar diretório de upload.'], 500);
            }

            [$maxW, $maxH] = $tipo === 'capa' ? [1200, 400] : [400, 400];
            if (!ImageProcessor::resizeAndSave($file['tmp_name'], $uploadDir . $filename, $mime, $maxW, $maxH)) {
                return Response::json(['status' => 'error', 'message' => 'Falha ao processar imagem.'], 500);
            }

            $url   = '/uploads/perfil/' . $filename;
            $campo = $tipo === 'capa' ? 'url_capa' : 'url_avatar';
            $this->service->atualizar($uuid, [$campo => $url]);

            return Response::json(['status' => 'success', 'url' => $url]);
        } catch (\Throwable $e) {
            return Response::json(['status' => 'error', 'message' => 'Erro no upload.', 'details' => $this->debug($e)], 500);
        }
    }

    private function resolverTipoImagem(mixed $tipo): string
    {
        $tipo = trim((string) $tipo);
        return in_array($tipo, ['avatar', 'capa'], true) ? $tipo : 'avatar';
    }

    private function validarArquivoImagem(?array $file): ?Response
    {
        if (!$file || ($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
            return Response::json(['status' => 'error', 'message' => 'Nenhuma imagem enviada ou erro no upload.'], 422);
        }
        if (($file['size'] ?? 0) > 5 * 1024 * 1024) {
            return Response::json(['status' => 'error', 'message' => 'Imagem muito grande. Máximo 5MB.'], 422);
        }
        // Validação real de conteúdo — mime_content_type é falsificável
        set_error_handler(static function (): bool { return true; });
        $imageInfo = getimagesize($file['tmp_name']);
        restore_error_handler();
        if ($imageInfo === false) {
            return Response::json(['status' => 'error', 'message' => 'Arquivo inválido: não é uma imagem.'], 422);
        }
        return null;
    }

    private function validarMimeImagem(string $mime): ?Response
    {
        $allowed = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'];
        if (!in_array($mime, $allowed, true)) {
            return Response::json(['status' => 'error', 'message' => 'Formato não suportado. Use JPEG, PNG ou WebP.'], 422);
        }
        return null;
    }

    private function gerarNomeArquivo(string $uuid, string $tipo, string $mime): string
    {
        $extMap = ['image/jpeg' => 'jpg', 'image/jpg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp'];
        $ext    = $extMap[$mime] ?? 'jpg';
        return $uuid . '_' . $tipo . '_' . time() . '.' . $ext;
    }

    public function deletarMinhaConta(Request $request): Response
    {
        try {
            $authUser = $request->attribute('auth_user');
            if (!$authUser) {
                return Response::json(['status' => 'error', 'message' => 'Não autenticado.'], 401);
            }

            $body = $request->body ?? [];
            $senha = (string) ($body['senha'] ?? $body['password'] ?? '');
            if ($senha === '' || !$authUser->verificarSenha($senha)) {
                return Response::json(['status' => 'error', 'message' => 'Senha incorreta.'], 403);
            }

            $this->service->deletar($authUser->getUuid()->toString());
            return Response::json(['status' => 'success', 'message' => 'Conta removida.']);
        } catch (DomainException | ModuleDomainException $e) {
            $status = $e->getCode() >= 400 && $e->getCode() <= 599 ? $e->getCode() : 422;
            return Response::json(['status' => 'error', 'message' => $e->getMessage()], $status);
        } catch (\Throwable $e) {
            return Response::json(['status' => 'error', 'message' => 'Erro ao remover conta.', 'details' => $this->debug($e)], 500);
        }
    }

    // ── Perfil público ────────────────────────────────────────────────────

    public function buscarPorUsername(Request $request, string $username): Response
    {
        try {
            $usuario = $this->service->buscarPorUsername($username);
            if (!$usuario || !$usuario->isAtivo()) {
                return Response::json(['status' => 'error', 'message' => 'Usuário não encontrado.'], 404);
            }
            return Response::json(['status' => 'success', 'usuario' => $this->serializarPublico($usuario)]);
        } catch (\Throwable $e) {
            return Response::json(['status' => 'error', 'message' => 'Erro ao buscar perfil.', 'details' => $this->debug($e)], 500);
        }
    }

    public function exibirPerfilHtml(Request $request, string $username): Response
    {
        try {
            $usuario = $this->service->buscarPorUsername($username);
            if (!$usuario || !$usuario->isAtivo()) {
                return Response::html('<h1>Usuário não encontrado</h1>', 404);
            }
            $nome       = htmlspecialchars($usuario->getNomeCompleto(), ENT_QUOTES, 'UTF-8');
            $usernameHtml = htmlspecialchars($usuario->getUsername(), ENT_QUOTES, 'UTF-8');
            $avatar     = htmlspecialchars($usuario->getUrlAvatar() ?? '', ENT_QUOTES, 'UTF-8');
            $bio        = htmlspecialchars($usuario->getBiografia() ?? '', ENT_QUOTES, 'UTF-8');
            $html = "<!doctype html><meta charset='utf-8'><title>{$nome}</title>"
                  . ($avatar ? "<img src='{$avatar}' alt='Avatar'>" : '')
                  . "<h1>{$nome}</h1><p>@{$usernameHtml}</p>"
                  . ($bio ? "<p>{$bio}</p>" : '');
            return Response::html($html);
        } catch (\Throwable $e) {
            return Response::html('<h1>Erro interno</h1>', 500);
        }
    }

    // ── Verificação de e-mail ─────────────────────────────────────────────

    public function enviarVerificacaoEmail(Request $request, string $uuid): Response
    {
        try {
            $usuario = $this->service->buscarPorUuid($uuid);
            if (!$usuario) {
                return Response::json(['status' => 'error', 'message' => 'Usuário não encontrado.'], 404);
            }
            if ($usuario->isEmailVerificado()) {
                return Response::json(['status' => 'success', 'message' => 'E-mail já verificado.']);
            }
            // Força envio ignorando throttle (ação administrativa explícita)
            $this->enviarEmailVerificacaoForcado($usuario);
            return Response::json(['status' => 'success', 'message' => 'E-mail de verificação enviado.']);
        } catch (\Throwable $e) {
            return Response::json(['status' => 'error', 'message' => 'Erro ao enviar e-mail.', 'details' => $this->debug($e)], 500);
        }
    }

    public function enviarVerificacaoEmailPorEmail(Request $request): Response
    {
        try {
            $body  = $request->body ?? [];
            $email = trim((string) ($body['email'] ?? ''));
            if ($email === '') {
                return Response::json(['status' => 'error', 'message' => 'E-mail é obrigatório.'], 422);
            }
            $usuario = $this->service->buscarPorEmail($email);
            if (!$usuario) {
                return Response::json(['status' => 'success', 'message' => 'Se o e-mail existir, o link será enviado.']);
            }
            if ($usuario->isEmailVerificado()) {
                return Response::json(['status' => 'success', 'message' => 'E-mail já verificado.']);
            }
            // Força envio ignorando throttle (ação administrativa explícita)
            $this->enviarEmailVerificacaoForcado($usuario);
            return Response::json(['status' => 'success', 'message' => 'E-mail de verificação enviado.']);
        } catch (\Throwable $e) {
            return Response::json(['status' => 'error', 'message' => 'Erro ao gerar token.', 'details' => $this->debug($e)], 500);
        }
    }

    public function verificarEmailStatus(Request $request): Response
    {
        try {
            $uuid = trim((string) ($request->query['uuid'] ?? ''));
            if ($uuid === '') {
                return Response::json(['status' => 'error', 'message' => 'UUID é obrigatório.'], 422);
            }
            $usuario = $this->service->buscarPorUuid($uuid);
            if (!$usuario) {
                return Response::json(['status' => 'error', 'message' => 'Usuário não encontrado.'], 404);
            }
            return Response::json(['status' => 'success', 'verificado' => $usuario->isEmailVerificado()]);
        } catch (\Throwable $e) {
            return Response::json(['status' => 'error', 'message' => 'Erro ao verificar status.', 'details' => $this->debug($e)], 500);
        }
    }

    public function verificarEmail(Request $request, string $token): Response
    {
        try {
            $token = trim($token);
            if ($token === '') {
                return Response::json(['status' => 'error', 'message' => 'Token inválido.'], 400);
            }
            $usuario = $this->service->buscarPorTokenVerificacaoEmail($token);
            if (!$usuario) {
                return Response::json(['status' => 'error', 'message' => 'Token inválido ou expirado.'], 400);
            }
            if ($usuario->isEmailVerificado()) {
                return Response::json(['status' => 'success', 'message' => 'E-mail já verificado.']);
            }
            $this->service->marcarEmailComoVerificado($usuario->getUuid()->toString());
            return Response::json(['status' => 'success', 'message' => 'E-mail verificado com sucesso.']);
        } catch (\Throwable $e) {
            return Response::json(['status' => 'error', 'message' => 'Erro ao verificar e-mail.', 'details' => $this->debug($e)], 500);
        }
    }

    // ── Helpers ───────────────────────────────────────────────────────────

    /**
     * Envia e-mail de verificação sem verificar throttle.
     * Usado por ações administrativas explícitas.
     */
    private function enviarEmailVerificacaoForcado(\Src\Modules\Usuario\Entities\Usuario $usuario): void
    {
        if ($this->emailSender === null) {
            return;
        }
        try {
            $token = bin2hex(random_bytes(32));
            $this->service->salvarTokenVerificacaoEmail($usuario->getUuid()->toString(), $token);
            $this->registrarReenvioVerificacao($usuario->getEmail());

            $base = rtrim($_ENV['APP_URL_FRONTEND'] ?? $_ENV['APP_URL'] ?? '', '/');
            if ($base === '') {
                $scheme = $_SERVER['REQUEST_SCHEME'] ?? 'http';
                $host   = $_SERVER['HTTP_HOST'] ?? 'localhost';
                $base   = $scheme . '://' . $host;
            }
            $link = $base . '/verificar-email?token=' . urlencode($token);

            $this->emailSender->sendConfirmation(
                $usuario->getEmail(),
                $usuario->getNomeCompleto(),
                $link,
                $_ENV['APP_LOGO_URL'] ?? null
            );
        } catch (\Throwable $e) {
            error_log('[UsuarioController] Falha ao enviar e-mail de verificação: ' . $e->getMessage());
        }
    }

    private function serializar(Usuario $u): array
    {
        return [
            'uuid'               => $u->getUuid()->toString(),
            'nome_completo'      => $u->getNomeCompleto(),
            'username'           => $u->getUsername(),
            'email'              => $u->getEmail(),
            'nivel_acesso'       => $u->getNivelAcesso(),
            'ativo'              => $u->isAtivo(),
            'verificado_email'   => $u->isEmailVerificado(),
            'url_avatar'         => $u->getUrlAvatar(),
            'url_capa'           => $u->getUrlCapa(),
            'biografia'          => $u->getBiografia(),
            'criado_em'          => $u->getCriadoEm()->format('Y-m-d\TH:i:sP'),
            'atualizado_em'      => $u->getAtualizadoEm()?->format('Y-m-d\TH:i:sP'),
        ];
    }

    private function serializarPublico(Usuario $u): array
    {
        return [
            'username'    => $u->getUsername(),
            'nome_completo' => $u->getNomeCompleto(),
            'url_avatar'  => $u->getUrlAvatar(),
            'url_capa'    => $u->getUrlCapa(),
            'biografia'   => $u->getBiografia(),
        ];
    }

    private function debug(\Throwable $e): ?string
    {
        return ($_ENV['APP_DEBUG'] ?? 'false') === 'true' ? $e->getMessage() : null;
    }
}

Passo 9 — Criar a Migration

A migration cria a tabela usuarios com suporte a PostgreSQL e MySQL. O campo nivel_acesso usa CHECK constraint no PostgreSQL. Retorne um array com as chaves up e down.

Arquivo: src/Modules/Usuario/Database/Migrations/001_create_usuarios.php

php
<?php
/**
 * Migration: Módulo Usuario — Tabela usuarios
 */
return [
    'up' => function (PDO $pdo): void {
        $driver = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
        if ($driver === 'pgsql') {
            $pdo->exec("
                CREATE TABLE IF NOT EXISTS usuarios (
                    uuid                    UUID         NOT NULL PRIMARY KEY,
                    nome_completo           VARCHAR(255) NOT NULL,
                    username                VARCHAR(50)  NOT NULL UNIQUE,
                    email                   VARCHAR(255) NOT NULL UNIQUE,
                    senha_hash              VARCHAR(255) NOT NULL,
                    url_avatar              VARCHAR(255),
                    url_capa                VARCHAR(255),
                    biografia               TEXT,
                    nivel_acesso            VARCHAR(20)  DEFAULT 'usuario'
                        CHECK (nivel_acesso IN ('usuario','admin','moderador','admin_system')),
                    token_recuperacao_senha VARCHAR(255),
                    token_verificacao_email VARCHAR(255),
                    ativo                   BOOLEAN      NOT NULL DEFAULT TRUE,
                    verificado_email        BOOLEAN      NOT NULL DEFAULT FALSE,
                    criado_em               TIMESTAMP    NOT NULL DEFAULT CURRENT_TIMESTAMP,
                    atualizado_em           TIMESTAMP,
                    status_verificacao      VARCHAR(30)  DEFAULT 'Não verificado'
                )
            ");
            $pdo->exec("CREATE INDEX IF NOT EXISTS idx_usuarios_email    ON usuarios (email)");
            $pdo->exec("CREATE INDEX IF NOT EXISTS idx_usuarios_username ON usuarios (username)");
            $pdo->exec("CREATE INDEX IF NOT EXISTS idx_usuarios_ativo    ON usuarios (ativo)");
        } else {
            $pdo->exec("
                CREATE TABLE IF NOT EXISTS usuarios (
                    uuid                    CHAR(36)     NOT NULL PRIMARY KEY,
                    nome_completo           VARCHAR(255) NOT NULL,
                    username                VARCHAR(50)  NOT NULL UNIQUE,
                    email                   VARCHAR(255) NOT NULL UNIQUE,
                    senha_hash              VARCHAR(255) NOT NULL,
                    url_avatar              VARCHAR(255),
                    url_capa                VARCHAR(255),
                    biografia               TEXT,
                    nivel_acesso            VARCHAR(20)  DEFAULT 'usuario',
                    token_recuperacao_senha VARCHAR(255),
                    token_verificacao_email VARCHAR(255),
                    ativo                   TINYINT(1)   NOT NULL DEFAULT 1,
                    verificado_email        TINYINT(1)   NOT NULL DEFAULT 0,
                    criado_em               DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
                    atualizado_em           DATETIME,
                    status_verificacao      VARCHAR(30)  DEFAULT 'Não verificado',
                    INDEX idx_email (email),
                    INDEX idx_username (username),
                    INDEX idx_ativo (ativo)
                ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
            ");
        }
    },
    'down' => function (PDO $pdo): void {
        $pdo->exec("DROP TABLE IF EXISTS usuarios");
    },
];

Passo 10 — Criar o Seeder

O Seeder cria o usuário admin_system inicial. As credenciais podem ser sobrescritas via variáveis de ambiente. O seeder verifica duplicidade antes de inserir.

Troque a senha em produção!

Defina ADMIN_EMAIL, ADMIN_PASSWORD, ADMIN_NAME e ADMIN_USERNAME no .env antes de rodar o seeder em produção.

Arquivo: src/Modules/Usuario/Database/Seeders/001_admin_user.php

php
<?php
/**
 * Seeder: Módulo Usuario — Usuário admin padrão
 *
 * Cria o usuário admin_system inicial se não existir.
 *
 * Credenciais padrão:
 *   E-mail: [email protected]
 *   Senha:  admin123
 *
 * Sobrescreva via variáveis de ambiente:
 *   ADMIN_EMAIL, ADMIN_PASSWORD, ADMIN_NAME, ADMIN_USERNAME
 *
 * ⚠ TROQUE A SENHA EM PRODUÇÃO!
 */
return function (PDO $pdo): void {
    $email    = $_ENV['ADMIN_EMAIL']    ?? '[email protected]';
    $senha    = $_ENV['ADMIN_PASSWORD'] ?? 'admin123';
    $nome     = $_ENV['ADMIN_NAME']     ?? 'Administrador';
    $username = $_ENV['ADMIN_USERNAME'] ?? 'admin';

    // Verifica se já existe pelo e-mail
    $stmt = $pdo->prepare("SELECT 1 FROM usuarios WHERE email = :email LIMIT 1");
    $stmt->execute([':email' => $email]);
    if ($stmt->fetchColumn()) {
        echo "  ⊘ Admin já existe: $email
";
        return;
    }

    // Verifica se o username já está em uso
    $stmt = $pdo->prepare("SELECT 1 FROM usuarios WHERE username = :username LIMIT 1");
    $stmt->execute([':username' => $username]);
    if ($stmt->fetchColumn()) {
        $username = 'admin_' . bin2hex(random_bytes(3));
    }

    $uuid = \Ramsey\Uuid\Uuid::uuid4()->toString();

    // Usa PASSWORD_BCRYPT para compatibilidade máxima
    // O seeder cria o hash diretamente — sem passar pela validação de complexidade
    // da entidade Usuario, pois é um usuário de bootstrap do sistema
    $hash = password_hash($senha, PASSWORD_BCRYPT, ['cost' => 12]);

    $driver = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);

    if ($driver === 'pgsql') {
        $stmt = $pdo->prepare("
            INSERT INTO usuarios
                (uuid, nome_completo, username, email, senha_hash,
                 nivel_acesso, ativo, verificado_email, status_verificacao, criado_em)
            VALUES
                (:uuid, :nome, :username, :email, :hash,
                 'admin_system', TRUE, TRUE, 'verificado', NOW())
        ");
    } else {
        $stmt = $pdo->prepare("
            INSERT INTO usuarios
                (uuid, nome_completo, username, email, senha_hash,
                 nivel_acesso, ativo, verificado_email, status_verificacao, criado_em)
            VALUES
                (:uuid, :nome, :username, :email, :hash,
                 'admin_system', 1, 1, 'verificado', NOW())
        ");
    }

    $stmt->execute([
        ':uuid'     => $uuid,
        ':nome'     => $nome,
        ':username' => $username,
        ':email'    => $email,
        ':hash'     => $hash,
    ]);

    echo "  ✔ Admin criado com sucesso!\n";
    echo "    E-mail:   $email\n";
    echo "    Username: $username\n";
    echo "    Senha:    $senha\n";
    echo "    Nível:    admin_system\n";
    echo "\n  ⚠  TROQUE A SENHA EM PRODUÇÃO!\n";
};

Passo 11 — Criar o connection.php

Este arquivo declara qual banco de dados o módulo usa. O valor core aponta para as variáveis DB_* do .env. Use modules para DB2_* ou auto para detecção automática.

Arquivo: src/Modules/Usuario/Database/connection.php

php
<?php
// Define qual banco de dados este módulo usa.
// Opções: 'core' (DB_*) | 'modules' (DB2_*) | 'auto'
return 'core';

Passo 12 — Definir as Rotas

O arquivo Routes/web.php é obrigatório para que o sistema registre as rotas HTTP do módulo. A variável \ é injetada automaticamente pelo Kernel.

AdminOnlyMiddleware — token especial

Rotas protegidas por AdminOnlyMiddleware (nível admin_system) exigem um token JWT assinado com JWT_API_SECRET. Use o endpoint POST /api/auth/login (não /api/login) para obter esse token.

Arquivo: src/Modules/Usuario/Routes/web.php

php
<?php

use Src\Kernel\Middlewares\AuthHybridMiddleware;
use Src\Kernel\Middlewares\AdminOnlyMiddleware;
use Src\Kernel\Middlewares\RateLimitMiddleware;
use Src\Kernel\Middlewares\CircuitBreakerMiddleware;
use Src\Modules\Usuario\Controllers\UsuarioController;

/** @var \Src\Kernel\Contracts\RouterInterface $router */

// Middlewares
$adminProtected = [AuthHybridMiddleware::class, AdminOnlyMiddleware::class];
$userProtected  = [AuthHybridMiddleware::class];

// Circuit breaker para rotas que dependem de DB
$dbCircuit = [CircuitBreakerMiddleware::class, ['service' => 'database', 'threshold' => 5, 'cooldown' => 20]];

// Rate limits
$registroRateLimit  = [RateLimitMiddleware::class, ['limit' => 5,  'window' => 60,  'key' => 'usuario.registro']];
$reenvioRateLimit   = [RateLimitMiddleware::class, ['limit' => 3,  'window' => 300, 'key' => 'usuario.reenvio']];
$perfilRateLimit    = [RateLimitMiddleware::class, ['limit' => 30, 'window' => 60,  'key' => 'usuario.perfil']];
$verificaRateLimit  = [RateLimitMiddleware::class, ['limit' => 10, 'window' => 60,  'key' => 'usuario.verifica']];

// Registro público de usuário
$router->post('/api/criar/usuario', [UsuarioController::class, 'criar'], [$registroRateLimit, $dbCircuit]);
$router->post('/api/registrar',     [UsuarioController::class, 'criar'], [$registroRateLimit, $dbCircuit]);

// Reenvio de e-mail de verificação (público, sem autenticação)
$router->post('/api/auth/reenviar-verificacao', [UsuarioController::class, 'reenviarVerificacaoEmail'], [$reenvioRateLimit]);

// Gerenciamento de usuários (admin)
$router->get('/api/usuarios', [UsuarioController::class, 'listar'], $adminProtected);
$router->get('/api/usuario/{uuid}', [UsuarioController::class, 'buscar'], $adminProtected);
$router->put('/api/usuario/atualizar/{uuid}', [UsuarioController::class, 'atualizar'], $adminProtected);
$router->delete('/api/usuario/deletar/{uuid}', [UsuarioController::class, 'deletar'], $adminProtected);
$router->patch('/api/usuario/{uuid}/desativar', [UsuarioController::class, 'desativar'], $adminProtected);
$router->patch('/api/usuario/{uuid}/ativar', [UsuarioController::class, 'ativar'], $adminProtected);

// Perfil do usuário autenticado
$router->get('/api/perfil', [UsuarioController::class, 'perfil'], $userProtected);
$router->put('/api/perfil', [UsuarioController::class, 'atualizarPerfil'], $userProtected);
$router->put('/api/perfil/email', [UsuarioController::class, 'alterarEmail'], $userProtected);
$router->put('/api/perfil/senha', [UsuarioController::class, 'alterarSenha'], $userProtected);
$router->post('/api/perfil/upload', [UsuarioController::class, 'uploadProfileImage'], $userProtected);
$router->delete('/api/perfil', [UsuarioController::class, 'deletarMinhaConta'], $userProtected);

// Perfil público
$router->get('/api/perfil/{username}', [UsuarioController::class, 'buscarPorUsername'], [$perfilRateLimit]);
$router->get('/perfil/{username}',     [UsuarioController::class, 'exibirPerfilHtml'],  [$perfilRateLimit]);

// Verificação de e-mail
$router->post('/api/usuarios/{uuid}/enviar-verificacao-email', [UsuarioController::class, 'enviarVerificacaoEmail'],        $adminProtected);
$router->post('/api/usuarios/enviar-verificacao-email',        [UsuarioController::class, 'enviarVerificacaoEmailPorEmail'], $adminProtected);
$router->get('/api/usuarios/verificar-email-status',           [UsuarioController::class, 'verificarEmailStatus'],          $adminProtected);
$router->post('/api/usuarios/verificar-email/{token}',         [UsuarioController::class, 'verificarEmail'],                [$verificaRateLimit]);

Resumo dos Endpoints

MétodoRotaAcessoDescrição
POST/api/criar/usuarioPúblicoRegistro de usuário
POST/api/registrarPúblicoAlias de registro
POST/api/auth/reenviar-verificacaoPúblicoReenviar e-mail de verificação
GET/api/usuariosAdminListar usuários com filtros e paginação
GET/api/usuario/{uuid}AdminBuscar usuário por UUID
PUT/api/usuario/atualizar/{uuid}AdminAtualizar dados de usuário
DELETE/api/usuario/deletar/{uuid}AdminRemover usuário
PATCH/api/usuario/{uuid}/desativarAdminDesativar usuário
PATCH/api/usuario/{uuid}/ativarAdminAtivar usuário
GET/api/perfilAutenticadoVer próprio perfil
PUT/api/perfilAutenticadoAtualizar próprio perfil
PUT/api/perfil/emailAutenticadoAlterar e-mail
PUT/api/perfil/senhaAutenticadoAlterar senha
POST/api/perfil/uploadAutenticadoUpload de avatar ou capa
DELETE/api/perfilAutenticadoDeletar própria conta
GET/api/perfil/{username}PúblicoPerfil público JSON
GET/perfil/{username}PúblicoPerfil público HTML
POST/api/usuarios/{uuid}/enviar-verificacao-emailAdminEnviar verificação por UUID
POST/api/usuarios/enviar-verificacao-emailAdminEnviar verificação por e-mail
GET/api/usuarios/verificar-email-statusAdminVerificar status de e-mail
POST/api/usuarios/verificar-email/{token}PúblicoConfirmar e-mail via token
Dependência do Kernel: tabela email_throttle

O Controller usa EmailThrottle para evitar disparos duplicados de e-mail. Essa classe depende da tabela email_throttle, que é criada automaticamente pela migration do Kernel (002_email_tables.sql) quando você roda php vupi migrate. Você não precisa criar essa tabela manualmente — ela já faz parte do core do sistema.

Pronto!

Com todos os arquivos criados, rode as migrations e o seeder para inicializar o banco:

bash
php vupi migrate --seed

Depois teste o registro com:

bash
curl -X POST http://localhost/api/registrar \
  -H "Content-Type: application/json" \
  -d '{"nome_completo":"Teste","username":"teste","email":"[email protected]","senha":"Senha@123"}'

Rotas e Middlewares

Como definir endpoints e protegê-los.

Definindo rotas

O arquivo Routes/web.php do módulo é carregado automaticamente. A variável $router já está disponível.

php
// Métodos disponíveis
$router->get('/api/recurso',          [Controller::class, 'listar']);
$router->post('/api/recurso',         [Controller::class, 'criar']);
$router->put('/api/recurso/{uuid}',   [Controller::class, 'atualizar']);
$router->patch('/api/recurso/{uuid}', [Controller::class, 'parcial']);
$router->delete('/api/recurso/{uuid}',[Controller::class, 'deletar']);

Parâmetros de rota

php
// Definição
$router->get('/api/usuario/{uuid}', [UsuarioController::class, 'buscar']);

// No Controller
public function buscar(Request $request): Response
{
    $uuid = $request->param('uuid'); // captura {uuid}
    // ...
}

Middlewares disponíveis

MiddlewareQuando usar
AuthHybridMiddlewareQualquer rota que exige usuário logado.
AdminOnlyMiddlewareRotas exclusivas para admin_system.
RateLimitMiddlewareLimitar requisições por IP/usuário.
CircuitBreakerMiddlewareRotas que dependem de banco ou serviços externos.
ApiTokenMiddlewareRotas acessadas por tokens de API (integrações).

Configurando Rate Limit

php
$loginLimit = [RateLimitMiddleware::class, [
    'limit'      => 10,   // max requisições
    'window'     => 60,   // janela em segundos
    'key'        => 'auth.login',  // chave única para este limite
    'user_limit' => 5,    // limite por usuário autenticado (opcional)
]];

$router->post('/api/login', [AuthController::class, 'login'], [$loginLimit]);

Rotas nativas do sistema

Os módulos Auth e Usuario já vêm com as seguintes rotas registradas:

Módulo Auth

MétodoRotaAcessoDescrição
POST/api/auth/loginPúblicaLogin. Rate limit: 10/min.
POST/api/loginPúblicaAlias de login.
POST/api/auth/refreshPúblicaRenova access token via refresh token. Rate limit: 20/min.
GET/api/auth/meAuthDados do usuário autenticado.
POST/api/auth/logoutAuthLogout — revoga tokens.
POST/api/auth/recuperacao-senhaPúblicaSolicita reset de senha. Rate limit: 5/min.
POST/api/auth/resetar-senhaPúblicaRedefine senha com token.
GET/api/recuperar-senha/validar/{token}PúblicaValida token de recuperação.
GET/api/auth/verify-emailPúblicaVerifica e-mail via token.
GET/api/auth/email-verificationAuthPolítica de verificação de e-mail.

Módulo Usuario

MétodoRotaAcessoDescrição
POST/api/registrarPúblicaRegistro de novo usuário. Rate limit: 5/min.
GET/api/perfilAuthDados do perfil autenticado.
PUT/api/perfilAuthAtualiza perfil.
PUT/api/perfil/emailAuthAltera e-mail.
PUT/api/perfil/senhaAuthAltera senha.
POST/api/perfil/uploadAuthUpload de avatar/capa.
DELETE/api/perfilAuthDeleta a própria conta.
GET/api/perfil/{username}PúblicaPerfil público. Rate limit: 30/min.
GET/api/usuariosAdminLista todos os usuários.
GET/api/usuario/{uuid}AdminBusca usuário por UUID.
PUT/api/usuario/atualizar/{uuid}AdminAtualiza usuário.
DELETE/api/usuario/deletar/{uuid}AdminRemove usuário.
PATCH/api/usuario/{uuid}/ativarAdminAtiva usuário.
PATCH/api/usuario/{uuid}/desativarAdminDesativa usuário.

Banco de Dados

Migrations, seeders e conexões — com exemplos reais do módulo Usuario.

Migrations

Uma migration é um arquivo PHP em Database/Migrations/ que retorna um array com duas chaves: up (aplica a mudança) e down (reverte). O nome do arquivo define a ordem de execução — use prefixo numérico.

Migrations são append-only

Nunca edite uma migration já executada. O sistema guarda um hash de cada arquivo e lança erro se detectar alteração. Para mudar o schema, crie uma nova migration.

Criando uma tabela (exemplo: usuarios)

Arquivo: src/Modules/Usuario/Database/Migrations/001_create_usuarios.php

php
<?php
return [
    'up' => function (PDO $pdo): void {
        $driver = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);

        if ($driver === 'pgsql') {
            $pdo->exec("
                CREATE TABLE IF NOT EXISTS usuarios (
                    uuid             UUID         NOT NULL PRIMARY KEY,
                    nome_completo    VARCHAR(255) NOT NULL,
                    username         VARCHAR(50)  NOT NULL UNIQUE,
                    email            VARCHAR(255) NOT NULL UNIQUE,
                    senha_hash       VARCHAR(255) NOT NULL,
                    nivel_acesso     VARCHAR(20)  DEFAULT 'usuario'
                        CHECK (nivel_acesso IN ('usuario','admin','moderador','admin_system')),
                    ativo            BOOLEAN      NOT NULL DEFAULT TRUE,
                    verificado_email BOOLEAN      NOT NULL DEFAULT FALSE,
                    criado_em        TIMESTAMP    NOT NULL DEFAULT CURRENT_TIMESTAMP,
                    atualizado_em    TIMESTAMP
                )
            ");
            $pdo->exec("CREATE INDEX IF NOT EXISTS idx_usuarios_email    ON usuarios (email)");
            $pdo->exec("CREATE INDEX IF NOT EXISTS idx_usuarios_username ON usuarios (username)");
        } else {
            $pdo->exec("
                CREATE TABLE IF NOT EXISTS usuarios (
                    uuid             CHAR(36)     NOT NULL PRIMARY KEY,
                    nome_completo    VARCHAR(255) NOT NULL,
                    username         VARCHAR(50)  NOT NULL UNIQUE,
                    email            VARCHAR(255) NOT NULL UNIQUE,
                    senha_hash       VARCHAR(255) NOT NULL,
                    nivel_acesso     VARCHAR(20)  DEFAULT 'usuario',
                    ativo            TINYINT(1)   NOT NULL DEFAULT 1,
                    verificado_email TINYINT(1)   NOT NULL DEFAULT 0,
                    criado_em        DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
                    atualizado_em    DATETIME,
                    INDEX idx_email (email),
                    INDEX idx_username (username)
                ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
            ");
        }
    },
    'down' => function (PDO $pdo): void {
        $pdo->exec("DROP TABLE IF EXISTS usuarios");
    },
];

Alterando uma tabela (nova migration)

Para adicionar ou modificar colunas, crie um novo arquivo — nunca edite o anterior.

Arquivo: src/Modules/Usuario/Database/Migrations/002_add_biografia_to_usuarios.php

php
<?php
return [
    'up' => function (PDO $pdo): void {
        $driver = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);

        if ($driver === 'pgsql') {
            $pdo->exec("ALTER TABLE usuarios ADD COLUMN IF NOT EXISTS biografia TEXT");
            $pdo->exec("ALTER TABLE usuarios ADD COLUMN IF NOT EXISTS url_avatar VARCHAR(255)");
        } else {
            // MySQL não tem ADD COLUMN IF NOT EXISTS — verifica antes
            $cols = $pdo->query("SHOW COLUMNS FROM usuarios LIKE 'biografia'")->fetchAll();
            if (empty($cols)) {
                $pdo->exec("ALTER TABLE usuarios ADD COLUMN biografia TEXT");
                $pdo->exec("ALTER TABLE usuarios ADD COLUMN url_avatar VARCHAR(255)");
            }
        }
    },
    'down' => function (PDO $pdo): void {
        $pdo->exec("ALTER TABLE usuarios DROP COLUMN IF EXISTS biografia");
        $pdo->exec("ALTER TABLE usuarios DROP COLUMN IF EXISTS url_avatar");
    },
];

Comandos de migration

bash
php vupi migrate              # executa todas as migrations pendentes
php vupi migrate --seed       # migrations + seeders
php vupi migrate --rollback   # desfaz a última migration
php vupi migrate --status     # lista status de cada migration (done/pending)
php vupi migrate --core       # apenas conexão core (DB_*)
php vupi migrate --modules    # apenas conexão modules (DB2_*)

# Alias equivalente
php db migrate
php db seed
php db rollback
Como o sistema rastreia migrations

O Migrator cria automaticamente uma tabela migrations no banco e guarda o nome e hash de cada arquivo executado. Se você alterar um arquivo já executado, o sistema detecta a mudança e lança erro antes de continuar.

Seeders

Um seeder é um arquivo PHP em Database/Seeders/ que retorna uma callable (função anônima). Ele recebe o PDO e insere dados iniciais. Seeders também são rastreados — cada arquivo roda apenas uma vez.

Seeders devem ser idempotentes

Use ON CONFLICT DO NOTHING (PostgreSQL) ou ON DUPLICATE KEY UPDATE / verificação prévia (MySQL) para que o seeder não falhe se rodar mais de uma vez.

Criando um seeder (exemplo: admin inicial)

Arquivo: src/Modules/Usuario/Database/Seeders/001_admin_user.php

php
<?php
/**
 * Seeder: cria o usuário admin_system inicial.
 * Credenciais configuráveis via .env:
 *   ADMIN_EMAIL, ADMIN_PASSWORD, ADMIN_NAME, ADMIN_USERNAME
 */
return function (PDO $pdo): void {
    $email    = $_ENV['ADMIN_EMAIL']    ?? '[email protected]';
    $senha    = $_ENV['ADMIN_PASSWORD'] ?? 'admin123';
    $nome     = $_ENV['ADMIN_NAME']     ?? 'Administrador';
    $username = $_ENV['ADMIN_USERNAME'] ?? 'admin';

    // Verifica se já existe — idempotência
    $stmt = $pdo->prepare("SELECT 1 FROM usuarios WHERE email = :email LIMIT 1");
    $stmt->execute([':email' => $email]);
    if ($stmt->fetchColumn()) {
        echo "  ⊘ Admin já existe: {$email}\n";
        return;
    }

    // Verifica conflito de username
    $stmt = $pdo->prepare("SELECT 1 FROM usuarios WHERE username = :username LIMIT 1");
    $stmt->execute([':username' => $username]);
    if ($stmt->fetchColumn()) {
        $username = 'admin_' . bin2hex(random_bytes(3));
    }

    $uuid   = \Ramsey\Uuid\Uuid::uuid4()->toString();
    $hash   = password_hash($senha, PASSWORD_BCRYPT, ['cost' => 12]);
    $driver = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);

    if ($driver === 'pgsql') {
        $stmt = $pdo->prepare("
            INSERT INTO usuarios (uuid, nome_completo, username, email, senha_hash,
                                  nivel_acesso, ativo, verificado_email, criado_em)
            VALUES (:uuid, :nome, :username, :email, :hash,
                    'admin_system', TRUE, TRUE, NOW())
        ");
    } else {
        $stmt = $pdo->prepare("
            INSERT INTO usuarios (uuid, nome_completo, username, email, senha_hash,
                                  nivel_acesso, ativo, verificado_email, criado_em)
            VALUES (:uuid, :nome, :username, :email, :hash,
                    'admin_system', 1, 1, NOW())
        ");
    }

    $stmt->execute([
        ':uuid'     => $uuid,
        ':nome'     => $nome,
        ':username' => $username,
        ':email'    => $email,
        ':hash'     => $hash,
    ]);

    echo "  ✔ Admin criado: {$email} / {$username}\n";
    echo "  ⚠  TROQUE A SENHA EM PRODUÇÃO!\n";
};

Executando seeders

bash
php vupi migrate --seed   # migrations + seeders juntos (recomendado)
php db seed                  # apenas seeders

Dois bancos simultâneos

O sistema suporta dois bancos: core (DB_*) para módulos nativos e modules (DB2_*) para módulos externos. Declare qual usar no connection.php do módulo:

php
<?php
// src/Modules/MeuModulo/Database/connection.php
// Opções: 'core' (DB_*) | 'modules' (DB2_*) | 'auto'
return 'core';
ValorUsaQuando usar
'core'DB_*Módulos nativos em src/Modules/ (padrão)
'modules'DB2_*Módulos externos / plugins que precisam de banco separado
'auto'Detecta automaticamenteMódulos em vendor/ usam modules; os demais usam core

Sempre use Prepared Statements

Nunca concatene SQL

Concatenar variáveis em SQL abre brecha para SQL Injection. Sempre use prepare() + bindValue().

php
// ERRADO — vulnerável a SQL Injection
$pdo->query("SELECT * FROM usuarios WHERE email = '$email'");

// CORRETO — sempre assim
$stmt = $pdo->prepare("SELECT * FROM usuarios WHERE email = :email");
$stmt->bindValue(':email', $email);
$stmt->execute();
$row = $stmt->fetch(PDO::FETCH_ASSOC);

Comunicação Entre Módulos

Como módulos conversam sem acoplamento.

Dependência obrigatória

Use quando seu módulo não funciona sem o outro. Se o módulo dependente for removido, o sistema para com erro claro.

php
use Src\Modules\Usuario\Services\UsuarioService;

class FaturaService
{
    public function __construct(
        private UsuarioService $usuarioService // obrigatorio
    ) {}

    public function emitir(string $userUuid): void
    {
        $usuario = $this->usuarioService->buscarPorUuid($userUuid);
        // ...
    }
}

Dependência opcional

Use quando seu módulo pode funcionar sem o outro. O Container injeta null se o módulo não existir.

php
use Src\Kernel\Contracts\EmailSenderInterface;

class FaturaService
{
    public function __construct(
        private ?EmailSenderInterface $email = null // opcional
    ) {}

    public function processar(): void
    {
        $this->salvarNoBanco();

        // Operador nullsafe: se $email for null, linha ignorada sem erro
        $this->email?->sendCustom('[email protected]', 'Fatura', '<p>Gerada!</p>');
    }
}
Regra de ouro

Nunca acesse a tabela de outro módulo diretamente via SQL. Sempre use o Service do outro módulo para obter dados.

Segurança

O sistema foi construído com segurança em cada camada.

Autenticação JWT

O sistema usa dois secrets JWT distintos:

SecretUsado para
JWT_SECRETTokens de usuários comuns.
JWT_API_SECRETTokens de admin_system e tokens de API.

Todo token é validado com: assinatura, expiração, iss, aud, jti (blacklist) e UUID do usuário.

Rate Limiting

Dupla camada: por IP e por usuário autenticado. Usa Redis (distribuído) ou File (servidor único). Configurável por rota.

ThreatScorer

Acumula pontos de comportamento suspeito por IP:

EventoPontos
Acesso a honeypot+100
User-Agent malicioso+50
Falha de login+30
Rate limit excedido+20
Sem User-Agent+15

Score ≥ 50: delay progressivo. Score ≥ 150: bloqueio (403). TTL: 1 hora.

Boas práticas

  • Sempre use APP_DEBUG=false em produção.
  • Nunca concatene variáveis em SQL — use prepared statements.
  • Nunca acesse tabelas de outros módulos diretamente.
  • Valide todos os inputs no Controller ou Service.
  • Use OwnershipGuard para verificar posse de recursos.
  • Gere secrets JWT com openssl rand -hex 32.
  • Configure COOKIE_SECURE=true e COOKIE_HTTPONLY=true em produção.

Comandos CLI

Referência completa de todos os comandos disponíveis via php vupi e php db.

Setup

bash
php vupi setup                    # menu interativo (23 opções)
php vupi setup --help             # lista todas as flags
php vupi setup --auto             # modo automático sem interação

Flags do modo --auto

FlagValoresPadrãoDescrição
--db-modecompose, docker, skipcomposeComo subir o banco: docker-compose, container avulso ou pular.
--serverbackground, php, pm2, pm2+caddybackgroundComo subir o servidor PHP.
--jwtif-empty, skipif-emptyGera JWT_SECRET e JWT_API_SECRET se estiverem vazios.
--api-tokengenerate, skipskipGera token JWT de API após o setup.
--caddyproduction, dev, skipskipInicia Caddy: produção (Let's Encrypt), dev (mkcert) ou pula.
bash
# Exemplos de uso
php vupi setup --auto --db-mode=docker --server=php --jwt=if-empty
php vupi setup --auto --server=pm2+caddy --caddy=production
php vupi setup --auto --db-mode=skip --caddy=dev
php vupi setup --auto --jwt=if-empty --api-token=generate

Migrations e banco

bash
php vupi migrate              # executa migrations pendentes (core + módulos)
php vupi migrate --seed       # migrations + seeders
php vupi migrate --rollback   # desfaz a última migration
php vupi migrate --core       # apenas conexão core (DB_*)
php vupi migrate --modules    # apenas conexão modules (DB2_*)
php vupi migrate --status     # status de todas as migrations
php vupi migrate --status --json  # status em formato JSON

# Alias: php db faz a mesma coisa
php db migrate
php db seed
php db rollback

Geração de código

bash
php vupi make:module NomeDoModulo
php vupi make:plugin NomeDoPlugin
php vupi make:plugin NomeDoPlugin --capability=email-sender --description="Envio de e-mails"

Plugins

bash
php vupi plugin:install    nome-do-plugin   # instala
php vupi plugin:enable     nome-do-plugin   # ativa
php vupi plugin:disable    nome-do-plugin   # desativa
php vupi plugin:uninstall  nome-do-plugin   # remove
php vupi plugin:migrate                     # migrations de todos os plugins
php vupi plugin:rollback   nome-do-plugin   # reverte migrations do plugin
php vupi plugin:validate                    # valida estrutura dos plugins
php vupi plugin:inspect                     # lista plugins instalados

Capacidades

bash
php vupi capability:list                    # lista todas as capacidades
php vupi capability:list email-sender       # detalha uma capacidade específica
php vupi plugin:provider:set email-sender nome-do-plugin  # define o provider

Makefile

Atalhos para tarefas comuns. Execute make help para ver todos.

bash
# Docker
make up-pg          # sobe PostgreSQL + Adminer
make up-mysql       # sobe MySQL + Adminer
make up-all         # sobe tudo
make down           # para containers
make restart        # reinicia containers
make logs           # logs em tempo real
make ps             # lista containers
make shell-pg       # abre psql no PostgreSQL
make shell-mysql    # abre mysql no MySQL

# Projeto
make install        # composer install
make migrate        # php vupi migrate
make seed           # php vupi migrate --seed
make test           # testes de segurança
make analyse        # PHPStan nível 6
make setup          # setup completo (banco + deps + migrations)

# Caddy (proxy reverso / HTTPS)
make caddy-install  # instala Caddy no Ubuntu/Debian
make caddy-start    # inicia Caddy em produção (HTTPS Let's Encrypt)
make caddy-stop     # para Caddy
make caddy-reload   # recarrega config sem downtime
make caddy-dev      # HTTPS local com mkcert (desenvolvimento)

# Limpeza
make clean          # remove cache e arquivos temporários
make reset          # para containers e apaga volumes (CUIDADO: apaga dados!)

Servidor

bash
php -S localhost:3005 index.php                       # desenvolvimento
php vupi setup --auto --server=pm2+caddy           # produção: PM2 + Caddy
php vupi setup --auto --server=pm2                 # produção: apenas PM2

Testes

bash
php vendor/bin/phpunit                                    # todos os testes unitários
php tests/SecurityTest.php http://localhost:3005          # OWASP API Top 10
php tests/FuzzTest.php http://localhost:3005              # fuzzing de endpoints
php tests/LoadAttackTest.php http://localhost:3005        # carga + ataque combinado
php tests/PerformanceTest.php http://localhost:3005       # testes de performance
ADMIN_PASSWORD=senha php tests/EnvConfigTest.php          # testes de configuração do .env

Sobre o Desenvolvedor

Informações sobre o criador da Vupi.us API.

Desenvolvedor

Adimael S.

GitHub pessoal

github.com/adimael

Repositório do projeto

github.com/vupi.us

API em produção

api.vupi.us