TaskFlow Pro
Sistema completo de gerenciamento de projetos e tarefas construído sobre uma arquitetura MVC em PHP puro, sem dependências de frameworks externos.
O que é o TaskFlow Pro?
O TaskFlow Pro é uma aplicação web profissional para gerenciamento de tarefas com board Kanban, comentários em tempo real, upload de anexos, sistema de convites por e-mail, relatórios e muito mais. Toda a base do framework é implementada do zero em PHP 8.1+.
Funcionalidades do Sistema
Stack Tecnológico
| Camada | Tecnologia | Versão |
|---|---|---|
| Backend | PHP (MVC puro, sem Laravel/Symfony) | 8.1+ |
| Banco de Dados | MySQL / MariaDB com PDO | 5.7+ / 10.3+ |
| Gerenciador de Pacotes | Composer (apenas vlucas/phpdotenv) | ^5.5 |
| Frontend CSS | CSS puro com variáveis (sem Bootstrap) | — |
| Frontend JS | JavaScript vanilla (sem jQuery/React) | ES2022+ |
| Ícones | Font Awesome | 6.5 |
| Fontes | Plus Jakarta Sans (Google Fonts) | — |
| Servidor | Apache com mod_rewrite | 2.4+ |
Instalação Rápida
Em menos de 5 minutos o TaskFlow Pro está rodando localmente com XAMPP, WAMP ou qualquer servidor Apache.
Pré-requisitos
- PHP 8.1+ com extensões:
pdo_mysql,mbstring,fileinfo,openssl - MySQL 5.7+ ou MariaDB 10.3+
- Apache com
mod_rewritehabilitado - Composer (opcional se usar o ZIP com vendor incluso)
Passo a Passo
Copiar o projeto para o servidor
Extraia o arquivo taskflow-mvc.zip dentro da pasta raiz do seu servidor:
# XAMPP (Windows)
C:\xampp\htdocs\taskflow-mvc\
# WAMP (Windows)
C:\wamp64\www\taskflow-mvc\
# Linux/Mac
/var/www/html/taskflow-mvc/
Configurar o arquivo .env
Copie o arquivo de exemplo e edite com suas configurações locais:
cp .env.example .env
APP_NAME="TaskFlow Pro"
APP_ENV=development
APP_URL=http://localhost/taskflow-mvc/public
DB_HOST=localhost
DB_DATABASE=taskflow_db
DB_USERNAME=root
DB_PASSWORD=
Criar o banco de dados
Importe o arquivo SQL no phpMyAdmin ou via terminal:
mysql -u root -p < database/migrations/001_taskflow_schema.sql
phpMyAdmin
Acesse phpMyAdmin → clique em "Importar" → selecione o arquivo
database/migrations/001_taskflow_schema.sql → clique em
"Executar".
Instalar dependências (se necessário)
O ZIP já inclui o vendor/, portanto este passo é opcional. Se precisar
reinstalar:
composer install
Acessar o sistema
Abra o navegador e acesse:
http://localhost/taskflow-mvc/public/auth/register
Crie sua conta na tela de registro. O primeiro usuário registrado recebe o papel de admin.
Configurar Permissões (Linux)
chmod -R 755 storage/
chmod -R 755 public/uploads/
chown -R www-data:www-data storage/ public/uploads/
Estrutura de Pastas
O projeto segue a convenção MVC com separação clara de responsabilidades. Cada diretório tem um único propósito.
app/ ← núcleo da aplicação
Controllers/ ← lógica de cada módulo
AuthController.php
DashboardController.php
ProjectController.php
TaskController.php
UserController.php
... (10 controllers)
Core/ ← framework interno
Application.php ← bootstrap
Router.php ← roteamento
Controller.php ← base
Model.php ← base CRUD
Database.php ← PDO Singleton
Session.php ← sessões seguras
View.php ← renderizador
Request.php, Logger.php, Validator.php
Helpers/ ← funções utilitárias
functions.php ← autoload Composer
TaskflowHelper.php
SecurityHelper.php
Middlewares/
AuthMiddleware.php
CsrfMiddleware.php
GuestMiddleware.php
Models/
User.php, Project.php, Task.php
Report.php, PasswordReset.php
Views/
layouts/ ← app.php, auth.php
auth/ ← login, register, etc.
dashboard/, projects/, kanban/
tasks/, my_tasks/, reports/
profile/, settings/, users/
config/
app.php ← constantes globais
database.php ← conexão BD
mail.php ← configuração de e-mail
database/migrations/
001_taskflow_schema.sql ← schema completo
public/ ← document root do Apache
index.php ← front controller
.htaccess ← rewrite rules
assets/css/ ← taskflow.css
assets/js/ ← taskflow.js
uploads/ ← arquivos enviados
routes/
web.php ← todas as rotas da aplicação
storage/
logs/ ← error-YYYY-MM-DD.log
sessions/ ← arquivos de sessão PHP
.env ← variáveis de ambiente (privado)
.env.example ← modelo público
composer.json
Document Root
O Apache deve apontar para a pasta public/, não para a raiz do projeto. O
.env e todo o código PHP ficam acima do public/, inacessíveis
ao browser.
Variáveis de Ambiente
Toda configuração sensível fica no arquivo .env na raiz do
projeto, carregado via vlucas/phpdotenv antes de qualquer código da aplicação.
# ── Aplicação ────────────────────────────────────────────
APP_NAME="TaskFlow Pro" # Nome exibido na interface
APP_ENV=development # development | production
APP_DEBUG=true # true exibe erros detalhados
APP_URL=http://localhost/taskflow-mvc/public # SEM barra final
APP_KEY=base64:SuaChaveAqui # Chave de segurança (qualquer string longa)
# ── Banco de Dados ────────────────────────────────────────
DB_HOST=localhost
DB_PORT=3306
DB_DATABASE=taskflow_db
DB_USERNAME=root
DB_PASSWORD=
# ── Sessão ───────────────────────────────────────────────
SESSION_LIFETIME=120 # Minutos de inatividade
SESSION_SECURE=false # true apenas com HTTPS
# ── E-mail ───────────────────────────────────────────────
MAIL_DRIVER=dev # dev (mostra link) | smtp (envia)
MAIL_SMTP_HOST=smtp.gmail.com
MAIL_SMTP_PORT=587
MAIL_SMTP_USER=seu@email.com
MAIL_SMTP_PASS=suasenha
MAIL_SMTP_SECURE=tls
Acessando Variáveis no Código
As variáveis do .env ficam disponíveis via $_ENV e são lidas nos
arquivos de configuração em config/:
// As variáveis .env são lidas aqui e viram constantes PHP globais
define('APP_NAME', $_ENV['APP_NAME'] ?? 'TaskFlow Pro');
define('APP_URL', $_ENV['APP_URL'] ?? 'http://localhost');
define('APP_DEBUG', $_ENV['APP_DEBUG'] ?? false);
// Acesso em qualquer lugar do código:
echo APP_URL; // http://localhost/taskflow-mvc/public
echo APP_DEBUG; // true
Nunca commite o .env
O arquivo .env contém senhas e chaves. Ele já está no
.gitignore. Use sempre o .env.example como modelo.
Banco de Dados
O TaskFlow usa MySQL com PDO. A classe Database implementa o
padrão Singleton e oferece uma interface fluente e segura contra SQL Injection.
Diagrama de Tabelas
| Tabela | Propósito | Chaves Relacionadas |
|---|---|---|
users |
Contas de usuário com tema, bio e papel | — (raiz) |
password_resets |
Tokens de recuperação de senha (1h) | email → users.email |
projects |
Projetos com cor, ícone e status | owner_id → users.id |
project_members |
Membros do projeto com papel editor/viewer | project_id, user_id |
project_invites |
Convites por e-mail com token único (7 dias) | project_id, invited_by |
tasks |
Tarefas com status, prioridade e prazo | project_id, assigned_to, created_by |
task_assignees |
Múltiplos responsáveis por tarefa | task_id, user_id |
task_checklist |
Itens de checklist por tarefa | task_id |
task_attachments |
Anexos com nome real, nome salvo e tipo MIME | task_id, user_id |
task_external_notes |
Observações sobre responsáveis externos | task_id, created_by |
comments |
Comentários com reply encadeado | task_id, user_id, parent_id |
Usando a Classe Database
A classe Database oferece uma interface fluente (method chaining). Todos os
parâmetros são vinculados com bind(), prevenindo SQL Injection:
use App\Core\Database;
$db = Database::getInstance(); // Singleton — mesma conexão em toda a requisição
// ── SELECT simples ────────────────────────────────────────────────
$user = $db->query("SELECT * FROM users WHERE id = :id LIMIT 1")
->bind(':id', 5)
->fetch();
echo $user->name; // acessa como objeto
// ── SELECT múltiplos ─────────────────────────────────────────────
$tasks = $db->query("SELECT * FROM tasks WHERE project_id = :pid AND status = :s")
->bind(':pid', 12)
->bind(':s', 'todo')
->fetchAll();
foreach ($tasks as $task) {
echo $task->title;
}
// ── INSERT ───────────────────────────────────────────────────────
$db->query("INSERT INTO projects (owner_id, name, color) VALUES (:uid, :name, :color)")
->bind(':uid', 1)
->bind(':name', 'Novo Projeto')
->bind(':color', '#6366f1')
->execute();
$newId = $db->lastInsertId(); // ID do registro inserido
// ── UPDATE ───────────────────────────────────────────────────────
$db->query("UPDATE tasks SET status = :s WHERE id = :id")
->bind(':s', 'done')
->bind(':id', 42)
->execute();
$affected = $db->rowCount(); // Linhas afetadas
// ── TRANSAÇÃO ────────────────────────────────────────────────────
$db->beginTransaction();
try {
$db->query("DELETE FROM task_checklist WHERE task_id = :id")->bind(':id', 5)->execute();
$db->query("DELETE FROM tasks WHERE id = :id")->bind(':id', 5)->execute();
$db->commit();
} catch (Exception $e) {
$db->rollback();
throw $e;
}
Adicionar Nova Tabela
Crie um arquivo SQL em database/migrations/ com o próximo número sequencial:
USE taskflow_db;
CREATE TABLE IF NOT EXISTS labels (
id INT PRIMARY KEY AUTO_INCREMENT,
project_id INT NOT NULL,
name VARCHAR(80) NOT NULL,
color VARCHAR(7) DEFAULT '#6366f1',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (project_id) REFERENCES projects(id)
) ENGINE=InnoDB;
-- Tabela pivot tarefa-label
CREATE TABLE IF NOT EXISTS task_labels (
task_id INT NOT NULL,
label_id INT NOT NULL,
PRIMARY KEY (task_id, label_id),
FOREIGN KEY (task_id) REFERENCES tasks(id),
FOREIGN KEY (label_id) REFERENCES labels(id)
) ENGINE=InnoDB;
Depois de criar a migration SQL, importe-a no banco e crie o Model correspondente em
app/Models/Label.php seguindo o padrão existente.
Ciclo de Vida
Cada requisição HTTP passa por uma cadeia bem definida de componentes antes de chegar à view. Entender este fluxo é fundamental para depurar e estender o sistema.
Detalhamento do Fluxo
| # | Etapa | Arquivo | O que acontece |
|---|---|---|---|
| 1 | HTTP Request | .htaccess |
Reescreve toda URL para index.php?url=path |
| 2 | Bootstrap | public/index.php |
Define constantes, carrega Composer, sessão, config |
| 3 | Application | Core/Application.php |
Cria Request e Router; carrega routes/web.php |
| 4 | Roteamento | Core/Router.php |
Compara URI com rotas registradas, extrai parâmetros |
| 5 | Middlewares | Middlewares/*.php |
Executa guards (Auth, CSRF, Guest) antes do controller |
| 6 | Controller | Controllers/*.php |
Chama Models, processa dados, chama $this->view() |
| 7 | Model | Models/*.php |
Executa queries via Database, retorna objetos |
| 8 | View | Views/*.php |
Renderiza HTML com dados, dentro do layout escolhido |
| 9 | Response | — | HTML final enviado ao browser |
O front controller (index.php)
<?php
// 1. Constantes de path
define('ROOT_PATH', dirname(__DIR__));
define('APP_PATH', ROOT_PATH . '/app');
define('PUBLIC_PATH', ROOT_PATH . '/public');
define('VIEW_PATH', APP_PATH . '/Views');
define('STORAGE_PATH', ROOT_PATH . '/storage');
define('CONFIG_PATH', ROOT_PATH . '/config');
// 2. Autoload do Composer (PSR-4 + functions.php)
require ROOT_PATH . '/vendor/autoload.php';
// 3. Carrega variáveis do .env
$dotenv = Dotenv\Dotenv::createImmutable(ROOT_PATH);
$dotenv->load();
// 4. Configurações globais (constantes APP_*, display_errors, timezone...)
require CONFIG_PATH . '/app.php';
// 5. Inicia sessão segura
\App\Core\Session::start();
// 6. Inicializa e executa a aplicação
$app = new \App\Core\Application();
$app->run();
Sistema de Rotas
Todas as rotas ficam em routes/web.php. O Router suporta
GET/POST, parâmetros dinâmicos, grupos com prefixo e middlewares por rota ou grupo.
Anatomia de uma Rota
<?php
// Sintaxe básica:
// $router->MÉTODO('/caminho', [Controller::class, 'método'], ['Middleware']);
// Rota GET simples
$router->get('/app/dashboard', [DashboardController::class, 'index']);
// Rota POST com CSRF obrigatório
$router->post('/app/projects/store', [ProjectController::class, 'store'], ['CsrfMiddleware']);
// Rota com parâmetro dinâmico {id}
$router->get('/app/projects/{id}/kanban', [ProjectController::class, 'kanban']);
// ↑ O valor de {id} é passado como $id para o método kanban(int $id)
// Grupo com prefixo e middleware compartilhado
$router->group(['prefix' => '/auth', 'middleware' => ['GuestMiddleware']], function ($router) {
$router->get('/login', [AuthController::class, 'loginForm']);
$router->post('/login', [AuthController::class, 'login'], ['CsrfMiddleware']);
$router->get('/register', [AuthController::class, 'registerForm']);
// Todas as rotas deste grupo herdam o GuestMiddleware
});
Tabela Completa de Rotas
/auth/loginAuthControllerloginForm
/auth/loginAuthControllerlogin
/auth/registerAuthControllerregisterForm
/auth/registerAuthControllerregister
/auth/forgot-passwordAuthControllerforgotForm
/auth/reset-passwordAuthControllerresetForm
/auth/logoutAuthControllerlogout
/app/dashboardDashboardControllerindex
/app/projectsProjectControllerindex
/app/projects/storeProjectControllerstore
/app/projects/{id}/updateProjectControllerupdate
/app/projects/{id}/kanbanProjectControllerkanban
/app/projects/{id}/tasks/createProjectControllercreateTask
/app/tasks/{id}TaskControllershow
/app/tasks/{id}/updateTaskControllerupdate
/app/my-tasksTaskControllermyTasks
/app/reportsReportControllerindex
/app/profileProfileControllerindex
/app/settingsSettingsControllerindex
/app/usersUserControllerindex
/api/update-statusTaskControllerapiUpdateStatus
/api/checklist/addTaskControllerapiAddChecklist
/api/comments/addTaskControllerapiAddComment
/api/comments?task_id={id}&after={id}TaskControllerapiGetComments
/api/attachments/uploadTaskControllerapiUploadAttachment
/api/set-themeTaskControllerapiSetTheme
/api/invite-memberUserControllerapiInviteMember
/api/project-members?project_id={id}UserControllerapiGetProjectMembers
Adicionar Nova Rota
// 1. Adicionar o use no topo do arquivo
use App\Controllers\LabelController;
// 2. Dentro do grupo /app (já tem AuthMiddleware):
$router->group(['prefix' => '/app', 'middleware' => ['AuthMiddleware']], function ($router) {
// ... rotas existentes ...
// Novas rotas para labels
$router->get('/labels', [LabelController::class, 'index']);
$router->post('/labels/store', [LabelController::class, 'store'], ['CsrfMiddleware']);
$router->post('/labels/{id}/delete', [LabelController::class, 'delete'], ['CsrfMiddleware']);
});
Controllers
Controllers orquestram a lógica da aplicação: recebem dados da requisição,
chamam Models, e retornam Views ou JSON. Herdam de App\Core\Controller.
Métodos da Classe Base
| Método | Descrição |
|---|---|
$this->view('modulo.arquivo', $data, 'layout') |
Renderiza view com layout |
$this->redirect('app/dashboard') |
Redireciona para URL relativa |
$this->back() |
Redireciona para a página anterior |
$this->json($data, 200) |
Responde com JSON e código HTTP |
$this->isAuthenticated() |
Verifica se usuário está logado |
Criando um Controller
<?php
namespace App\Controllers;
use App\Core\Controller;
use App\Core\Session;
use App\Models\Label; // Model que você vai criar
class LabelController extends Controller
{
private Label $labelModel;
public function __construct()
{
$this->labelModel = new Label();
}
// GET /app/labels
public function index(): void
{
$userId = currentUserId();
$labels = $this->labelModel->getByUser($userId);
$this->view('labels.index', [
'title' => 'Labels',
'labels' => $labels,
'message' => Session::getFlash('label_msg'),
], 'app'); // 'app' = layout app.php
}
// POST /app/labels/store
public function store(): void
{
$userId = currentUserId();
$name = htmlspecialchars(strip_tags(trim($_POST['name'] ?? '')));
$color = preg_match('/^#[0-9a-fA-F]{6}$/', $_POST['color'] ?? '') ? $_POST['color'] : '#6366f1';
if (!$name) {
Session::flash('label_msg', 'Nome é obrigatório.');
} else {
$this->labelModel->create(['user_id' => $userId, 'name' => $name, 'color' => $color]);
Session::flash('label_msg', 'Label criado com sucesso!');
}
$this->redirect('app/labels');
}
}
Padrão de Resposta JSON (endpoints de API)
public function apiUpdateStatus(): void
{
$userId = currentUserId();
if (!$userId) jsonResponse(false, 'Não autenticado.'); // encerra execução
$input = json_decode(file_get_contents('php://input'), true);
$taskId = (int)($input['task_id'] ?? 0);
$status = $input['status'] ?? '';
$valid = ['todo', 'in_progress', 'review', 'done'];
if (!$taskId || !in_array($status, $valid)) {
jsonResponse(false, 'Dados inválidos.');
}
// ... lógica ...
// Resposta de sucesso com dados extras
jsonResponse(true, 'Status atualizado.', ['new_status' => $status]);
// Gera: {"success":true,"message":"Status atualizado.","new_status":"done"}
}
Models
Models encapsulam toda a interação com o banco de dados. Herdam de
App\Core\Model que fornece CRUD genérico. Métodos específicos são adicionados em
cada Model filho.
Métodos da Classe Base (Model)
$model = new User();
$model->all('name', 'ASC'); // SELECT * FROM users ORDER BY name ASC
$model->find(5); // SELECT * FROM users WHERE id = 5
$model->findBy('email', 'a@b.com'); // SELECT * FROM users WHERE email = ?
$model->where('role', 'admin'); // SELECT * FROM users WHERE role = 'admin'
$model->create(['name'=>'João','email'=>'j@ex.com']); // INSERT (respeita fillable)
$model->update(5, ['name'=>'João Silva']); // UPDATE WHERE id = 5
$model->delete(5); // DELETE WHERE id = 5
// Acesso direto ao Database para queries complexas:
$model->db->query("SELECT ...")->bind(':p', $v)->fetchAll();
Criando um Model
<?php
namespace App\Models;
use App\Core\Model;
class Label extends Model
{
// Nome da tabela no banco
protected string $table = 'labels';
// Campos que podem ser inseridos/atualizados (whitelist de segurança)
protected array $fillable = ['project_id', 'name', 'color'];
// Método customizado — busca labels de um projeto
public function getByProject(int $projectId): array
{
return $this->db
->query("SELECT * FROM {$this->table} WHERE project_id = :pid ORDER BY name ASC")
->bind(':pid', $projectId)
->fetchAll();
}
// Query mais complexa com JOIN
public function getWithTaskCount(int $projectId): array
{
return $this->db->query(
'SELECT l.*, COUNT(tl.task_id) AS task_count
FROM labels l
LEFT JOIN task_labels tl ON tl.label_id = l.id
WHERE l.project_id = :pid
GROUP BY l.id
ORDER BY l.name ASC'
)->bind(':pid', $projectId)->fetchAll();
}
}
Propriedade fillable — Segurança
Sempre defina o fillable
O array $fillable é uma whitelist. Apenas os campos listados podem ser
inseridos/atualizados via create() e update(). Isso evita que
um atacante injete campos como role ou is_admin via formulário
malicioso.
Views & Layouts
Views são arquivos PHP que produzem HTML. O sistema usa dois layouts:
app (com sidebar) para área logada e auth (página centralizada) para
login/registro.
Como Chamar uma View
// Sintaxe: $this->view('pasta.arquivo', $dados, 'layout')
// Os dots (.) são convertidos para / na resolução do path
$this->view('dashboard.index', [
'title' => 'Dashboard',
'stats' => $stats,
'tasks' => $tasks,
], 'app'); // usa Views/layouts/app.php
// Layout auth (login, registro):
$this->view('auth.login', ['title' => 'Login'], 'auth');
// Sem layout (para componentes ou emails):
$this->view('emails.welcome', ['user' => $user], '');
// equivale a: Views/emails/welcome.php renderizado puro
Estrutura de uma View
<?php
// Variáveis injetadas pelo controller via extract($data)
// $title, $labels, $message estão disponíveis diretamente
?>
<div class="page-header">
<h1 class="page-title">Labels</h1>
</div>
<!-- Mensagem flash (sucesso/erro após redirect) -->
<?php if (!empty($message)): ?>
<div class="alert alert-success"><?= e($message) ?></div>
<?php endif; ?>
<!-- Formulário com CSRF obrigatório em todo POST -->
<form method="POST" action="<?= url('app/labels/store') ?>">
<?= csrf_field() ?> <!-- gera <input type="hidden" name="_csrf_token" ...> -->
<input type="text" name="name" required>
<input type="color" name="color" value="#6366f1">
<button type="submit">Criar</button>
</form>
<!-- Loop de dados -->
<?php foreach ($labels as $label): ?>
<div style="background:<?= e($label->color) ?>">
<?= e($label->name) ?> <!-- e() escapa HTML — sempre use em dados do banco -->
</div>
<?php endforeach; ?>
Funções Disponíveis nas Views
| Função | Retorno | Uso |
|---|---|---|
e($string) |
string | Escapa HTML — use sempre em dados do BD |
url('app/dashboard') |
string | URL absoluta a partir do APP_URL |
asset('css/taskflow.css') |
string | URL para arquivo em public/assets/ |
csrf_field() |
HTML | Campo hidden com token CSRF |
flash('chave') |
string|null | Lê e remove mensagem flash da sessão |
currentUserId() |
int | ID do usuário logado |
currentUserName() |
string | Nome do usuário logado (escapado) |
currentTheme() |
string | 'light' ou 'dark' |
priorityLabel('high') |
string | Label traduzido: "Alta" |
statusLabel('in_progress') |
string | Label traduzido: "Em Progresso" |
tfFormatDate('2024-01-15') |
string | Formata para "15/01/2024" |
isOverdue($date, $status) |
bool | True se prazo passou e status != done |
avatarInitials('João Silva') |
string | "JS" |
oldInput('email') |
string | Recupera valor de formulário após erro |
Editar o Layout Principal (app.php)
O layout em app/Views/layouts/app.php define a sidebar, topbar e estrutura da área
logada. A variável $content contém o HTML da view renderizada:
<?php $theme = currentTheme(); ?>
<!DOCTYPE html>
<html data-theme="<?= $theme ?>">
<head>
<title><?= e($title ?? 'TaskFlow Pro') ?></title>
<link rel="stylesheet" href="<?= asset('css/taskflow.css') ?>">
</head>
<body>
<aside class="sidebar">...menu...</aside>
<main class="main-content">
<?= $content ?> <!-- VIEW É INJETADA AQUI -->
</main>
<script>const API_BASE = '<?= rtrim(url(''), '/') . '/' ?>';</script>
<script src="<?= asset('js/taskflow.js') ?>"></script>
</body>
</html>
Middlewares
Middlewares são executados antes do controller e podem bloquear ou
redirecionar a requisição. Ficam em app/Middlewares/ e implementam o método
handle(Request $request).
Middlewares Disponíveis
| Middleware | Quando ativa | O que faz |
|---|---|---|
AuthMiddleware |
Rotas /app/* e /api/* |
Redireciona para login se não autenticado |
GuestMiddleware |
Rotas /auth/* |
Redireciona para dashboard se já logado |
CsrfMiddleware |
Rotas POST com dados de formulário | Valida _csrf_token ou retorna HTTP 419 |
Criando um Novo Middleware
<?php
namespace App\Middlewares;
use App\Core\Request;
use App\Core\Session;
class AdminMiddleware
{
public function handle(Request $request): void
{
// Garante que o usuário é admin
if (Session::get('user_role') !== 'admin') {
Session::flash('error', 'Acesso negado. Você não tem permissão.');
redirect('app/dashboard');
}
}
}
// Rota com múltiplos middlewares
$router->get('/app/admin', [AdminController::class, 'index'], ['AdminMiddleware']);
// Grupo inteiro protegido
$router->group(['prefix' => '/app/admin', 'middleware' => ['AdminMiddleware']], function ($r) {
$r->get('/users', [AdminController::class, 'users']);
$r->get('/settings', [AdminController::class, 'settings']);
});
Autenticação
O sistema usa sessões PHP com proteção contra fixação, CSRF em todos os formulários, e hashing bcrypt para senhas. Sem JWT, sem token stateless.
Fluxo de Login
public function login(): void
{
$email = trim($_POST['email'] ?? '');
$password = $_POST['password'] ?? '';
// User::authenticate() verifica email, active e bcrypt verify
$user = $this->userModel->authenticate($email, $password);
if (!$user) {
Session::flash('error', 'E-mail ou senha incorretos.');
flashOldInput(['email' => $email]); // repopula o formulário
$this->redirect('auth/login');
return;
}
// Regenera ID da sessão (previne Session Fixation Attack)
session_regenerate_id(true);
// Armazena dados na sessão
Session::set('user_id', $user->id);
Session::set('user_name', $user->name);
Session::set('user_role', $user->role);
Session::set('user_theme', $user->theme ?? 'light');
$this->redirect('app/dashboard');
}
Dados da Sessão Disponíveis
| Chave | Tipo | Acesso |
|---|---|---|
user_id |
int | currentUserId() ou $_SESSION['user_id'] |
user_name |
string | currentUserName() |
user_role |
string | $_SESSION['user_role'] |
user_theme |
string | currentTheme() |
Recuperação de Senha
O fluxo gera um token único de 64 hex chars com validade de 1 hora, salvo na tabela
password_resets. Em modo MAIL_DRIVER=dev, o link é exibido na própria
tela em vez de enviado por e-mail — ideal para desenvolvimento.
Ativar envio real de e-mail
Mude MAIL_DRIVER=smtp no .env e configure as credenciais SMTP.
O sistema usa a classe App\Helpers\Mailer que suporta PHPMailer (inclua via
Composer: composer require phpmailer/phpmailer).
Projetos & Kanban
Cada projeto tem um dono (owner), pode ter membros convidados e contém tarefas organizadas em um board Kanban com 4 colunas e drag-and-drop.
Colunas do Kanban
| Valor no BD | Label exibido | Cor |
|---|---|---|
todo |
A Fazer | Cinza |
in_progress |
Em Progresso | Azul |
review |
Em Revisão | Amarelo |
done |
Concluído | Verde |
Drag and Drop — Como Funciona
O drag-and-drop usa a API nativa HTML5 (draggable="true"). Quando um card é solto em
outra coluna, o JS faz uma chamada à API:
// POST /api/update-status
const result = await api(API_BASE + "api/update-status", {
task_id: taskId, // ID da tarefa
status: newStatus, // nova coluna: 'in_progress'
positions: [ // nova ordem de todas as cards na coluna
{ id: 12, position: 0 },
{ id: 8, position: 1 },
]
});
Papéis de Membro no Projeto
| Papel | Criar tarefas | Editar tarefas | Mover no Kanban | Gerenciar membros |
|---|---|---|---|---|
| owner | ✅ | ✅ | ✅ | ✅ |
| editor | ✅ | ✅ | ✅ | ❌ |
| viewer | ❌ | ❌ | ❌ | ❌ |
Tarefas
Cada tarefa pode ter checklist, comentários com reply, anexos, múltiplos responsáveis, notas externas, tags e prazo. A página de detalhe atualiza comentários automaticamente.
Campos da Tabela tasks
| Campo | Tipo | Valores |
|---|---|---|
status |
ENUM | todo | in_progress | review |
done
|
priority |
ENUM | low | medium | high | urgent
|
due_date |
DATE | Prazo (NULL = sem prazo) |
assigned_to |
INT | Responsável primário (FK users) |
tags |
VARCHAR(500) | Tags separadas por vírgula |
position |
INT | Ordem na coluna do Kanban |
Polling de Comentários
A view tasks/show.php inclui um polling automático que busca comentários novos a
cada 20 segundos sem recarregar a página:
// GET /api/comments?task_id=42&after=15
// "after" = ID do último comentário exibido — busca apenas os novos
const res = await fetch(API_BASE + `api/comments?task_id=${TASK_ID}&after=${lastId}`);
const data = await res.json();
if (data.comments.length > 0) {
data.comments.forEach(c => appendComment(c)); // adiciona ao DOM
}
// Indicador visual de sincronização no cabeçalho da seção
Upload de Arquivos
O upload de anexos em tarefas é feito via AJAX com validação de tipo MIME real (não apenas extensão) e nomes aleatórios para evitar conflitos e ataques.
Regras de Upload
- Tamanho máximo: 10 MB por arquivo
- Tipos permitidos: JPG, PNG, GIF, WEBP, PDF, TXT, ZIP, DOCX, XLSX
- O nome salvo em disco é gerado com
bin2hex(random_bytes(12))— ex:a3f9c12e4b56.pdf - O nome original é preservado na tabela
task_attachments.original_name - O tipo MIME é verificado com
finfono servidor (não confia na extensão do cliente) - Arquivos ficam em
public/uploads/com.htaccessque bloqueia execução de PHP
// 1. Verificar tamanho
if ($fileSize > 10 * 1024 * 1024) jsonResponse(false, 'Arquivo excede 10 MB.');
// 2. Verificar tipo MIME real com finfo (ignora extensão)
$allowed = ['image/jpeg', 'image/png', 'application/pdf', /* ... */];
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$mimeType = $finfo->file($tmpPath);
if (!in_array($mimeType, $allowed, true)) {
jsonResponse(false, 'Tipo de arquivo não permitido.');
}
// 3. Nome aleatório — evita conflito e path traversal
$ext = strtolower(pathinfo($origName, PATHINFO_EXTENSION));
$safeName = bin2hex(random_bytes(12)) . ($ext ? '.' . $ext : '');
// 4. Mover para pasta com .htaccess que bloqueia execução PHP
move_uploaded_file($tmpPath, PUBLIC_PATH . '/uploads/' . $safeName);
API JSON Interna
O sistema expõe endpoints JSON em /api/* usados pelo frontend
JavaScript para operações assíncronas. Todos requerem sessão ativa (AuthMiddleware). Não há CSRF
nessas rotas pois usam Content-Type: application/json.
Formato de Resposta Padrão
// Sucesso
{ "success": true, "message": "Tarefa atualizada.", "new_status": "done" }
// Erro
{ "success": false, "message": "Dados inválidos." }
Fazendo uma Chamada da API
// A função api() já está disponível globalmente no taskflow.js
// POST com corpo JSON
const result = await api(API_BASE + 'api/update-status', {
task_id: 42,
status: 'done'
});
if (result.success) Toast.success(result.message);
else Toast.error(result.message);
// GET com query string
const data = await api(API_BASE + 'api/comments?task_id=42&after=0', null, 'GET');
console.log(data.comments);
// Upload de arquivo (multipart/form-data)
const form = new FormData();
form.append('task_id', 42);
form.append('file', fileInput.files[0]);
const r = await apiForm(API_BASE + 'api/attachments/upload', form);
Referência Completa dos Endpoints
| Método | Endpoint | Body / Params | Descrição |
|---|---|---|---|
| POST | /api/update-status |
{task_id, status, positions[]} |
Atualiza status e ordem no kanban |
| POST | /api/checklist/add |
{task_id, item} |
Adiciona item ao checklist |
| POST | /api/checklist/toggle |
{id, is_done} |
Marca/desmarca item |
| POST | /api/checklist/delete |
{id} |
Remove item |
| POST | /api/comments/add |
{task_id, content, parent_id?} |
Novo comentário |
| GET | /api/comments |
?task_id=N&after=N |
Polling de novos comentários |
| POST | /api/attachments/upload |
FormData {task_id, file} |
Upload de arquivo |
| POST | /api/attachments/delete |
{id} |
Remove anexo |
| POST | /api/set-theme |
{theme: 'dark'|'light'} |
Salva tema na sessão e BD |
| POST | /api/invite-member |
{project_id, email, role} |
Envia convite |
| GET | /api/project-members |
?project_id=N |
Lista membros do projeto |
| POST | /api/project-members-manage |
{action, project_id, user_id, role?} |
add/remove/update_role |
| GET | /api/task-assignees |
?task_id=N |
IDs dos responsáveis da tarefa |
| POST | /api/delete-external-note |
{note_id} |
Remove observação externa |
CSS & Temas
Todo o CSS fica em public/assets/css/taskflow.css. O tema
dark/light é implementado com variáveis CSS no seletor [data-theme] sem nenhuma
dependência externa.
Variáveis CSS Principais
:root { /* Tema claro (padrão) */
--accent: #6366f1; /* Cor principal — roxo índigo */
--accent-light: rgba(99,102,241,0.12);
--success: #10b981; /* Verde */
--warning: #f59e0b; /* Amarelo */
--danger: #ef4444; /* Vermelho */
--bg: #f4f5f9; /* Fundo da página */
--surface: #ffffff; /* Fundo de cards */
--surface2: #f0f1f6; /* Hover, inputs */
--border: rgba(0,0,0,0.07); /* Bordas */
--text-primary: #111827; /* Texto principal */
--text-muted: #9ca3af; /* Texto secundário */
--radius: 10px; /* Border radius padrão */
}
[data-theme="dark"] { /* Sobrescreve apenas as cores */
--bg: #0b0d14;
--surface: #13161f;
--surface2: #1a1d2a;
--border: rgba(255,255,255,0.07);
--text-primary: #e8eaf2;
--text-muted: #636880;
}
Alterando as Cores do Sistema
Para mudar a cor de destaque do sistema inteiro, basta alterar --accent no
:root:
/* Mudar para verde */
:root { --accent: #059669; --accent2: #047857; }
/* Mudar para azul */
:root { --accent: #2563eb; --accent2: #1d4ed8; }
/* Mudar para laranja */
:root { --accent: #ea580c; --accent2: #c2410c; }
Classes de Componentes
| Classe | Uso |
|---|---|
.btn .btn-primary |
Botão com cor de destaque |
.btn .btn-secondary |
Botão neutro |
.btn .btn-danger |
Botão vermelho (ações destrutivas) |
.btn .btn-sm |
Botão pequeno |
.card .card-body |
Container com borda e fundo |
.card-header-bar |
Cabeçalho de card com título e ação |
.modal-overlay |
Overlay de modal (usa classe .open) |
.form-control |
Input, select, textarea |
.form-group |
Agrupamento de label + input |
.form-label |
Label de campo de formulário |
.alert .alert-success |
Mensagem de sucesso |
.alert .alert-danger |
Mensagem de erro |
.badge .badge-high |
Badge de prioridade (low/medium/high/urgent) |
.status-pill .status-done |
Pill de status (todo/in_progress/review/done) |
.table-wrap table |
Tabela responsiva com hover |
.page-header .page-title |
Cabeçalho de página |
.empty-state |
Estado vazio com ícone e mensagem |
JavaScript
Todo o JS fica em public/assets/js/taskflow.js. Não usa jQuery
nem frameworks. As funções são organizadas em módulos IIFE e expostas globalmente no
window.
Objetos Globais Disponíveis nas Views
// ── Toast — notificações não-bloqueantes ─────────────────────────
Toast.success('Projeto criado com sucesso!');
Toast.error('Erro ao salvar. Tente novamente.');
Toast.info('Funcionalidade em beta.');
// ── Modal — modais com overlay ───────────────────────────────────
Modal.open('createProjectModal'); // abre modal pelo ID
Modal.close('createProjectModal'); // fecha modal específico
Modal.closeAll(); // fecha todos
// ── Confirm — diálogo de confirmação assíncrono ──────────────────
const ok = await Confirm.open('Deseja excluir esta tarefa?');
// ok = true se o usuário confirmou, false se cancelou
if (ok) {
document.getElementById('deleteForm').submit();
}
// ── API_BASE — base URL injetada pelo layout ─────────────────────
// Disponível em todas as páginas do app:
console.log(API_BASE); // "http://localhost/taskflow-mvc/public/"
Wiring Automático de Modais
Qualquer elemento com data-open-modal ou data-close-modal funciona
automaticamente sem escrever JS adicional:
<!-- Abre o modal -->
<button data-open-modal="meuModal">Abrir</button>
<!-- Fecha o modal -->
<button data-close-modal="meuModal">Cancelar</button>
<!-- O modal -->
<div class="modal-overlay" id="meuModal">
<div class="modal">
<div class="modal-header">
<span class="modal-title">Título</span>
<button class="modal-close" data-close-modal="meuModal">✕</button>
</div>
<div class="modal-body">Conteúdo</div>
<div class="modal-footer">...</div>
</div>
</div>
Helpers & Funções
O arquivo app/Helpers/functions.php é carregado automaticamente
pelo Composer em toda requisição. Todas as funções estão no escopo global.
Referência Completa
// ── URLs ──────────────────────────────────────────────────────────
url('app/dashboard') // http://localhost/taskflow-mvc/public/app/dashboard
asset('css/taskflow.css') // http://localhost/.../public/assets/css/taskflow.css
// ── HTML Seguro ───────────────────────────────────────────────────
e('<script>alert(1)</script>') // <script>... → previne XSS
// ── Sessão ───────────────────────────────────────────────────────
currentUserId() // (int) ID do usuário logado, ou 0
currentUserName() // (string) Nome escapado, ou ''
currentTheme() // 'light' | 'dark'
auth() // (bool) true se logado
user() // (object|null) dados completos do usuário
// ── Flash Messages ────────────────────────────────────────────────
flash('success') // lê e remove da sessão
hasFlash('error') // verifica sem remover
flashOldInput(['email' => $email]) // salva inputs para repopular form
oldInput('email') // recupera input salvo (lê e limpa)
// ── CSRF ─────────────────────────────────────────────────────────
csrf_field() // <input type="hidden" name="_csrf_token" value="abc...">
csrf_token() // retorna apenas o valor do token
// ── Formatação ───────────────────────────────────────────────────
dateBR('2024-01-15') // '15/01/2024'
dateTimeBR('2024-01-15 14:30') // '15/01/2024 14:30'
truncate($text, 100, '...') // Trunca com sufixo
// ── TaskFlow específico ───────────────────────────────────────────
priorityLabel('high') // 'Alta'
statusLabel('in_progress') // 'Em Progresso'
tfFormatDate('2024-01-15') // '15/01/2024'
isOverdue('2024-01-01', 'todo') // true
avatarInitials('João Silva') // 'JS'
// ── Debug ─────────────────────────────────────────────────────────
dd($variavel, $outra) // dump e die com output formatado
// ── API ───────────────────────────────────────────────────────────
// Retorna JSON e encerra execução:
jsonResponse(true, 'OK', ['data' => $dados]);
jsonResponse(false, 'Erro de validação.');
// ── Redirect ─────────────────────────────────────────────────────
redirect('auth/login'); // header Location + exit
Adicionando Novos Helpers
Adicione funções diretamente no final de app/Helpers/functions.php. Estarão
disponíveis globalmente sem necessidade de use ou require:
// app/Helpers/functions.php — adicionar ao final
/**
* Formata bytes em formato legível (KB, MB, GB)
*/
function formatBytes(int $bytes, int $precision = 1): string
{
$units = ['B', 'KB', 'MB', 'GB'];
$bytes = max($bytes, 0);
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
$pow = min($pow, count($units) - 1);
return round($bytes / 1024 ** $pow, $precision) . ' ' . $units[$pow];
}
// Uso em qualquer view:
// formatBytes(1536); // "1.5 KB"
Criar Nova Página
Adicionar uma nova página ao sistema requer apenas 3 passos: criar a view, criar o método no controller, e registrar a rota.
Criar a View
Crie o arquivo em app/Views/[modulo]/[pagina].php:
<div class="page-header">
<h1 class="page-title">Estatísticas do Kanban</h1>
</div>
<div class="card card-body">
<p>Total de tarefas: <strong><?= (int)$totalTasks ?></strong></p>
<p>Concluídas hoje: <strong><?= (int)$doneTodayCount ?></strong></p>
</div>
Adicionar o método no Controller
public function stats(int $projectId): void
{
$userId = currentUserId();
$project = $this->projectModel->getProjectForUser($projectId, $userId);
if (!$project) { $this->redirect('app/projects'); return; }
$totalTasks = count($this->taskModel->getByProject($projectId));
$doneTodayCount = /* query ... */ 3;
$this->view('kanban.stats', [
'title' => 'Estatísticas',
'project' => $project,
'totalTasks' => $totalTasks,
'doneTodayCount' => $doneTodayCount,
], 'app');
}
Registrar a rota
// Dentro do grupo /app com AuthMiddleware:
$router->get('/projects/{id}/stats', [ProjectController::class, 'stats']);
Pronto. Acesse /app/projects/1/stats.
Criar Novo Módulo
Um módulo completo consiste em: tabela no BD → Model → Controller → Views → Rotas → Link no menu.
Checklist de Criação de Módulo
| # | Etapa | Arquivo |
|---|---|---|
| 1 | Criar tabela SQL | database/migrations/00N_nome.sql |
| 2 | Criar Model | app/Models/NomeModel.php |
| 3 | Criar Controller | app/Controllers/NomeController.php |
| 4 | Criar Views | app/Views/modulo/index.php, etc. |
| 5 | Registrar rotas | routes/web.php |
| 6 | Adicionar classmap | vendor/composer/autoload_classmap.php |
| 7 | Link no sidebar | app/Views/layouts/app.php |
Adicionando Link no Menu Lateral
<!-- Adicionar dentro do bloco de navegação -->
<a href="<?= url('app/labels') ?>"
class="nav-item <?= str_contains($uri, '/app/labels') ? 'active' : '' ?>">
<span class="nav-icon"><i class="fas fa-tag"></i></span>
<span class="nav-text">Labels</span>
</a>
Atualizando o autoload_classmap.php
O PSR-4 já resolve automaticamente novos arquivos — o classmap é apenas uma otimização. Se preferir, adicione manualmente:
'App\\Controllers\\LabelController' => $baseDir . '/app/Controllers/LabelController.php',
'App\\Models\\Label' => $baseDir . '/app/Models/Label.php',
Segurança do Sistema
O TaskFlow implementa múltiplas camadas de proteção seguindo as principais recomendações do OWASP.
bind() com PDO prepared
statements. Nenhum valor é concatenado na query.e() =
htmlspecialchars() com UTF-8. JSON usa json_encode.
hash_equals() em todo POST de
formulário. Regenerado a cada login.password_hash(). Verificação com
password_verify().
finfo, nomes aleatórios,
.htaccess bloqueando PHP na pasta de uploads.
basename() no nome original, nome salvo gerado com
random_bytes().
Headers de Segurança HTTP
// Enviados em toda requisição via Application::__construct()
header("X-Content-Type-Options: nosniff");
header("X-Frame-Options: SAMEORIGIN");
header("X-XSS-Protection: 1; mode=block");
header("Referrer-Policy: strict-origin-when-cross-origin");
Deploy em Produção
VPS com Apache (Ubuntu/Debian)
<VirtualHost *:443>
ServerName taskflow.seudominio.com
DocumentRoot /var/www/taskflow-mvc/public
<Directory /var/www/taskflow-mvc/public>
AllowOverride All
Require all granted
Options -Indexes
</Directory>
# SSL (Certbot / Let's Encrypt)
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/taskflow.seudominio.com/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/taskflow.seudominio.com/privkey.pem
# Logs
ErrorLog /var/log/apache2/taskflow-error.log
CustomLog /var/log/apache2/taskflow-access.log combined
</VirtualHost>
Checklist de Produção
APP_ENV=production
APP_DEBUG=false # CRÍTICO: nunca true em produção
APP_URL=https://taskflow.seudominio.com
SESSION_SECURE=true # Exige HTTPS para cookie de sessão
MAIL_DRIVER=smtp # Envio real de e-mails
APP_DEBUG=false em produção
Com APP_DEBUG=true em produção, erros PHP expõem paths, SQL queries e
variáveis de configuração ao usuário final. Sempre use false.
Permissões em Produção
chown -R www-data:www-data /var/www/taskflow-mvc/
chmod -R 755 /var/www/taskflow-mvc/
chmod -R 775 /var/www/taskflow-mvc/storage/
chmod -R 775 /var/www/taskflow-mvc/public/uploads/
# Proteger o .env
chmod 600 /var/www/taskflow-mvc/.env
Boas Práticas
Regras de Ouro do Projeto
- Sempre use
e()ao exibir dados do banco — sem exceções, mesmo para inteiros - Sempre use
bind()nas queries — nunca concatene valores na SQL - Sempre inclua
csrf_field()em formulários POST - Mantenha Controllers finos — lógica complexa vai nos Models
- Valide dados no Controller antes de passar ao Model
- Use
Session::flash()+ redirect após POST (padrão PRG) - Defina
$fillableem todo Model — whitelist obrigatória - Nomeie métodos de API com prefixo
api— ex:apiAddComment() - Use
htmlspecialchars(strip_tags(trim(...)))em inputs de texto antes de salvar - Nunca commite o
.env— use o.env.examplecomo documentação
Padrão PRG (Post/Redirect/Get)
Todo POST que altera dados deve terminar com redirect, nunca renderizar view diretamente:
// ✅ CORRETO — PRG pattern
public function store(): void
{
// processa...
Session::flash('project_msg', 'Projeto criado!');
$this->redirect('app/projects'); // ← sempre redireciona
}
// ❌ ERRADO — renderiza após POST (causa resubmit no F5)
public function store(): void
{
// processa...
$this->view('projects.index', $data); // ← nunca faça isso em POST
}
Solução de Problemas
Problemas Comuns
🔴 "Página em branco" ou 404 em todas as rotas
- Verifique se
mod_rewriteestá ativo:sudo a2enmod rewrite - Verifique se
AllowOverride Allestá no VirtualHost - Confirme que o
APP_URLno.envnão tem barra final - Certifique-se de acessar
.../public/, não a raiz do projeto
🔴 Erro 419 — Token CSRF inválido
- Certifique-se de incluir
<?= csrf_field() ?>em todo formulário POST - Se a sessão expirou, o token fica inválido — faça logout e login novamente
- Verifique se a pasta
storage/sessions/tem permissão de escrita
🔴 Erro de conexão ao banco de dados
# Verificar logs de BD
cat storage/logs/db-$(date +%Y-%m-%d).log
# Testar conexão manualmente
mysql -u root -p taskflow_db -e "SELECT 1;"
🔴 JS não funciona (botões, modais, drag-and-drop)
- Abra o console do browser (F12) e verifique erros JavaScript
- O erro mais comum é redeclaração de
const API_BASE— verifique se háconst API_BASEem alguma view além do layout - Certifique-se de que
public/assets/js/taskflow.jscarrega sem erros (aba Network do DevTools)
🔴 Upload de arquivo não funciona
- Verifique permissões:
chmod 775 public/uploads/ - Verifique o
php.ini:upload_max_filesizeepost_max_sizedevem ser ≥ 10M - Verifique se a extensão
fileinfoestá ativa no PHP
🔴 Erro "View não encontrada"
- Verifique se o path usa pontos:
'kanban.index'→Views/kanban/index.php - Verifique se o arquivo existe com o nome exato (case-sensitive no Linux)
Ativando Logs Detalhados
# .env
APP_DEBUG=true # Exibe stack trace completo na tela
# Verificar logs de aplicação
tail -f storage/logs/error-$(date +%Y-%m-%d).log
Perguntas Frequentes
Sim. O ZIP já inclui a pasta vendor/ completa. Compositor é necessário
apenas se você quiser adicionar novas dependências ou reinstalar do zero.
Configure no .env: MAIL_DRIVER=smtp com suas credenciais SMTP.
O sistema usa a classe App\Helpers\Mailer. Para Gmail, ative a autenticação
de dois fatores e gere uma "Senha de App". Para usar PHPMailer completo, adicione:
composer require phpmailer/phpmailer.
Altere em duas partes: (1) no TaskController::apiUploadAttachment(), mude a
constante 10 * 1024 * 1024; (2) no php.ini, ajuste
upload_max_filesize e post_max_size. Em XAMPP, o
php.ini fica em C:\xampp\php\php.ini.
1. Altere o ENUM na tabela users.role no BD:
ALTER TABLE users MODIFY role ENUM('admin','member','gerente') DEFAULT 'member';
2.
Crie um GerenciadorMiddleware em app/Middlewares/
3.
Aplique o middleware nas rotas restritas
4. Atualize a lógica de exibição nas views
onde necessário.
Sim. Coloque a pasta em C:\xampp\htdocs\taskflow-mvc\ e configure
APP_URL=http://localhost/taskflow-mvc/public no .env.
Certifique-se de habilitar mod_rewrite no XAMPP (Apache > Config >
httpd.conf: descomente a linha LoadModule rewrite_module).
O tema é salvo na coluna users.theme no banco de dados e na variável de
sessão $_SESSION['user_theme']. O botão de tema chama o endpoint
POST /api/set-theme que atualiza ambos. O data-theme no
<html> é definido pelo PHP no carregamento da página, então não há
flash de tema.
1. Adicione a rota em routes/web.php no grupo /api
2. Crie o método no controller (com prefixo api)
3. Use jsonResponse() para retornar:
// routes/web.php
$router->get('/api/my-endpoint', [MyController::class, 'apiGetData']);
// MyController.php
public function apiGetData(): void {
$data = ['items' => [1, 2, 3]];
jsonResponse(true, 'OK', $data);
}
1. Adicione a coluna na tabela users:
ALTER TABLE users ADD COLUMN phone VARCHAR(20) DEFAULT NULL;
2. Adicione
o campo ao array $fillable do User model
3. Adicione o
input na view profile/index.php
4. Trate o campo no
ProfileController::update()
Atualizar o Sistema
Adicionando uma Funcionalidade
Banco de dados
Crie um novo arquivo SQL em database/migrations/ com o número
sequencial. Execute no banco.
Model & Controller
Adicione métodos ao Model existente ou crie um novo. Adicione métodos ao Controller existente ou crie um novo.
Views & Rotas
Crie as views necessárias e registre as rotas em routes/web.php.
Testar
Teste o fluxo completo: criação, edição, exclusão, validações, permissões e erros.
Backup Antes de Atualizar em Produção
# Backup do banco
mysqldump -u root -p taskflow_db > backup_$(date +%Y%m%d_%H%M%S).sql
# Backup dos uploads
tar -czf uploads_backup_$(date +%Y%m%d).tar.gz public/uploads/
# Backup do .env
cp .env .env.backup.$(date +%Y%m%d)
Autoload & Composer
O projeto usa o autoloader PSR-4 do Composer para carregar classes
automaticamente. Apenas uma dependência externa é necessária: vlucas/phpdotenv.
Como o Autoload Funciona
O Composer mapeia o namespace App\ para a pasta app/. Isso significa
que qualquer classe com namespace App\Controllers\MeuController é carregada
automaticamente de app/Controllers/MeuController.php — sem necessidade de
require manual.
{
"name": "taskflow/taskflow-mvc",
"require": {
"php": ">=8.1",
"vlucas/phpdotenv": "^5.5"
},
"autoload": {
"psr-4": {
"App\\": "app/"
},
"files": [
"app/Helpers/functions.php"
]
}
}
Dois mecanismos de autoload
PSR-4 carrega classes por namespace. files carrega
functions.php automaticamente em cada requisição — por isso as funções
globais como url(), e(), flash() estão sempre
disponíveis sem use.
Mapeamento PSR-4
| Namespace | Pasta | Exemplo |
|---|---|---|
App\Controllers\ |
app/Controllers/ |
App\Controllers\TaskController |
App\Models\ |
app/Models/ |
App\Models\Task |
App\Core\ |
app/Core/ |
App\Core\Database |
App\Middlewares\ |
app/Middlewares/ |
App\Middlewares\AuthMiddleware |
App\Helpers\ |
app/Helpers/ |
App\Helpers\TaskflowHelper |
Adicionando Novas Dependências
# Instalar PHPMailer para envio real de e-mails
composer require phpmailer/phpmailer
# Instalar uma biblioteca de exportação Excel
composer require phpoffice/phpspreadsheet
# Após qualquer alteração manual no composer.json
composer dump-autoload -o
Regenerar o Classmap (otimização)
O arquivo vendor/composer/autoload_classmap.php mapeia explicitamente cada classe
para seu arquivo. Após criar novos controllers ou models, atualize-o:
composer dump-autoload --optimize
# Ou manualmente: adicionar a entrada em autoload_classmap.php
<?php
return array(
// ... entradas existentes ...
'App\\Controllers\\LabelController' => $baseDir . '/app/Controllers/LabelController.php',
'App\\Models\\Label' => $baseDir . '/app/Models/Label.php',
);
Membros & Convites
O sistema de convites permite convidar colaboradores externos por e-mail. O convidado recebe um link único com validade de 7 dias. O dono do projeto tem controle total sobre papéis e remoção.
Fluxo de Convite
Tabelas Envolvidas
| Tabela | Campo | Descrição |
|---|---|---|
project_invites |
token |
64 chars hex, único, expira em 7 dias |
project_invites |
status |
pending | accepted | cancelled |
project_invites |
role |
editor | viewer |
project_members |
role |
owner | editor | viewer |
Modo Desenvolvimento (sem SMTP)
Com MAIL_DRIVER=dev no .env, o link de aceite aparece na tela do modal
de convites em vez de ser enviado por e-mail. Isso permite testar todo o fluxo localmente:
{
"success": true,
"message": "Convite enviado para colaborador@email.com.",
"invite_token": "a3f9c12e4b56...",
"dev_url": "http://localhost/taskflow-mvc/public/app/accept-invite?token=a3f9c..."
}
Gerenciar Membros via API
// Adicionar membro existente diretamente
await api(API_BASE + 'api/project-members-manage', {
action: 'add',
project_id: 5,
user_id: 12,
role: 'editor'
});
// Remover membro
await api(API_BASE + 'api/project-members-manage', {
action: 'remove',
project_id: 5,
user_id: 12
});
// Alterar papel
await api(API_BASE + 'api/project-members-manage', {
action: 'update_role',
project_id: 5,
user_id: 12,
role: 'viewer'
});
Responsividade
O TaskFlow Pro é totalmente responsivo usando CSS Grid, Flexbox e media queries sem nenhuma dependência de framework CSS. A sidebar usa um sistema off-canvas em mobile.
Breakpoints
| Breakpoint | Dispositivo | Comportamento |
|---|---|---|
> 1200px |
Desktop grande | Layout padrão, sidebar fixa expandida |
900px–1200px |
Desktop médio | Sidebar ainda visível, padding reduzido |
≤ 900px |
Tablet / Mobile | Sidebar off-canvas, hamburger menu visível |
≤ 768px |
Mobile | Kanban empilhado, forms em coluna única |
Sidebar Off-Canvas (Mobile)
Em telas ≤ 900px, a sidebar fica escondida com transform: translateX(-100%). O botão
hamburger (id="mobileMenuBtn") aciona a classe .open via JS:
@media (max-width: 768px) {
.sidebar {
transform: translateX(-100%); /* escondida por padrão */
transition: transform .18s ease;
position: fixed; z-index: 90;
}
.sidebar.open {
transform: translateX(0); /* visível quando JS adiciona .open */
box-shadow: 0 0 40px rgba(0,0,0,0.4);
}
.main-content { margin-left: 0; } /* sem margem em mobile */
}
Sidebar Colapsável (Desktop)
Em desktop, o botão mobileMenuBtn colapsa a sidebar para apenas ícones (64px),
preservando espaço na tela. O estado é salvo no localStorage:
/* Quando .app-layout tem a classe .sidebar-collapsed: */
.app-layout.sidebar-collapsed .sidebar { width: 64px; overflow: hidden; }
.app-layout.sidebar-collapsed .sidebar .nav-text,
.app-layout.sidebar-collapsed .sidebar .logo-text { display: none; }
.app-layout.sidebar-collapsed .main-content { margin-left: 64px; }
Kanban em Mobile
O board Kanban em telas pequenas empilha as colunas verticalmente. A coluna "Concluído" começa colapsada para economizar espaço:
@media (max-width: 768px) {
.kanban-wrapper {
flex-direction: column; /* empilha colunas */
overflow-x: visible;
}
.kanban-col {
min-width: unset;
width: 100%;
}
}
Grid de Projetos Responsivo
/* auto-fit adapta automaticamente o número de colunas */
.projects-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 18px;
}
/* 3 colunas em desktop, 2 em tablet, 1 em mobile — automático */
Testando a Responsividade
- Use o DevTools do browser (F12 → ícone de dispositivo móvel)
- Teste com os breakpoints: 375px (iPhone), 768px (iPad), 1024px (tablet landscape)
- Verifique se formulários, modais e tabelas não quebram o layout horizontal
- Confirme que o kanban é usável com toque (drag-and-drop funciona em touch devices via HTML5)
Nova Tabela no Banco de Dados
Guia completo para adicionar uma nova tabela ao sistema, desde o SQL até a integração com Model e Controller.
Convenções de Nomenclatura
| Item | Convenção | Exemplo |
|---|---|---|
| Nome da tabela | snake_case, plural |
task_labels |
| Chave primária | id INT AUTO_INCREMENT |
id |
| Chaves estrangeiras | tabela_id |
task_id, user_id |
| Timestamps | created_at, updated_at |
TIMESTAMP DEFAULT CURRENT_TIMESTAMP |
| Booleanos | TINYINT(1) |
is_done TINYINT(1) DEFAULT 0 |
| Enums | valores em inglês, minúsculo | ENUM('low','medium','high') |
Exemplo Completo — Tabela de Labels
1. Criar o arquivo de migration SQL
USE taskflow_db;
-- Tabela de labels (etiquetas coloridas para tarefas)
CREATE TABLE IF NOT EXISTS labels (
id INT PRIMARY KEY AUTO_INCREMENT,
project_id INT NOT NULL,
name VARCHAR(80) NOT NULL,
color VARCHAR(7) DEFAULT '#6366f1',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
) ENGINE=InnoDB;
-- Tabela pivot tarefa-label (relação N:N)
CREATE TABLE IF NOT EXISTS task_labels (
task_id INT NOT NULL,
label_id INT NOT NULL,
PRIMARY KEY (task_id, label_id),
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
FOREIGN KEY (label_id) REFERENCES labels(id) ON DELETE CASCADE
) ENGINE=InnoDB;
-- Índices para performance
CREATE INDEX idx_labels_project ON labels(project_id);
CREATE INDEX idx_task_labels_task ON task_labels(task_id);
CREATE INDEX idx_task_labels_label ON task_labels(label_id);
ON DELETE CASCADE
Use ON DELETE CASCADE em chaves estrangeiras de tabelas dependentes para que
os dados relacionados sejam removidos automaticamente quando o registro pai for
excluído. Evita registros órfãos.
2. Criar o Model
<?php
namespace App\Models;
use App\Core\Model;
class Label extends Model
{
protected string $table = 'labels';
protected array $fillable = ['project_id', 'name', 'color'];
public function getByProject(int $projectId): array
{
return $this->db
->query("SELECT * FROM {$this->table} WHERE project_id = :pid ORDER BY name")
->bind(':pid', $projectId)
->fetchAll();
}
public function attachToTask(int $taskId, int $labelId): void
{
$this->db->query("INSERT IGNORE INTO task_labels (task_id, label_id) VALUES (:tid, :lid)")
->bind(':tid', $taskId)->bind(':lid', $labelId)->execute();
}
public function detachFromTask(int $taskId, int $labelId): void
{
$this->db->query("DELETE FROM task_labels WHERE task_id = :tid AND label_id = :lid")
->bind(':tid', $taskId)->bind(':lid', $labelId)->execute();
}
public function getForTask(int $taskId): array
{
return $this->db->query(
'SELECT l.* FROM labels l
JOIN task_labels tl ON tl.label_id = l.id
WHERE tl.task_id = :tid ORDER BY l.name'
)->bind(':tid', $taskId)->fetchAll();
}
}
3. Atualizar o autoload_classmap.php
// vendor/composer/autoload_classmap.php — adicionar:
'App\\Models\\Label' => $baseDir . '/app/Models/Label.php',
4. Usar no Controller
use App\Models\Label;
// No método do controller:
$labelModel = new Label();
$labels = $labelModel->getByProject($projectId);
$taskLabels = $labelModel->getForTask($taskId);