PHP MVC Base

Boilerplate PHP puro com arquitetura MVC profissional. 14 classes Core reutilizáveis, CLI completo, autenticação pronta e tudo que você precisa para iniciar qualquer projeto — sem frameworks, sem magia.

5 comandos para rodar
Do zero ao servidor local em menos de 2 minutos. Autenticação completa incluída.
🔧
Core reutilizável
14 classes genéricas prontas. Basta estender e implementar o seu negócio.
🛡
Seguro por padrão
CSRF, XSS, SQL Injection — protegidos sem configuração adicional.
📐
Arquitetura limpa
Controller → Service → Repository → Model. Cada camada no lugar certo.
💻
CLI completo
php mvc make:controller, migrations, serve e mais 10 comandos.
📦
PSR-4 + Composer
Autoload profissional, namespaces organizados, pronto para pacotes.

Classes incluídas no Core

Classe Responsabilidade Tipo
Core\Application Kernel: boot, dispatch, error handling centralizado Core
Core\Router Roteamento com grupos, parâmetros dinâmicos, rotas nomeadas, resource() Core
Core\Controller Base com helpers: view, json, redirect, validate, abort Core
Core\Model Active Record + query builder fluente + paginação + soft delete Core
Core\Database Singleton PDO com prepared statements, bind fluente e transações Core
Core\Session Sessões seguras, flash messages, CSRF, old input Core
Core\Auth Autenticação genérica: attempt, loginUser, check, is, requireRole Core
Core\Validator 20+ regras de validação com mensagens em português Core
Core\Upload Upload seguro com validação de MIME real (finfo), nomes aleatórios Core
Core\Logger Logs diários em arquivo com contexto JSON, 5 níveis Core
Core\Request Encapsula requisição HTTP com sanitização automática, suporte JSON Core
Core\View Renderizador de templates PHP com suporte a layouts e capture Core
Core\Repository Base de acesso a dados desacoplado com CRUD + paginate Core
Core\Service Base de lógica de negócio com helper de transação Core

Instalação

Do zero ao servidor local em menos de 2 minutos.

Instale as dependências

PHP 8.1+, Composer e MySQL (ou MariaDB) devem estar instalados.

Configure o ambiente

Copie .env.example para .env e preencha as credenciais do banco de dados.

Gere a chave da aplicação

Gera e salva automaticamente APP_KEY no .env.

Execute as migrations

Cria as tabelas users e password_resets no banco.

Popule o banco (opcional)

Cria usuários iniciais para teste.

Inicie o servidor

Acesse http://localhost:8000

Terminal
# 1. Instalar dependências
composer install

# 2. Configurar ambiente
cp .env.example .env
# Edite .env com suas credenciais de banco e URL

# 3. Gerar chave da aplicação
php mvc key:generate

# 4. Criar tabelas no banco
php mvc migrate

# 5. Dados iniciais (opcional)
php database/seeds/UserSeeder.php

# 6. Servidor local
php mvc serve
# → http://localhost:8000

# Porta personalizada
php mvc serve --port=8080

Requisitos

Requisito Versão mínima Extensões necessárias
PHP 8.1+ pdo_mysql, mbstring, openssl, fileinfo
MySQL / MariaDB 5.7+ / 10.3+
Composer 2.x
Apache / Nginx Qualquer mod_rewrite (Apache)
💡
Nginx

Adicione try_files $uri $uri/ /index.php?$query_string; no bloco location /. O arquivo public/.htaccess já configura o rewriting para Apache automaticamente.

Estrutura de Pastas

Cada pasta tem uma responsabilidade única e bem definida.

🌐 public/ Única pasta exposta ao servidor web WEB ROOT
├──index.phpEntry point único — toda requisição passa aqui
├──.htaccessURL rewriting + headers de segurança (Apache)
└──assets/CSS, JS e imagens estáticas (app.css, app.js)
⚙️ app/Core/ Framework reutilizável — não edite CORE
├──Application.phpKernel: boot, dispatch, error handling, security headers
├──Router.phpRoteamento: grupos, parâmetros, rotas nomeadas, resource()
├──Controller.phpBase: view(), json(), redirect(), validate(), abort()
├──Model.phpActive Record + query builder fluente + paginação + soft delete
├──Database.phpSingleton PDO, bind fluente, transações com closure
├──Session.phpSessões seguras, flash messages, CSRF, old input
├──Auth.phpattempt(), loginUser(), check(), is(), requireRole(), logout()
├──Validator.php20+ regras, mensagens PT-BR, unique/exists no banco
├──Upload.phpUpload seguro: validação MIME real, presets, nomes aleatórios
├──Logger.php5 níveis, logs diários, contexto JSON
├──Request.phpHTTP encapsulado: get/post/json/file, sanitização, isAjax()
├──View.phprender(), capture() — templates PHP com layouts
├──Repository.phpBase CRUD + paginate; controllers não acessam Model diretamente
├──Service.phpBase de negócio: transaction(), fail()
└──Interfaces/RepositoryInterface.php
🏗 app/ Código da sua aplicação APP
├──Controllers/AuthController, DashboardController, HomeController
├──Models/User.php, PasswordReset.php
├──Services/AuthService.php — lógica de negócio, sem HTTP
├──Repositories/UserRepository.php — queries encapsuladas e reutilizáveis
├──Requests/FormRequests — validação, sanitização, autorização
├──FormRequest.phpClasse base abstrata
├──Auth/Login, Register, ForgotPassword, ResetPassword
└──Users/StoreUser, UpdateUser
├──Middlewares/Auth, Guest, Csrf, Role
├──Helpers/functions.php, SecurityHelper.php, Mailer.php
└──Views/Templates PHP por módulo
├──layouts/app.php (padrão), auth.php
├──components/alerts, sidebar, topbar, pagination, footer
├──auth/login, register, forgot, reset
└──errors/404, generic, debug (rich debug em dev)
🔧 bootstrap/   config/   routes/ Inicialização e configuração CONFIG
├──bootstrap/app.phpBoot: carrega .env, config, sessão, Application
├──config/app.phpConstantes globais, timezone, debug mode, APP_URL
├──config/database.phpCredenciais de banco — lê do .env
├──config/mail.phpDriver smtp | log | dev — lê do .env
└──routes/web.phpTodas as rotas da aplicação registradas aqui
🗄 database/ Migrations, seeds e runner DATABASE
├──migrate.phpRunner legado — use php mvc migrate de preferência
├──migrations/Arquivos SQL numerados: 001_create_base_tables.sql …
└──seeds/UserSeeder.php — scripts PHP para dados iniciais
📦 storage/ Arquivos gerados em runtime — não versionar conteúdo RUNTIME
├──logs/app-YYYY-MM-DD.log — rotação diária automática
├──uploads/Arquivos enviados pelos usuários (nomes aleatórios)
├──sessions/Sessões PHP armazenadas fora do public/ (seguro)
└──cache/Reservado para expansão futura
💻 cli/   mvc CLI do framework — inspirado no Artisan CLI
├──mvcExecutável: php mvc make:controller UserController
├──Kernel.phpParse de args, registro e dispatch de comandos
├──Command.phpClasse base: args, options, generateFile()
├──Commands/11 comandos: make:*, migrate, serve, key:generate
└──Stubs/Templates PHP/SQL para geração de código

Configuração (.env)

Todas as configurações sensíveis ficam no arquivo .env. Nunca commite credenciais no repositório.

.env.example
# ── Aplicação ───────────────────────────────────────────────
APP_NAME="PHP MVC Base"
APP_ENV=development          # development | production
APP_DEBUG=true
APP_KEY=                     # gerado por: php mvc key:generate
APP_URL=http://localhost:8000

# ── Banco de dados ───────────────────────────────────────────
DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=meu_banco
DB_USER=root
DB_PASS=

# ── Sessão ───────────────────────────────────────────────────
SESSION_SECURE=false         # true em HTTPS
APP_TIMEZONE=America/Sao_Paulo

# ── E-mail ───────────────────────────────────────────────────
MAIL_DRIVER=dev              # dev | log | smtp
MAIL_FROM=noreply@example.com
MAIL_FROM_NAME="PHP MVC Base"
MAIL_HOST=smtp.example.com
MAIL_PORT=587
MAIL_USER=
MAIL_PASS=
MAIL_ENCRYPTION=tls

Constantes globais definidas em config/app.php

Constante Descrição
APP_NAME Nome da aplicação
APP_ENV development ou production — controla exibição de erros
APP_DEBUG bool — exibe stack trace detalhado em exceções
APP_URL URL base detectada automaticamente ou definida no .env
ROOT_PATH Caminho absoluto da raiz do projeto
VIEW_PATH Caminho para app/Views
STORAGE_PATH Caminho para storage/

Arquitetura em Camadas

Cada camada tem uma responsabilidade única. Nenhuma camada sabe mais do que precisa sobre as outras.

🌐 HTTP RequestChega em public/index.php → bootstrap/app.php inicializa o kernel
🛣 RouterResolve URI → Controller@method, aplica middlewares em ordem
🔒 MiddlewaresAuth, CSRF, Guest, Role — executados antes do controller
🎮 ControllerLê Request, valida, chama Service ou Repository, retorna View/JSON
⚙️ ServiceLógica de negócio pura — sem HTTP, sem HTML, sem sessão
📦 RepositoryQueries SQL encapsuladas — o Controller não toca no Model diretamente
🗄 Model + DatabaseActive Record sobre PDO com prepared statements automáticos

Regra de ouro por camada

Camada ✅ Deve ter ❌ Não deve ter
Controller Ler request, chamar Service/Repo, retornar View/JSON Queries SQL, regras de negócio complexas
Service Lógica de negócio, orquestração, validações de domínio $_POST, header(), HTML, redirect()
Repository Queries SQL da entidade, buscas customizadas Lógica de negócio, validações
Model Estrutura da tabela, $fillable, $softDelete Lógica de apresentação, HTTP

Router

Roteamento desacoplado com grupos aninhados, parâmetros opcionais, middlewares, rotas nomeadas e geração de URL.

routes/web.php
<?php
use App\Controllers\PostController;
use Core\Router;

/** @var Router $router */

// ── Rotas simples ─────────────────────────────────────────────────────────────
$router->get('/',               [HomeController::class, 'index'])->name('home');
$router->get('/posts',          [PostController::class, 'index'])->name('posts.index');
$router->post('/posts',         [PostController::class, 'store'],   ['CsrfMiddleware']);
$router->get('/posts/{id}',     [PostController::class, 'show'])->name('posts.show');
$router->put('/posts/{id}',     [PostController::class, 'update'],  ['CsrfMiddleware']);
$router->delete('/posts/{id}',  [PostController::class, 'destroy'], ['CsrfMiddleware']);

// ── Parâmetro opcional ────────────────────────────────────────────────────────
$router->get('/archive/{year?}', [PostController::class, 'archive']);

// ── match() — múltiplos métodos numa rota só ──────────────────────────────────
$router->match(['GET', 'POST'], '/webhook', [WebhookController::class, 'handle']);

// ── resource() — 7 rotas CRUD de uma vez ─────────────────────────────────────
$router->resource('/posts', PostController::class);
// Equivale a: index, create, store, show, edit, update, destroy

// Apenas algumas ações:
$router->resource('/posts', PostController::class, only: ['index', 'store', 'destroy']);

// ── Grupos com prefixo e middleware compartilhado ────────────────────────────
$router->group(['prefix' => '/auth', 'middleware' => ['GuestMiddleware']], function (Router $r) {
    $r->get('/login',    [AuthController::class, 'loginForm'])->name('auth.login');
    $r->post('/login',   [AuthController::class, 'login'],     ['CsrfMiddleware']);
    $r->get('/register', [AuthController::class, 'registerForm']);
    $r->post('/register',[AuthController::class, 'register'],  ['CsrfMiddleware']);
});

$router->group(['prefix' => '/dashboard', 'middleware' => ['AuthMiddleware']], function (Router $r) {
    $r->get('', [DashboardController::class, 'index'])->name('dashboard');

    // Sub-grupo aninhado
    $r->group(['prefix' => '/admin', 'middleware' => ['RoleMiddleware:admin']], function (Router $r) {
        $r->get('/users', [AdminController::class, 'users']);
    });
});

// ── Rotas nomeadas → geração de URL via route() ──────────────────────────────
echo route('home');                          // → https://app.com/
echo route('posts.show', ['id' => 42]);      // → https://app.com/posts/42
echo route('auth.login');                    // → https://app.com/auth/login
ℹ️
Sintaxe de string também funciona

Além de [Controller::class, 'method'], o Router aceita 'Controller@method'.

Middlewares

Filtros executados antes do controller. Quatro middlewares incluídos, prontos para usar ou estender.

Middleware O que faz Quando usar
AuthMiddleware Redireciona para /auth/login se não autenticado Rotas da área logada
GuestMiddleware Redireciona para /dashboard se já logado Login, registro, recuperação de senha
CsrfMiddleware Valida token _csrf_token em POST/PUT/DELETE Qualquer formulário ou rota mutável
RoleMiddleware:admin Exige role específica (parâmetro após :) Áreas administrativas restritas
app/Middlewares/SubscriberMiddleware.php — criando um middleware
<?php
namespace App\Middlewares;

use Core\Request;
use Core\Session;

class SubscriberMiddleware
{
    public function handle(Request $request): void
    {
        $user = Session::get('user');

        if (!$user || $user->plan !== 'premium') {
            if ($request->isJson()) {
                http_response_code(403);
                header('Content-Type: application/json');
                echo json_encode(['success' => false, 'message' => 'Premium required.']);
                exit;
            }
            Session::flash('error', 'Recurso exclusivo para assinantes Premium.');
            redirect('dashboard');
        }
    }
}

// Uso nas rotas:
$router->get('/premium', [ContentController::class, 'index'], ['AuthMiddleware', 'SubscriberMiddleware']);

Controller

Todos os controllers herdam de Core\Controller e ganham helpers para views, JSON, redirecionamento, validação e abort.

ℹ️
Layout padrão

O método view() usa o layout app por padrão (arquivo app/Views/layouts/app.php). O layout anterior era main.

app/Controllers/PostController.php
<?php
namespace App\Controllers;

use Core\Controller;
use App\Services\PostService;
use App\Repositories\PostRepository;

class PostController extends Controller
{
    private PostService    $service;
    private PostRepository $repo;

    public function __construct()
    {
        $this->service = new PostService();
        $this->repo    = new PostRepository();
    }

    // Renderiza view com layout 'app' (padrão)
    public function index(): void
    {
        $posts = $this->repo->paginate(15, (int)($_GET['page'] ?? 1));

        $this->view('posts.index', [
            'title' => 'Posts',
            'posts' => $posts,
        ]);
    }

    // Layout alternativo
    public function create(): void
    {
        $this->view('posts.create', ['title' => 'Novo Post'], 'app');
    }

    // Valida → redireciona de volta se falhar → continua se OK
    public function store(): void
    {
        $this->validate($_POST, [
            'title'   => 'required|min:3|max:200',
            'content' => 'required|min:10',
            'status'  => 'required|in:draft,published',
        ]);

        $result = $this->service->create($_POST, $this->userId());

        $result['success']
            ? $this->redirectWith('dashboard/posts', 'success', 'Post criado!')
            : $this->redirectWith('dashboard/posts/create', 'error', $result['message']);
    }

    // Resposta JSON para APIs
    public function apiList(): void
    {
        $posts = $this->repo->paginate(10);
        $this->jsonSuccess('OK', ['posts' => $posts]);
    }

    // Aborta se condição não atendida
    public function show(int $id): void
    {
        $post = $this->repo->findById($id);
        $this->abortUnless((bool)$post, 404, 'Post não encontrado');
        $this->view('posts.show', compact('post'));
    }
}

Métodos disponíveis

Método Descrição
view($view, $data, $layout) Renderiza view com layout (padrão: 'app')
viewOnly($view, $data) Renderiza view sem layout algum
redirect($url) Redireciona para URL absoluta ou relativa
back() Redireciona para a URL anterior (HTTP_REFERER)
redirectWith($url, $type, $msg) Redireciona + salva flash message na sessão
json($data, $status) Resposta JSON com código HTTP
jsonSuccess($msg, $data, $status) JSON {"success":true,"message":"...",...}
jsonError($msg, $status, $data) JSON {"success":false,"message":"..."}
flash($type, $message) Salva flash message sem redirecionar
validate($data, $rules) Valida e redireciona de volta se falhar (salva erros e old input)
auth() bool — usuário está autenticado?
user() ?object — dados do usuário logado (id, name, email, role)
userId() int — ID do usuário logado (0 se guest)
userRole() string — role do usuário ('guest' se não logado)
abort($code, $msg) Lança RuntimeException com código HTTP
abortUnless($cond, $code) Aborta se condição for false
abortIf($cond, $code) Aborta se condição for true

Model & Query Builder

Active Record com query builder fluente. Cada model mapeia uma tabela e herda CRUD completo, paginação, soft delete e filtro por $fillable.

app/Models/Post.php
<?php
namespace App\Models;

use Core\Model;

class Post extends Model
{
    protected string $table      = 'posts';
    protected string $primaryKey = 'id';                  // padrão
    protected array  $fillable   = ['user_id', 'title', 'slug', 'content', 'status'];
    protected array  $hidden     = ['deleted_at'];         // ocultos na serialização
    protected bool   $timestamps = true;                   // created_at / updated_at automáticos
    protected bool   $softDelete = false;                  // true → usa deleted_at ao invés de DELETE

    // Método customizado simples — fica no Model
    public function findBySlug(string $slug): object|false
    {
        return $this->findBy('slug', $slug);
    }
}
Uso do Query Builder
$post = new Post();

// ── CRUD básico ───────────────────────────────────────────────────────────────
$post->all();                                  // SELECT * ORDER BY id ASC
$post->all('created_at', 'DESC');             // ORDER BY created_at DESC
$post->find(42);                              // WHERE id = 42 LIMIT 1
$post->findBy('slug', 'meu-post');            // WHERE slug = ? LIMIT 1
$id = $post->create(['title' => 'Oi', 'user_id' => 1]); // INSERT → lastInsertId
$post->update(42, ['title' => 'Oi!']);        // UPDATE WHERE id = 42
$post->delete(42);                            // DELETE (ou soft delete se habilitado)
$post->forceDelete(42);                       // DELETE permanente mesmo com softDelete = true

// ── Query builder fluente ─────────────────────────────────────────────────────
$post->where('status', 'published')
     ->where('user_id', 5)
     ->orderBy('created_at', 'DESC')
     ->limit(10)
     ->get();                                  // → array de objetos stdClass

$post->where('status', 'published')->count();  // → int
$post->where('email', $email)->exists();       // → bool
$post->where('status', 'published')->first();  // → object|false

// orWhere
$post->where('status', 'published')
     ->orWhere('featured', 1)
     ->get();

// SELECT específico
$post->select('id', 'title', 'created_at')
     ->where('status', 'published')
     ->orderBy('id', 'DESC')
     ->get();

// ── Paginação ─────────────────────────────────────────────────────────────────
$result = $post->where('status', 'published')
               ->paginate(15, (int)($_GET['page'] ?? 1));
// $result = [
//   'data'      => [...],   ← array de objetos
//   'total'     => 120,
//   'page'      => 1,
//   'per_page'  => 15,
//   'last_page' => 8,
//   'from'      => 1,
//   'to'        => 15,
// ]

Database

Singleton PDO com interface fluente para queries seguras e transações com closure.

Queries diretas e transações
$db = Core\Database::getInstance();

// ── Query com bind (prepared statement — sempre seguro) ───────────────────────
$user = $db->query("SELECT * FROM users WHERE email = :e AND active = 1")
           ->bind(':e', $email)
           ->fetch();                          // → object | false

$users = $db->query("SELECT * FROM users WHERE role = :r ORDER BY name")
            ->bind(':r', 'admin')
            ->fetchAll();                      // → array de objetos

// ── INSERT / UPDATE / DELETE ──────────────────────────────────────────────────
$db->query("INSERT INTO logs (user_id, action) VALUES (:uid, :act)")
   ->bind(':uid', $userId)
   ->bind(':act', 'login')
   ->execute();

$lastId = $db->lastInsertId();
$rows   = $db->rowCount();

// ── Transação com closure (commit automático, rollback em exceção) ────────────
$db->transaction(function ($db) use ($userId, $data) {
    $db->query("UPDATE wallets SET balance = balance - :v WHERE user_id = :uid")
       ->bind(':v', $data['amount'])->bind(':uid', $userId)->execute();

    $db->query("INSERT INTO transactions (user_id, amount, type) VALUES (:uid, :v, 'debit')")
       ->bind(':uid', $userId)->bind(':v', $data['amount'])->execute();
});

// ── Transação manual ──────────────────────────────────────────────────────────
$db->beginTransaction();
try {
    // ... queries ...
    $db->commit();
} catch (\Throwable $e) {
    $db->rollback();
    throw $e;
}
⚠️
Use sempre prepared statements

Nunca interpolle variáveis diretamente em SQL. Use o ->bind() — ele garante escape automático contra SQL Injection.

Request

Encapsula a requisição HTTP com sanitização automática, suporte a JSON body e detecção de IP/headers.

Uso do Request
$req = new Core\Request();

// ── Método e URI ──────────────────────────────────────────────────────────────
$req->method();        // 'GET', 'POST', 'PUT', 'DELETE' (suporta override via _method)
$req->uri();           // '/dashboard/posts/42'
$req->isGet();
$req->isPost();
$req->isAjax();        // X-Requested-With: XMLHttpRequest
$req->isJson();        // Content-Type: application/json

// ── Dados de entrada (sanitizados por padrão) ─────────────────────────────────
$req->get('page', 1);     // $_GET com default
$req->post('name');       // $_POST sanitizado
$req->all();              // todos os $_POST sanitizados como array
$req->input('q');         // busca em POST e GET, nessa ordem
$req->raw('content');     // sem sanitização (markdown, editor HTML, etc.)
$req->has('email');       // bool: campo existe e não é vazio?

// ── JSON body (APIs REST) ─────────────────────────────────────────────────────
$data  = $req->json();         // array completo do body JSON
$title = $req->json('title');  // campo específico

// ── Arquivos ──────────────────────────────────────────────────────────────────
$req->file('avatar');          // array de $_FILES['avatar']
$req->hasFile('avatar');       // bool

// ── Metadados ─────────────────────────────────────────────────────────────────
$req->ip();                    // IP real (respeita X-Forwarded-For)
$req->header('Authorization'); // valor de um header HTTP
$req->userAgent();

Session

Sessões configuradas de forma segura (httpOnly, SameSite, path fora do public), flash messages e CSRF integrado.

Uso da Session
use Core\Session;

// ── CRUD básico ───────────────────────────────────────────────────────────────
Session::set('chave', $valor);
Session::get('chave', $default);
Session::has('chave');           // bool
Session::forget('chave');
Session::destroy();              // logout completo (destrói toda a sessão)

// ── Flash messages (existem por apenas 1 requisição) ──────────────────────────
Session::flash('success', 'Salvo com sucesso!');
Session::flash('error',   'Algo deu errado.');
$msg = Session::getFlash('success');   // lê e remove
Session::hasFlash('error');            // bool: existe?

// ── Old input (repopulação de formulários após erro) ──────────────────────────
Session::flashInput($_POST);           // salva inputs na sessão
Session::oldInput('email');            // recupera na view (ou use old() helper)
Session::oldInput('email', '');        // com default

// ── CSRF ─────────────────────────────────────────────────────────────────────
$token = Session::csrfToken();         // gera ou retorna token existente
Session::validateCsrf($token);         // bool — valida token enviado
Session::regenerateCsrf();             // rotaciona o token
💡
Helpers globais equivalentes

Use flash('success'), old('email'), hasError('campo'), error('campo') e csrf_field() diretamente nas views — são wrappers que chamam Session internamente.

Auth

Sistema de autenticação genérico e plugável. Funciona com qualquer Model que implemente authenticate(). Guarda em sessão: user_id, user_role e objeto user.

Uso do Auth
use Core\Auth;

// ── Login ─────────────────────────────────────────────────────────────────────
if (Auth::attempt($email, $password)) {
    redirect('dashboard');
}

// Com "lembrar"
Auth::attempt($email, $password, remember: true);

// Login manual (OAuth, magic link, etc.)
$user = (new User())->find($id);
Auth::loginUser($user);
Auth::loginUser($user, remember: true);

// ── Verificações ──────────────────────────────────────────────────────────────
Auth::check();              // bool: está logado?
Auth::guest();              // bool: não está logado?
Auth::user();               // ?object {id, name, email, role}
Auth::id();                 // ?int
Auth::role();               // string: 'admin', 'member', 'guest'...
Auth::is('admin');          // bool: role exatamente 'admin'?
Auth::isAny('admin', 'editor'); // bool: tem alguma dessas roles?

// ── Guards (redirecionam se condição não atendida) ────────────────────────────
Auth::require('auth/login');             // exige login, redireciona se não
Auth::requireRole('admin', 'dashboard'); // exige role, redireciona se não

// ── Logout ────────────────────────────────────────────────────────────────────
Auth::logout(); // destrói sessão + loga o evento

// ── Trocar modelo de usuário ──────────────────────────────────────────────────
Auth::setUserModel(AdminUser::class);

Validação

20+ regras de validação com mensagens em português. Integrado ao Controller Base para redirecionar automaticamente em caso de falha.

Uso do Validator
use Core\Validator;

// Factory estática
$v = Validator::make($_POST, [
    'name'     => 'required|min:2|max:100',
    'email'    => 'required|email|unique:users,email',
    'password' => 'required|min:6|confirmed',     // exige password_confirmation
    'age'      => 'required|integer',
    'website'  => 'nullable|url',
    'role'     => 'required|in:admin,editor,viewer',
    'user_id'  => 'required|exists:users,id',
    'price'    => 'required|numeric',
]);

if ($v->fails()) {
    $errors = $v->errors();           // ['email' => ['E-mail já em uso.'], ...]
    $first  = $v->firstError();       // 'Nome é obrigatório.'
    $field  = $v->firstError('email');
}

// No Controller — valida e redireciona de volta automaticamente
$this->validate($_POST, [
    'title' => 'required|min:3',
    'email' => 'required|email',
]);
// Se falhar: salva erros + old input na sessão, redireciona para URL anterior
// Se passar: continua normalmente, retorna $data

Todas as regras disponíveis

Regra Descrição
required Campo obrigatório (não vazio, não null)
nullable Permite vazio — pula as demais regras se vazio
min:N Mínimo N caracteres (string) ou valor N (número)
max:N Máximo N caracteres (string) ou valor N (número)
email E-mail válido via filter_var(FILTER_VALIDATE_EMAIL)
url URL válida
numeric Valor numérico (int ou float)
integer Número inteiro
alpha Apenas letras (a-z, A-Z)
alphanumeric Letras e números
date Data válida via strtotime()
confirmed Deve ser igual a {campo}_confirmation
same:outro Deve ser igual ao campo especificado
different:outro Deve ser diferente do campo especificado
in:a,b,c Deve estar na lista de valores
not_in:a,b,c Não deve estar na lista
regex:/pattern/ Deve corresponder à expressão regular
unique:tabela,col Valor único no banco (unique:users,email,{id_ignorado})
exists:tabela,col Deve existir no banco de dados

Views & Layouts

Sistema de templates PHP com layouts, componentes parciais e helpers de segurança. Tudo em PHP puro — sem Blade, sem Twig.

Como funciona o sistema de layouts
// Controller chama:
$this->view('posts.index', ['title' => 'Posts', 'posts' => $posts]);
// ↑ layout padrão é 'app' (app/Views/layouts/app.php)

// Outros layouts
$this->view('auth.login', $data, 'auth');    // layouts/auth.php
$this->view('admin.users', $data, 'admin'); // layouts/admin.php

// Sem layout
$this->viewOnly('emails.welcome', compact('user'));

// View::render() — para usar em qualquer lugar (fora de controller)
\Core\View::render('components.pagination', ['data' => $pagResult]);

// View::capture() — captura como string (ex: para e-mails)
$html = \Core\View::capture('emails.welcome', compact('user'));
app/Views/posts/index.php — template típico
<div class="page-header">
    <h1><?= e($title) ?></h1>               <!-- e() escapa XSS -->
    <a href="<?= url('dashboard/posts/create') ?>" class="btn btn-primary">Novo</a>
</div>

<?php \Core\View::render('components.alerts'); ?>   <!-- flash messages -->

<div class="card">
    <?php foreach ($posts['data'] as $post): ?>
        <div class="list-item">
            <h3><?= e($post->title) ?></h3>
            <span class="text-muted"><?= dateBR($post->created_at) ?></span>

            <form method="POST" action="<?= url("dashboard/posts/{$post->id}") ?>">
                <?= csrf_field() ?>                    <!-- token CSRF -->
                <?= method_field('DELETE') ?>          <!-- override método HTTP -->
                <button class="btn btn-danger btn-sm">Excluir</button>
            </form>
        </div>
    <?php endforeach; ?>
</div>

<?php \Core\View::render('components.pagination', ['data' => $posts]); ?>

Helpers disponíveis nas views

Helper Descrição
e($valor) Escapa HTML — use sempre ao exibir dados do usuário
url('path') URL absoluta baseada em APP_URL
asset('css/app.css') URL de asset em public/assets/
storageUrl('uploads/img.png') URL de arquivo em storage/
route('nome', $params) URL gerada a partir do nome da rota
csrf_field() Hidden input com token CSRF
csrf_token() Apenas o valor do token (para JS/Ajax)
method_field('DELETE') Hidden input para override do método HTTP
old('campo', '') Repopula campo após falha de validação
error('campo') Primeiro erro de validação do campo
hasError('campo') Bool: campo tem erro de validação?
flash('success') Lê e remove flash message da sessão
hasFlash('error') Bool: existe flash message do tipo?
auth() Bool: usuário está logado?
user() Objeto do usuário logado (?object)
userId() ID do usuário logado (int, 0 se guest)
userRole() Role do usuário logado
isRole('admin') Bool: tem a role especificada?
isActive('dashboard') Retorna 'active' se URI atual bate com o path
dateBR($date) Formata para DD/MM/YYYY
dateTimeBR($date) Formata para DD/MM/YYYY HH:MM
diffForHumans($date) "2 dias atrás", "agora mesmo"
str_limit($text, 100) Trunca texto com "..."
slug('Meu Título') Gera slug: 'meu-titulo'
formatBytes(1024) Formata bytes: "1.0 KB"
dd($valor) Dump and die (debug)

Upload

Upload seguro com validação de MIME real via finfo, nomes aleatórios e interface fluente com presets.

Upload de imagem e documento
use Core\Upload;

// ── Preset de imagens ─────────────────────────────────────────────────────────
$upload = new Upload($_FILES['avatar']);
$upload->forImages(maxMb: 2)
       ->setUploadDir(STORAGE_PATH . '/uploads/avatars')
       ->setPrefix('user_' . $userId . '_');

if ($upload->process()) {
    $filename = $upload->getFilename();  // 'user_42_a3f8c1d2e4b8.jpg'
    (new User())->update($userId, ['avatar' => $filename]);
} else {
    Session::flash('error', $upload->getFirstError());
}

// ── Preset de documentos ──────────────────────────────────────────────────────
$upload = new Upload($_FILES['doc']);
$upload->forDocuments(maxMb: 10)
       ->setUploadDir(STORAGE_PATH . '/uploads/docs');

// ── Configuração totalmente manual ────────────────────────────────────────────
$upload->setAllowedTypes(['image/jpeg', 'image/png', 'image/webp'])
       ->setAllowedExtensions(['jpg', 'jpeg', 'png', 'webp'])
       ->setMaxSize(3 * 1024 * 1024)    // 3 MB em bytes
       ->setUploadDir(STORAGE_PATH . '/uploads/photos')
       ->setRandomName(true)
       ->setPrefix('photo_')
       ->process();

Logger

Logs em arquivo diário (storage/logs/app-YYYY-MM-DD.log) com contexto JSON. Cinco níveis inspirados na PSR-3.

Uso do Logger
use Core\Logger;

// ── 5 níveis (DEBUG < INFO < WARNING < ERROR < CRITICAL) ──────────────────────
Logger::debug('Query executada',    ['sql' => $sql, 'time_ms' => 12.3]);
Logger::info('Usuário logado',      ['user_id' => 42, 'ip' => $ip]);
Logger::warning('Login falhou',     ['email' => $email, 'tentativa' => 3]);
Logger::error('Falha no upload',    ['arquivo' => $name, 'erro' => $msg]);
Logger::critical('Banco offline',   ['host' => $host, 'port' => 3306]);

// ── Nível mínimo (em produção, ignore DEBUG e INFO) ───────────────────────────
Logger::setMinLevel('WARNING');

// ── Formato no arquivo:
// [2024-03-15 14:32:01] [INFO] Usuário logado {"user_id":42,"ip":"127.0.0.1"}

Helpers Globais

Funções disponíveis em qualquer lugar da aplicação sem import. Carregadas via autoload.files no Composer. Definidas em app/Helpers/functions.php.

Referência completa — functions.php
// ── URLs ──────────────────────────────────────────────────────────────────────
url('dashboard/posts')         // URL absoluta com APP_URL como base
asset('css/app.css')           // → APP_URL/assets/css/app.css
storageUrl('uploads/img.png')  // → APP_URL/storage/uploads/img.png
route('posts.show', ['id'=>5]) // URL por nome de rota

// ── Redirecionamento ──────────────────────────────────────────────────────────
redirect('auth/login')         // redireciona e encerra execução

// ── Segurança ─────────────────────────────────────────────────────────────────
e($valor)                      // htmlspecialchars() — escapa XSS
csrf_field()                   // <input hidden name="_csrf_token" value="...">
csrf_token()                   // apenas o valor do token
method_field('DELETE')         // <input hidden name="_method" value="DELETE">

// ── Sessão / Formulários ──────────────────────────────────────────────────────
flash('success')               // lê e remove flash message
hasFlash('error')              // bool
old('email', '')               // valor anterior após erro de validação
error('email')                 // primeiro erro do campo
hasError('email')              // bool
errors()                       // todos os erros: ['campo' => [...]]

// ── Autenticação ──────────────────────────────────────────────────────────────
auth()                         // bool: logado?
user()                         // ?object {id, name, email, role}
userId()                       // int (0 se guest)
userRole()                     // string
isRole('admin')                // bool

// ── Ambiente ──────────────────────────────────────────────────────────────────
env('APP_NAME', 'default')     // variável de ambiente com fallback

// ── Strings ───────────────────────────────────────────────────────────────────
str_limit($text, 150)          // trunca com "..."
slug('Meu Título')             // → 'meu-titulo' (remove acentos, lowercase)

// ── Datas ─────────────────────────────────────────────────────────────────────
dateBR($date)                  // DD/MM/YYYY  (null → '—')
dateTimeBR($date)              // DD/MM/YYYY HH:MM
diffForHumans($date)           // "3 dias atrás", "agora mesmo"

// ── Números ───────────────────────────────────────────────────────────────────
formatBytes(1536, 1)           // → "1.5 KB"

// ── Navegação ─────────────────────────────────────────────────────────────────
isActive('dashboard')          // retorna 'active' se URI bate, '' caso contrário
isActive('dashboard', 'selected') // custom class

// ── Debug ─────────────────────────────────────────────────────────────────────
dd($valor)                     // dump + die (com highlight visual)
dump($a, $b)                   // dump (continua execução)

Mailer

Wrapper de e-mail com 3 drivers: smtp (produção via PHPMailer), log (homologação), dev (desenvolvimento).

Envio de e-mail
use App\Helpers\Mailer;

$mailer = new Mailer();

$result = $mailer->send(
    to:      'destinatario@example.com',
    subject: 'Bem-vindo ao sistema!',
    body:    '<h1>Olá, João!</h1><p>Sua conta foi criada.</p>',
    altBody: 'Olá, João! Sua conta foi criada.',   // versão texto puro
    toName:  'João Silva'
);

if ($result['success']) {
    Logger::info('E-mail enviado', ['to' => 'destinatario@example.com']);
} else {
    Logger::error('Falha no e-mail', ['message' => $result['message']]);
}
Driver (MAIL_DRIVER) Comportamento Quando usar
dev Não envia. Retorna conteúdo no array de retorno. Desenvolvimento local
log Grava em storage/logs/mail.log Homologação / staging
smtp Envia via PHPMailer + SMTP configurado no .env Produção

FormRequests

Camada dedicada para centralizar validação, sanitização e autorização. Mantém os Controllers finos e sem lógica de entrada. Inspirado no FormRequest do Laravel.

💡
Por que usar FormRequests?

Sem essa camada, validações ficam espalhadas nos Controllers. Com ela, cada formulário tem sua própria classe com regras, mensagens customizadas e sanitização isoladas — reutilizáveis e testáveis de forma independente.

Fluxo interno

new LoginRequest()Captura dados: $_POST, JSON body, nomes de $_FILES
authorize()Retorna false → HTTP 403 imediato (sem validar)
sanitize()Trim, strip_tags, htmlspecialchars (ou lógica customizada)
validate(rules())Aplica Core\Validator com messages() customizadas
validated()Retorna apenas os campos válidos e limpos para o Service
app/Requests/Posts/StorePostRequest.php
<?php
namespace App\Requests\Posts;

use App\Requests\FormRequest;
use Core\Auth;

class StorePostRequest extends FormRequest
{
    public function authorize(): bool
    {
        return Auth::check(); // apenas usuários logados
    }

    public function rules(): array
    {
        return [
            'title'   => 'required|min:3|max:255',
            'content' => 'required|min:10',
            'status'  => 'required|in:draft,published',
            'slug'    => 'nullable|max:260|unique:posts,slug',
        ];
    }

    public function messages(): array
    {
        return [
            'title.required' => 'O título é obrigatório.',
            'title.min'      => 'O título deve ter pelo menos 3 caracteres.',
            'content.required' => 'O conteúdo não pode estar vazio.',
            'status.in'      => 'Status inválido. Use: draft ou published.',
            'slug.unique'    => 'Este slug já está em uso.',
        ];
    }

    // Sobrescrita da sanitização padrão
    public function sanitize(): array
    {
        return array_merge($this->input, [
            'title'   => ucfirst(trim($this->input['title']   ?? '')),
            'content' => trim($this->input['content'] ?? ''),
            'status'  => trim($this->input['status']  ?? 'draft'),
            'slug'    => !empty($this->input['slug'])
                           ? slug($this->input['slug'])
                           : slug($this->input['title'] ?? ''),
        ]);
    }
}
Uso no Controller (HTML e API)
// ── Formulário HTML ───────────────────────────────────────────────────────────
public function store(): void
{
    $request = new StorePostRequest();

    if ($request->fails()) {
        Session::flash('error', $request->firstError());
        Session::flashInput($request->all()); // old input para repopular form
        $this->back();
    }

    $result = (new PostService())->create($request->validated(), $this->userId());

    $result['success']
        ? $this->redirectWith('dashboard/posts', 'success', 'Post criado!')
        : $this->redirectWith('dashboard/posts/create', 'error', $result['message']);
}

// ── API REST (JSON body) ──────────────────────────────────────────────────────
public function storeApi(): void
{
    $request = new StorePostRequest();

    if ($request->fails()) {
        $this->jsonError('Dados inválidos.', 422, ['errors' => $request->errors()]);
    }

    $result = (new PostService())->create($request->validated(), $this->userId());
    $this->jsonSuccess('Post criado.', ['id' => $result['id']], 201);
}

API do FormRequest

Método Retorno Descrição
fails() bool True se a validação falhou
passes() bool True se a validação passou
errors() array Todos os erros: ['campo' => ['msg',...]]
firstError() ?string Primeiro erro global
firstError('email') ?string Primeiro erro do campo específico
validated() array Dados válidos e sanitizados — passe ao Service
get('campo') mixed Campo específico do validated()
old('campo') mixed Valor anterior (para repopular form)
all() array Todos os dados sanitizados (antes da validação)

FormRequests incluídos no projeto

Classe Campos validados Detalhe
Auth\LoginRequest email, password Email normalizado para lowercase na sanitização
Auth\RegisterRequest name, email, password, password_confirmation Email único, password confirmada via confirmed
Auth\ForgotPasswordRequest email Não valida existência (evita user enumeration)
Auth\ResetPasswordRequest token, password, confirm Token validado contra o banco em authorize()
Users\StoreUserRequest name, email, password, role Apenas admins — authorize() exige Auth::is('admin')
Users\UpdateUserRequest name, email, password?, role? Admin edita qualquer; membro edita apenas a si

Repositories

Camada de acesso a dados. Encapsula todas as queries de uma entidade. Controllers nunca acessam o Model diretamente — sempre via Repository.

app/Repositories/UserRepository.php
<?php
namespace App\Repositories;

use Core\Repository;
use App\Models\User;

class UserRepository extends Repository
{
    protected string $modelClass = User::class;

    // Métodos herdados do Repository base:
    // all(), findById(), create(), update(), delete(), paginate()

    // Queries customizadas
    public function findByEmail(string $email): object|false
    {
        return $this->model()->findBy('email', $email);
    }

    public function findActive(int $page = 1): array
    {
        return $this->model()
            ->where('active', 1)
            ->orderBy('name')
            ->paginate(20, $page);
    }

    public function findByRole(string $role): array
    {
        return $this->model()
            ->where('role', $role)
            ->orderBy('name')
            ->get();
    }

    public function countActive(): int
    {
        return $this->model()->where('active', 1)->count();
    }

    // Acesso direto ao Database para queries complexas
    public function findWithStats(): array
    {
        return $this->db()
            ->query("SELECT u.*, COUNT(p.id) as post_count
                     FROM users u
                     LEFT JOIN posts p ON p.user_id = u.id
                     GROUP BY u.id
                     ORDER BY u.name")
            ->fetchAll();
    }
}

Métodos herdados de Core\Repository

Método Retorno Descrição
all() array Todos os registros
findById(int $id) object|false Busca por ID
create(array $data) string|false Insere e retorna o ID inserido
update(int $id, array $data) bool Atualiza registro
delete(int $id) bool Remove registro
paginate(int $perPage, int $page) array Dados paginados com metadados
model() Model Acesso ao Model para queries customizadas
db() Database Acesso ao PDO para queries complexas (JOINs, etc.)

Services

Camada de lógica de negócio. Orquestra Repositories, validações de domínio e efeitos colaterais (e-mail, log, eventos). Sem dependência de HTTP.

app/Services/AuthService.php — exemplo real do projeto
<?php
namespace App\Services;

use Core\Service;
use Core\Auth;
use Core\Logger;
use App\Repositories\UserRepository;

class AuthService extends Service
{
    public function __construct(
        private readonly UserRepository $users = new UserRepository()
    ) {}

    public function login(string $email, string $password): array
    {
        $user = $this->users->findByEmail($email);

        if (!$user || !password_verify($password, $user->password)) {
            Logger::warning('Login falhou', ['email' => $email]);
            return ['success' => false, 'message' => 'E-mail ou senha incorretos.'];
        }

        if (empty($user->active)) {
            return ['success' => false, 'message' => 'Conta inativa.'];
        }

        Auth::loginUser($user);
        Logger::info('Login realizado', ['user_id' => $user->id]);
        return ['success' => true];
    }

    public function register(array $data): array
    {
        if ($this->users->findByEmail($data['email'])) {
            return ['success' => false, 'message' => 'E-mail já cadastrado.'];
        }

        $id = $this->users->create([
            'name'     => $data['name'],
            'email'    => $data['email'],
            'password' => password_hash($data['password'], PASSWORD_BCRYPT, ['cost' => 12]),
            'role'     => $data['role'] ?? 'member',
            'active'   => 1,
        ]);

        if (!$id) return ['success' => false, 'message' => 'Erro ao criar conta.'];

        Logger::info('Usuário registrado', ['id' => $id, 'email' => $data['email']]);
        return ['success' => true, 'user_id' => $id];
    }

    public function logout(): void
    {
        Auth::logout();
    }
}
Usando transaction() herdado de Core\Service
class OrderService extends Service
{
    public function place(array $data, int $userId): array
    {
        return $this->transaction(function () use ($data, $userId) {
            $orderId = (new OrderRepository())->create([
                'user_id' => $userId,
                'total'   => $data['total'],
                'status'  => 'pending',
            ]);

            foreach ($data['items'] as $item) {
                (new OrderItemRepository())->create([
                    'order_id' => $orderId,
                    'product_id' => $item['id'],
                    'qty'        => $item['qty'],
                ]);
            }

            return ['success' => true, 'order_id' => $orderId];
        });
        // Se qualquer exceção lançada dentro do closure: rollback automático
    }
}

Migrations & Seeds

Migrations são arquivos SQL numerados. Seeds são scripts PHP para popular o banco com dados iniciais.

database/migrations/001_create_base_tables.sql — tabelas incluídas
-- Tabela de usuários
CREATE TABLE IF NOT EXISTS users (
    id         INT UNSIGNED  AUTO_INCREMENT PRIMARY KEY,
    name       VARCHAR(100)  NOT NULL,
    email      VARCHAR(180)  NOT NULL UNIQUE,
    password   VARCHAR(255)  NOT NULL,
    role       VARCHAR(40)   NOT NULL DEFAULT 'member',
    active     TINYINT(1)    NOT NULL DEFAULT 1,
    created_at DATETIME      NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME      NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    INDEX idx_email  (email),
    INDEX idx_active (active)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- Tabela de recuperação de senha
CREATE TABLE IF NOT EXISTS password_resets (
    id         INT UNSIGNED  AUTO_INCREMENT PRIMARY KEY,
    email      VARCHAR(180)  NOT NULL,
    token      VARCHAR(100)  NOT NULL UNIQUE,
    expires_at DATETIME      NOT NULL,
    used       TINYINT(1)    NOT NULL DEFAULT 0,
    created_at DATETIME      NOT NULL DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_email (email),
    INDEX idx_token (token)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
database/migrations/002_create_posts_table.sql — nova migration
CREATE TABLE IF NOT EXISTS posts (
    id         INT UNSIGNED  AUTO_INCREMENT PRIMARY KEY,
    user_id    INT UNSIGNED  NOT NULL,
    title      VARCHAR(255)  NOT NULL,
    slug       VARCHAR(260)  NOT NULL UNIQUE,
    content    TEXT          NOT NULL,
    status     ENUM('draft','published') NOT NULL DEFAULT 'draft',
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
    INDEX idx_status (status),
    INDEX idx_user   (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
Executando migrations
# Executa migrations pendentes (em ordem numérica)
php mvc migrate

# Recria banco do zero: DROP todas as tabelas + migrate novamente
php mvc migrate --fresh

# Gera nova migration via CLI
php mvc make:migration CreatePostsTable
# → cria: database/migrations/002_create_posts_table.sql
⚠️
Cuidado com --fresh

O flag --fresh dropa todas as tabelas do banco antes de migrar. Nunca use em produção com dados reais.

CLI — php mvc

Interface de linha de comando inspirada no Artisan. Execute php mvc list para ver todos os comandos disponíveis.

Uso básico
php mvc <comando> [argumentos] [--opções]

# Listar comandos
php mvc list
php mvc help
Geradores de código
php mvc make:controller UserControllerCria Controller com estrutura CRUD em app/Controllers/
php mvc make:model ProductCria Model em app/Models/ com $table e $fillable
php mvc make:request StoreProductRequestCria FormRequest em app/Requests/
php mvc make:service ProductServiceCria Service em app/Services/
php mvc make:repository ProductRepositoryCria Repository em app/Repositories/
php mvc make:seed ProductSeederCria Seeder em database/seeds/
php mvc make:migration CreateProductsTableCria arquivo SQL em database/migrations/
php mvc make:view productCria views index/show/create/edit em app/Views/product/
Banco de dados
php mvc migrateExecuta migrations pendentes em ordem numérica
php mvc migrate --freshDROP todas as tabelas + executa tudo do zero
Configuração
php mvc key:generateGera APP_KEY segura e salva no .env automaticamente
Servidor
php mvc serveInicia servidor local na porta 8000
php mvc serve --port=8080Inicia em porta personalizada
Criando um novo comando personalizado
<?php
// 1. Crie cli/Commands/ClearCacheCommand.php
namespace Cli\Commands;

use Cli\Command;
use Cli\Output;

class ClearCacheCommand extends Command
{
    public function handle(): bool
    {
        Output::info('Limpando cache...');
        $files = glob(STORAGE_PATH . '/cache/*');
        foreach ($files as $file) {
            if (is_file($file)) unlink($file);
        }
        Output::success('Cache limpo! ' . count($files) . ' arquivo(s) removido(s).');
        return true;
    }
}

// 2. Registre em cli/Kernel.php, dentro de registerCommands():
'cache:clear' => ClearCacheCommand::class,

// 3. Use:
// php mvc cache:clear

Criando um Módulo Completo

Passo a passo para adicionar um novo módulo — exemplo: Posts. Use o CLI para acelerar cada etapa.

Geração via CLI (recomendado)
php mvc make:migration CreatePostsTable
php mvc make:model Post
php mvc make:repository PostRepository
php mvc make:service PostService
php mvc make:controller PostController
php mvc make:request StorePostRequest
php mvc make:view post
php mvc migrate
1. Migration — database/migrations/002_create_posts_table.sql
CREATE TABLE IF NOT EXISTS posts (
    id         INT UNSIGNED  AUTO_INCREMENT PRIMARY KEY,
    user_id    INT UNSIGNED  NOT NULL,
    title      VARCHAR(255)  NOT NULL,
    slug       VARCHAR(260)  NOT NULL UNIQUE,
    content    TEXT          NOT NULL,
    status     ENUM('draft','published') NOT NULL DEFAULT 'draft',
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
2. Model — app/Models/Post.php
<?php
namespace App\Models;

use Core\Model;

class Post extends Model
{
    protected string $table    = 'posts';
    protected array  $fillable = ['user_id', 'title', 'slug', 'content', 'status'];

    public function findBySlug(string $slug): object|false
    {
        return $this->findBy('slug', $slug);
    }
}
3. Repository — app/Repositories/PostRepository.php
<?php
namespace App\Repositories;

use Core\Repository;
use App\Models\Post;

class PostRepository extends Repository
{
    protected string $modelClass = Post::class;

    public function findPublished(int $page = 1): array
    {
        return $this->model()
            ->where('status', 'published')
            ->orderBy('created_at', 'DESC')
            ->paginate(15, $page);
    }

    public function findByAuthor(int $userId): array
    {
        return $this->model()
            ->where('user_id', $userId)
            ->orderBy('created_at', 'DESC')
            ->get();
    }
}
4. Service — app/Services/PostService.php
<?php
namespace App\Services;

use Core\Service;
use Core\Logger;
use App\Repositories\PostRepository;

class PostService extends Service
{
    public function __construct(
        private readonly PostRepository $posts = new PostRepository()
    ) {}

    public function create(array $data, int $userId): array
    {
        $id = $this->posts->create([
            'user_id' => $userId,
            'title'   => $data['title'],
            'slug'    => slug($data['slug'] ?? $data['title']),
            'content' => $data['content'],
            'status'  => $data['status'] ?? 'draft',
        ]);

        if (!$id) return ['success' => false, 'message' => 'Erro ao criar post.'];

        Logger::info('Post criado', ['id' => $id, 'user_id' => $userId]);
        return ['success' => true, 'id' => $id];
    }

    public function delete(int $postId, int $userId): array
    {
        $post = $this->posts->findById($postId);
        if (!$post || (int)$post->user_id !== $userId)
            return ['success' => false, 'message' => 'Sem permissão.'];

        $this->posts->delete($postId);
        return ['success' => true];
    }
}
5. Controller — app/Controllers/PostController.php
<?php
namespace App\Controllers;

use Core\Controller;
use App\Services\PostService;
use App\Repositories\PostRepository;

class PostController extends Controller
{
    private PostService    $service;
    private PostRepository $repo;

    public function __construct()
    {
        $this->service = new PostService();
        $this->repo    = new PostRepository();
    }

    public function index(): void
    {
        $this->view('posts.index', [
            'title' => 'Posts',
            'posts' => $this->repo->findPublished((int)($_GET['page'] ?? 1)),
        ]);
    }

    public function store(): void
    {
        $this->validate($_POST, [
            'title'   => 'required|min:3|max:255',
            'content' => 'required|min:10',
        ]);

        $result = $this->service->create($_POST, $this->userId());
        $result['success']
            ? $this->redirectWith('dashboard/posts', 'success', 'Post criado!')
            : $this->redirectWith('dashboard/posts', 'error', $result['message']);
    }

    public function show(int $id): void
    {
        $post = $this->repo->findById($id);
        $this->abortUnless((bool)$post, 404);
        $this->view('posts.show', compact('post'));
    }

    public function destroy(int $id): void
    {
        $result = $this->service->delete($id, $this->userId());
        $this->json(['success' => $result['success'], 'message' => $result['message'] ?? '']);
    }
}
6. Rotas — routes/web.php
// Dentro do grupo ['prefix' => '/dashboard', 'middleware' => ['AuthMiddleware']]:
$r->get('/posts',           [PostController::class, 'index'])->name('posts.index');
$r->get('/posts/create',    [PostController::class, 'create']);
$r->post('/posts',          [PostController::class, 'store'],   ['CsrfMiddleware']);
$r->get('/posts/{id}',      [PostController::class, 'show'])->name('posts.show');
$r->get('/posts/{id}/edit', [PostController::class, 'edit']);
$r->put('/posts/{id}',      [PostController::class, 'update'],  ['CsrfMiddleware']);
$r->delete('/posts/{id}',   [PostController::class, 'destroy'], ['CsrfMiddleware']);

// Ou usando resource() para registrar as 7 rotas de uma vez:
$r->resource('/posts', PostController::class);

API REST

O boilerplate está preparado para APIs JSON. Controllers retornam JSON, FormRequests suportam body JSON e o grupo /api já existe em routes/web.php.

Controller de API
<?php
namespace App\Controllers;

use Core\Controller;
use App\Repositories\PostRepository;
use App\Requests\Posts\StorePostRequest;

class ApiPostController extends Controller
{
    public function index(): void
    {
        $posts = (new PostRepository())->findPublished((int)($_GET['page'] ?? 1));
        $this->jsonSuccess('OK', ['posts' => $posts]);
    }

    public function store(): void
    {
        $request = new StorePostRequest();

        if ($request->fails()) {
            $this->jsonError('Dados inválidos.', 422, ['errors' => $request->errors()]);
        }

        $result = (new PostService())->create($request->validated(), $this->userId());
        $this->jsonSuccess('Post criado.', ['id' => $result['id']], 201);
    }

    public function destroy(int $id): void
    {
        $result = (new PostService())->delete($id, $this->userId());
        $result['success']
            ? $this->json(['success' => true], 204)
            : $this->jsonError($result['message'], 403);
    }
}
Formato de resposta padrão
// Sucesso simples
{ "success": true, "message": "Post criado.", "id": 42 }

// Lista paginada
{
  "success": true,
  "message": "OK",
  "posts": {
    "data": [...],
    "total": 120,
    "page": 1,
    "per_page": 15,
    "last_page": 8,
    "from": 1,
    "to": 15
  }
}

// Erro simples
{ "success": false, "message": "Não autorizado" }

// Erro de validação
{
  "success": false,
  "message": "Dados inválidos.",
  "errors": { "title": ["O título é obrigatório."], "content": ["..."] }
}
Chamada via JavaScript (apiFetch helper)
// apiFetch está definido em public/assets/js/app.js
// Inclui automaticamente o CSRF token e Content-Type: application/json

const result = await apiFetch('/api/posts', {
    method: 'POST',
    body: JSON.stringify({ title: 'Novo Post', content: '...' }),
});

if (result.success) {
    console.log('ID criado:', result.id);
} else {
    console.error('Erro:', result.message, result.errors);
}

// Manual (sem o helper):
fetch('/api/posts', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content,
    },
    body: JSON.stringify(data),
});

Segurança

Proteções ativas por padrão. A tabela abaixo resume o que está protegido e onde.

CSRF — Cross-Site Request Forgery

Formulários HTML e AJAX
<!-- Formulário HTML -->
<form method="POST" action="<?= url('posts') ?>">
    <?= csrf_field() ?>   <!-- <input type="hidden" name="_csrf_token" value="..."> -->
    ...
</form>
AJAX / fetch manual
// Usando o helper apiFetch (recomendado — já injeta o token)
const result = await apiFetch('/api/posts', { method: 'POST', body: JSON.stringify(data) });

// Manualmente via meta tag:
fetch('/api/posts', {
    method: 'POST',
    headers: { 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content },
    body: JSON.stringify(data),
});

XSS — Cross-Site Scripting

Sempre use e() nas views
// ✅ Seguro
echo e($user->name);
echo e($post->title);

// ❌ NUNCA — vulnerável a XSS
echo $user->name;
echo "<h1>{$post->title}</h1>";

SQL Injection

Prepared statements automáticos
// ✅ Seguro — prepared statement via query builder
$user = (new User())->findBy('email', $email);
$user = (new User())->where('email', $email)->first();

// ✅ Seguro — bind manual
$db->query("SELECT * FROM users WHERE email = :e")->bind(':e', $email)->fetch();

// ❌ NUNCA — injeção direta
$db->query("SELECT * FROM users WHERE email = '$email'");

Headers de Segurança

Definidos em Core\Application e reforçados pelo public/.htaccess.

Header Valor padrão Proteção
X-Content-Type-Options nosniff Evita MIME sniffing
X-Frame-Options SAMEORIGIN Evita clickjacking
X-XSS-Protection 1; mode=block XSS em browsers antigos
Referrer-Policy strict-origin-when-cross-origin Privacidade de URL

Checklist de segurança

Proteção Como está implementada
CSRF CsrfMiddleware + csrf_field() em todos os formulários
XSS e() em todas as saídas + sanitização automática no Request
SQL Injection 100% prepared statements — nunca interpolação de variáveis em SQL
Sessões httpOnly, SameSite=Lax, path fora do public, regeneração de ID no login
Uploads Validação de MIME real (finfo), não apenas extensão; nomes aleatórios
Senhas password_hash(BCRYPT, cost: 12) + password_verify()
Credenciais Sempre no .env, nunca no código-fonte
Debug em prod APP_ENV=production oculta erros detalhados