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.
php mvc make:controller, migrations, serve e mais 10
comandos.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
# 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) |
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.
php mvc migrate de
preferênciaphp mvc make:controller UserControllerConfiguração (.env)
Todas as configurações sensíveis ficam no arquivo .env. Nunca
commite credenciais no repositório.
# ── 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.
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.
<?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
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 |
<?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.
O método view() usa o layout app por padrão (arquivo
app/Views/layouts/app.php). O layout anterior era main.
<?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.
<?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);
}
}
$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.
$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;
}
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.
$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.
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
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.
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.
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.
// 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'));
<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.
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.
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.
// ── 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).
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.
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
<?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'] ?? ''),
]);
}
}
// ── 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 |
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.
<?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.
<?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();
}
}
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.
-- 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;
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;
# 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
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.
php mvc <comando> [argumentos] [--opções]
# Listar comandos
php mvc list
php mvc help
<?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.
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
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;
<?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);
}
}
<?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();
}
}
<?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];
}
}
<?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'] ?? '']);
}
}
// 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.
<?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);
}
}
// 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": ["..."] }
}
// 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ário HTML -->
<form method="POST" action="<?= url('posts') ?>">
<?= csrf_field() ?> <!-- <input type="hidden" name="_csrf_token" value="..."> -->
...
</form>
// 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
// ✅ Seguro
echo e($user->name);
echo e($post->title);
// ❌ NUNCA — vulnerável a XSS
echo $user->name;
echo "<h1>{$post->title}</h1>";
SQL Injection
// ✅ 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 |