Chapter 34: Building a Complete App#
1. Putting It All Together#
Twenty chapters of building blocks. Routing. Templates. Databases. ORM. Authentication. Middleware. Queues. WebSocket. Caching. Frontend. GraphQL. Testing. Dev tools. CLI scaffolding. Deployment. Now all of it works together in one application.
TaskFlow -- a task management system with:
- User registration and JWT authentication
- Task creation, assignment, and tracking
- A dashboard with real-time updates
- Email notifications when tasks are assigned
- Caching for dashboard performance
- A full test suite
- Docker deployment
Not a toy project. A production-ready application. Every major Tina4 feature in one codebase.
2. Planning the App#
Models#
| Model | Table | Fields |
|---|---|---|
| User | users | id, name, email, passwordHash, role, createdAt |
| Task | tasks | id, title, description, status, priority, createdBy, assignedTo, dueDate, completedAt, createdAt, updatedAt |
Relationships#
- A User has many Tasks (created by them)
- A User has many Tasks (assigned to them)
- A Task belongs to a User (creator)
- A Task belongs to a User (assignee)
Routes#
| Method | Path | Description | Auth |
|---|---|---|---|
| POST | /api/auth/register | Register a new user | public |
| POST | /api/auth/login | Login, get JWT | public |
| GET | /api/profile | Get current user profile | secured |
| GET | /api/tasks | List tasks (with filters) | secured |
| GET | /api/tasks/{id} | Get a single task | secured |
| POST | /api/tasks | Create a task | secured |
| PUT | /api/tasks/{id} | Update a task | secured |
| DELETE | /api/tasks/{id} | Delete a task | secured |
| GET | /api/dashboard/stats | Dashboard statistics | secured |
| GET | /admin | Dashboard HTML page | public |
Templates#
base.html-- Base layout with sidebar and topbardashboard.html-- Dashboard with stats, task list, quick actionslogin.html-- Login pageregister.html-- Registration page
3. Step 1: Init Project and Set Up Database#
tina4 init taskflowcd taskflowcomposer installUpdate .env:
TINA4_DEBUG=trueJWT_SECRET=taskflow-dev-secret-change-in-productionJWT_EXPIRY=86400Create Migrations#
Create migrations/20260322000100_create_users_table.sql:
-- UPCREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, email TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'user', created_at TEXT DEFAULT CURRENT_TIMESTAMP);โCREATE INDEX idx_users_email ON users(email);โ-- DOWNDROP TABLE IF EXISTS users;Create migrations/20260322000200_create_tasks_table.sql:
-- UPCREATE TABLE tasks ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, description TEXT DEFAULT '', status TEXT NOT NULL DEFAULT 'todo', priority TEXT NOT NULL DEFAULT 'medium', created_by INTEGER NOT NULL, assigned_to INTEGER, due_date TEXT, completed_at TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP, updated_at TEXT DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (created_by) REFERENCES users(id), FOREIGN KEY (assigned_to) REFERENCES users(id));โCREATE INDEX idx_tasks_status ON tasks(status);CREATE INDEX idx_tasks_assigned_to ON tasks(assigned_to);CREATE INDEX idx_tasks_created_by ON tasks(created_by);โ-- DOWNDROP TABLE IF EXISTS tasks;Run the migrations:
tina4 migrateRunning migrations... [APPLIED] 20260322000100_create_users_table.sql [APPLIED] 20260322000200_create_tasks_table.sqlMigrations complete. 2 applied.4. Step 2: User Model with Registration and Login#
Create src/orm/User.php:
<?phpuse Tina4\ORM;โclass User extends ORM{ public int $id; public string $name; public string $email; public string $passwordHash; public string $role = "user"; public string $createdAt;โ public string $tableName = "users"; public string $primaryKey = "id";โ /** * Get tasks created by this user */ public function createdTasks(): array { return $this->hasMany(Task::class, "created_by"); }โ /** * Get tasks assigned to this user */ public function assignedTasks(): array { return $this->hasMany(Task::class, "assigned_to"); }โ /** * Hash a password */ public static function hashPassword(string $password): string { return password_hash($password, PASSWORD_DEFAULT); }โ /** * Verify a password against the stored hash */ public function verifyPassword(string $password): bool { return password_verify($password, $this->passwordHash); }โ /** * Convert to dict without the password hash */ public function toSafeDict(): array { $dict = $this->toArray(); unset($dict["password_hash"]); return $dict; }}Authentication Routes#
Create src/routes/auth.php:
<?phpuse Tina4\Router;use Tina4\Auth;โ/** * @noauth */Router::post("/api/auth/register", function ($request, $response) { $body = $request->body;โ // Validate input if (empty($body["name"]) || empty($body["email"]) || empty($body["password"])) { return $response->json(["error" => "name, email, and password are required"], 400); }โ if (strlen($body["password"]) < 8) { return $response->json(["error" => "Password must be at least 8 characters"], 400); }โ if (!filter_var($body["email"], FILTER_VALIDATE_EMAIL)) { return $response->json(["error" => "Invalid email address"], 400); }โ // Check for existing user $existing = new User(); $found = $existing->select("*", "email = :email", ["email" => $body["email"]]); if (count($found) > 0) { return $response->json(["error" => "Email already registered"], 409); }โ // Create user $user = new User(); $user->name = $body["name"]; $user->email = $body["email"]; $user->passwordHash = User::hashPassword($body["password"]); $user->role = "user"; $user->save();โ return $response->json($user->toSafeDict(), 201);});โ/** * @noauth */Router::post("/api/auth/login", function ($request, $response) { $body = $request->body;โ if (empty($body["email"]) || empty($body["password"])) { return $response->json(["error" => "email and password are required"], 400); }โ // Find user by email $user = new User(); $found = $user->select("*", "email = :email", ["email" => $body["email"]]);โ if (count($found) === 0) { return $response->json(["error" => "Invalid email or password"], 401); }โ $user = $found[0];โ // Verify password if (!$user->verifyPassword($body["password"])) { return $response->json(["error" => "Invalid email or password"], 401); }โ // Generate JWT token $token = Auth::getToken([ "user_id" => $user->id, "email" => $user->email, "role" => $user->role ]);โ return $response->json([ "token" => $token, "user" => $user->toSafeDict() ]);});โ/** * @secured */Router::get("/api/profile", function ($request, $response) { $userId = $request->user["user_id"];โ $user = new User(); $user->load($userId);โ if (empty($user->id)) { return $response->json(["error" => "User not found"], 404); }โ return $response->json(["user" => $user->toSafeDict()]);});Test Registration and Login#
# Start the servertina4 serve# Register a usercurl -X POST http://localhost:7145/api/auth/register \ -H "Content-Type: application/json" \ -d '{"name": "Alice Johnson", "email": "alice@example.com", "password": "securepass123"}'{ "id": 1, "name": "Alice Johnson", "email": "alice@example.com", "role": "user", "created_at": "2026-03-22 10:00:00"}# Logincurl -X POST http://localhost:7145/api/auth/login \ -H "Content-Type: application/json" \ -d '{"email": "alice@example.com", "password": "securepass123"}'{ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "user": { "id": 1, "name": "Alice Johnson", "email": "alice@example.com", "role": "user" }}# Access protected routecurl http://localhost:7145/api/profile \ -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."{ "user": { "id": 1, "name": "Alice Johnson", "email": "alice@example.com", "role": "user" }}5. Step 3: Task Model with CRUD#
Create src/orm/Task.php:
<?phpuse Tina4\ORM;โclass Task extends ORM{ public int $id; public string $title; public string $description = ""; public string $status = "todo"; public string $priority = "medium"; public int $createdBy; public ?int $assignedTo = null; public ?string $dueDate = null; public ?string $completedAt = null; public string $createdAt; public string $updatedAt;โ public string $tableName = "tasks"; public string $primaryKey = "id";โ /** * Get the user who created this task */ public function creator(): ?User { return $this->belongsTo(User::class, "created_by"); }โ /** * Get the user this task is assigned to */ public function assignee(): ?User { return $this->belongsTo(User::class, "assigned_to"); }โ /** * Convert to dict with nested user data */ public function toDetailedDict(): array { $dict = $this->toArray();โ $creator = $this->creator(); $dict["creator"] = $creator ? $creator->toSafeDict() : null;โ $assignee = $this->assignee(); $dict["assignee"] = $assignee ? $assignee->toSafeDict() : null;โ return $dict; }}Task Routes#
Create src/routes/tasks.php:
<?phpuse Tina4\Router;โRouter::group("/api", function () {โ // List tasks with filters /** * @secured */ Router::get("/tasks", function ($request, $response) { $userId = $request->user["user_id"];โ $status = $request->params["status"] ?? ""; $priority = $request->params["priority"] ?? ""; $assigned = $request->params["assigned"] ?? ""; $page = (int) ($request->params["page"] ?? 1); $perPage = (int) ($request->params["per_page"] ?? 20);โ $conditions = []; $params = [];โ // Show tasks created by or assigned to the current user $conditions[] = "(created_by = :userId OR assigned_to = :userId2)"; $params["userId"] = $userId; $params["userId2"] = $userId;โ if (!empty($status)) { $conditions[] = "status = :status"; $params["status"] = $status; }โ if (!empty($priority)) { $conditions[] = "priority = :priority"; $params["priority"] = $priority; }โ if ($assigned === "me") { $conditions[] = "assigned_to = :assignedTo"; $params["assignedTo"] = $userId; } elseif ($assigned === "unassigned") { $conditions[] = "assigned_to IS NULL"; }โ $filter = implode(" AND ", $conditions); $offset = ($page - 1) * $perPage;โ $task = new Task(); $tasks = $task->select("*", $filter, $params, "created_at DESC", $perPage, $offset);โ $results = array_map(fn($t) => $t->toDetailedDict(), $tasks);โ return $response->json([ "tasks" => $results, "page" => $page, "per_page" => $perPage, "count" => count($results) ]); });โ // Get a single task /** * @secured */ Router::get("/tasks/{id:int}", function ($request, $response) { $task = new Task(); $task->load($request->params["id"]);โ if (empty($task->id)) { return $response->json(["error" => "Task not found"], 404); }โ return $response->json($task->toDetailedDict()); });โ // Create a task Router::post("/tasks", function ($request, $response) { $userId = $request->user["user_id"]; $body = $request->body;โ if (empty($body["title"])) { return $response->json(["error" => "title is required"], 400); }โ $validStatuses = ["todo", "in_progress", "review", "done"]; $validPriorities = ["low", "medium", "high", "urgent"];โ $status = $body["status"] ?? "todo"; if (!in_array($status, $validStatuses)) { return $response->json([ "error" => "Invalid status. Must be one of: " . implode(", ", $validStatuses) ], 400); }โ $priority = $body["priority"] ?? "medium"; if (!in_array($priority, $validPriorities)) { return $response->json([ "error" => "Invalid priority. Must be one of: " . implode(", ", $validPriorities) ], 400); }โ $task = new Task(); $task->title = $body["title"]; $task->description = $body["description"] ?? ""; $task->status = $status; $task->priority = $priority; $task->createdBy = $userId; $task->dueDate = $body["due_date"] ?? null;โ // Handle assignment if (!empty($body["assigned_to"])) { $assignee = new User(); $assignee->load((int) $body["assigned_to"]); if (empty($assignee->id)) { return $response->json(["error" => "Assigned user not found"], 400); } $task->assignedTo = $assignee->id; }โ $task->save();โ return $response->json($task->toDetailedDict(), 201); });โ // Update a task Router::put("/tasks/{id:int}", function ($request, $response) { $task = new Task(); $task->load($request->params["id"]);โ if (empty($task->id)) { return $response->json(["error" => "Task not found"], 404); }โ $body = $request->body;โ if (isset($body["title"])) $task->title = $body["title"]; if (isset($body["description"])) $task->description = $body["description"]; if (isset($body["priority"])) $task->priority = $body["priority"]; if (isset($body["due_date"])) $task->dueDate = $body["due_date"];โ // Handle status change if (isset($body["status"])) { $oldStatus = $task->status; $task->status = $body["status"];โ // Mark completion time if ($body["status"] === "done" && $oldStatus !== "done") { $task->completedAt = date("Y-m-d H:i:s"); } elseif ($body["status"] !== "done") { $task->completedAt = null; } }โ // Handle reassignment if (isset($body["assigned_to"])) { if ($body["assigned_to"] === null) { $task->assignedTo = null; } else { $assignee = new User(); $assignee->load((int) $body["assigned_to"]); if (empty($assignee->id)) { return $response->json(["error" => "Assigned user not found"], 400); } $task->assignedTo = $assignee->id; } }โ $task->save();โ return $response->json($task->toDetailedDict()); });โ // Delete a task Router::delete("/tasks/{id:int}", function ($request, $response) { $task = new Task(); $task->load($request->params["id"]);โ if (empty($task->id)) { return $response->json(["error" => "Task not found"], 404); }โ $task->delete(); return $response->json(null, 204); });});Test the Task API#
# Get token (from login)TOKEN="eyJhbGciOiJIUzI1NiIs..."โ# Create taskscurl -X POST http://localhost:7145/api/tasks \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $TOKEN" \ -d '{"title": "Design database schema", "priority": "high", "status": "done"}'โcurl -X POST http://localhost:7145/api/tasks \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $TOKEN" \ -d '{"title": "Build API endpoints", "priority": "high", "status": "in_progress"}'โcurl -X POST http://localhost:7145/api/tasks \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $TOKEN" \ -d '{"title": "Write documentation", "priority": "medium", "due_date": "2026-04-01"}'โ# List all taskscurl http://localhost:7145/api/tasks \ -H "Authorization: Bearer $TOKEN"โ# Filter by statuscurl "http://localhost:7145/api/tasks?status=in_progress" \ -H "Authorization: Bearer $TOKEN"6. Step 4: Dashboard Template with tina4css#
Create src/templates/app/layout.html:
<!DOCTYPE html><html lang="en"><head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>{% block title %}TaskFlow{% endblock %}</title> <link rel="stylesheet" href="/css/tina4.css"> <script> (function() { var t = localStorage.getItem("theme"); if (t) document.documentElement.setAttribute("data-theme", t); else if (window.matchMedia("(prefers-color-scheme: dark)").matches) document.documentElement.setAttribute("data-theme", "dark"); })(); </script> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; } .app { display: flex; min-height: 100vh; } .sidebar { width: 240px; background: #1a1a2e; color: #ccc; flex-shrink: 0; } .sidebar-brand { padding: 20px; font-size: 1.3em; font-weight: bold; color: #fff; border-bottom: 1px solid rgba(255,255,255,0.1); } .sidebar-nav { list-style: none; padding: 12px 0; } .sidebar-nav a { display: block; padding: 10px 20px; color: rgba(255,255,255,0.6); text-decoration: none; transition: 0.2s; } .sidebar-nav a:hover, .sidebar-nav a.active { color: #fff; background: rgba(255,255,255,0.08); } .main { flex: 1; background: #f4f5f7; display: flex; flex-direction: column; } .topbar { background: #fff; padding: 14px 24px; border-bottom: 1px solid #e1e4e8; display: flex; justify-content: space-between; align-items: center; } .topbar-left { font-size: 1.1em; font-weight: 600; } .topbar-right { display: flex; gap: 12px; align-items: center; } .content { padding: 24px; flex: 1; } .stats-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; } .stat-card { background: #fff; border-radius: 8px; padding: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); } .stat-card .value { font-size: 2em; font-weight: bold; } .stat-card .label { color: #6c757d; margin-top: 4px; font-size: 0.9em; } .task-row { background: #fff; border-radius: 6px; padding: 14px 18px; margin-bottom: 8px; display: flex; align-items: center; gap: 12px; box-shadow: 0 1px 2px rgba(0,0,0,0.05); } .task-row .task-title { flex: 1; font-weight: 500; } .priority-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; } .priority-dot.urgent { background: #dc3545; } .priority-dot.high { background: #fd7e14; } .priority-dot.medium { background: #ffc107; } .priority-dot.low { background: #28a745; } @media (max-width: 768px) { .sidebar { display: none; } .stats-row { grid-template-columns: 1fr 1fr; } } </style> {% block extra_css %}{% endblock %}</head><body> <div class="app"> <aside class="sidebar"> <div class="sidebar-brand">TaskFlow</div> <ul class="sidebar-nav"> <li><a href="/admin" class="{% if page == 'dashboard' %}active{% endif %}">Dashboard</a></li> <li><a href="/admin/tasks" class="{% if page == 'tasks' %}active{% endif %}">All Tasks</a></li> <li><a href="/admin/my-tasks" class="{% if page == 'my-tasks' %}active{% endif %}">My Tasks</a></li> </ul> </aside> <div class="main"> <div class="topbar"> <div class="topbar-left">{% block page_title %}Dashboard{% endblock %}</div> <div class="topbar-right"> <button class="btn btn-sm btn-outline-secondary" onclick="toggleTheme()">Theme</button> <span id="userName">{{ user_name | default("User") }}</span> <button class="btn btn-sm btn-outline-danger" onclick="logout()">Logout</button> </div> </div> <div class="content"> {% block content %}{% endblock %} </div> </div> </div> <script src="/js/frond.js"></script> <script> function toggleTheme() { var html = document.documentElement; var next = html.getAttribute("data-theme") === "dark" ? "light" : "dark"; html.setAttribute("data-theme", next); localStorage.setItem("theme", next); } function logout() { frond.clearToken(); window.location.href = "/login"; } </script> {% block extra_js %}{% endblock %}</body></html>Create src/templates/app/dashboard.html:
{% extends "app/layout.html" %} {% block title %}Dashboard - TaskFlow{% endblock %}{% block page_title %}Dashboard{% endblock %} {% block content %} <div class="stats-row"> <div class="stat-card"> <div class="value" id="statTotal">--</div> <div class="label">Total Tasks</div> </div> <div class="stat-card"> <div class="value" id="statTodo">--</div> <div class="label">To Do</div> </div> <div class="stat-card"> <div class="value" id="statInProgress">--</div> <div class="label">In Progress</div> </div> <div class="stat-card"> <div class="value" id="statDone">--</div> <div class="label">Completed</div> </div> </div> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;"> <h3>Recent Tasks</h3> <button class="btn btn-primary btn-sm" data-toggle="modal" data-target="#newTaskModal"> New Task </button> </div> <div id="taskList"> <p class="text-muted">Loading tasks...</p> </div> <!-- New Task Modal --> <div class="modal" id="newTaskModal"> <div class="modal-dialog"> <div class="modal-content"> <div class="modal-header"> <h5 class="modal-title">Create Task</h5> <button type="button" class="close" data-dismiss="modal">×</button> </div> <div class="modal-body"> <div class="form-group"> <label>Title</label> <input type="text" class="form-control" id="taskTitle"> </div> <div class="form-group"> <label>Description</label> <textarea class="form-control" id="taskDescription" rows="3"></textarea> </div> <div class="form-group"> <label>Priority</label> <select class="form-control" id="taskPriority"> <option value="low">Low</option> <option value="medium" selected>Medium</option> <option value="high">High</option> <option value="urgent">Urgent</option> </select> </div> <div class="form-group"> <label>Due Date</label> <input type="date" class="form-control" id="taskDueDate"> </div> </div> <div class="modal-footer"> <button class="btn btn-secondary" data-dismiss="modal">Cancel</button> <button class="btn btn-primary" onclick="createTask()">Create</button> </div> </div> </div> </div> <div id="alertArea" style="position: fixed; top: 80px; right: 24px; width: 300px; z-index: 1000;"></div>{% endblock %} {% block extra_js %}<script> function loadStats() { frond.get("/api/dashboard/stats", function (data) { document.getElementById("statTotal").textContent = data.total; document.getElementById("statTodo").textContent = data.todo; document.getElementById("statInProgress").textContent = data.in_progress; document.getElementById("statDone").textContent = data.done; }); } function loadTasks() { frond.get("/api/tasks?per_page=10", function (data) { var html = ""; if (data.tasks.length === 0) { html = '<p class="text-muted">No tasks yet. Create your first task!</p>'; } else { data.tasks.forEach(function (task) { html += '<div class="task-row">' + '<span class="priority-dot ' + task.priority + '"></span>' + '<span class="task-title">' + task.title + '</span>' + '<span class="badge badge-' + statusColor(task.status) + '">' + task.status.replace("_", " ") + '</span>' + (task.due_date ? '<span class="text-muted" style="font-size:0.85em;">' + task.due_date + '</span>' : '') + '</div>'; }); } document.getElementById("taskList").innerHTML = html; }); } function statusColor(status) { var colors = { "todo": "secondary", "in_progress": "primary", "review": "warning", "done": "success" }; return colors[status] || "secondary"; } function createTask() { var data = { title: document.getElementById("taskTitle").value, description: document.getElementById("taskDescription").value, priority: document.getElementById("taskPriority").value, due_date: document.getElementById("taskDueDate").value || null }; frond.post("/api/tasks", data, function (result) { showAlert("Task created: " + result.title, "success"); loadTasks(); loadStats(); // Clear form document.getElementById("taskTitle").value = ""; document.getElementById("taskDescription").value = ""; }, function (error) { showAlert("Error creating task", "danger"); }); } function showAlert(message, type) { var area = document.getElementById("alertArea"); area.innerHTML = '<div class="alert alert-' + type + '">' + message + '</div>'; setTimeout(function () { area.innerHTML = ""; }, 3000); } // Initial load loadStats(); loadTasks(); // Auto-refresh every 30 seconds setInterval(function () { loadStats(); loadTasks(); }, 30000);</script>{% endblock %}Dashboard Stats Route#
Create src/routes/dashboard.php:
<?phpuse Tina4\Router;โ/** * @secured */Router::get("/api/dashboard/stats", function ($request, $response) { $userId = $request->user["user_id"];โ $task = new Task(); $filter = "(created_by = :uid OR assigned_to = :uid2)"; $params = ["uid" => $userId, "uid2" => $userId];โ $all = $task->select("*", $filter, $params);โ $stats = [ "total" => count($all), "todo" => 0, "in_progress" => 0, "review" => 0, "done" => 0 ];โ foreach ($all as $t) { if (isset($stats[$t->status])) { $stats[$t->status]++; } }โ return $response->json($stats);});โ/** * @noauth */Router::get("/admin", function ($request, $response) { return $response->render("app/dashboard.html", [ "page" => "dashboard" ]);});7. Step 5: Real-Time Updates via WebSocket#
Real-time task updates. All connected dashboard users see changes the moment they happen.
Create src/routes/websocket.php:
<?phpuse Tina4\WebSocket;โWebSocket::on("connect", function ($client) { error_log("Client connected: " . $client->id);});โWebSocket::on("disconnect", function ($client) { error_log("Client disconnected: " . $client->id);});โWebSocket::on("message", function ($client, $message) { $data = json_decode($message, true);โ if ($data["type"] === "subscribe" && $data["channel"] === "tasks") { WebSocket::subscribe($client, "tasks"); }});Broadcast task changes by updating the task routes. Add this helper function at the top of src/routes/tasks.php:
function broadcastTaskUpdate(string $event, array $taskData): void{ \Tina4\WebSocket::broadcast("tasks", json_encode([ "type" => "task_update", "event" => $event, "task" => $taskData ]));}Call it after each write operation:
// After creating a task$task->save();broadcastTaskUpdate("created", $task->toDetailedDict());โ// After updating a task$task->save();broadcastTaskUpdate("updated", $task->toDetailedDict());โ// After deleting a task$task->delete();broadcastTaskUpdate("deleted", ["id" => $request->params["id"]]);Add WebSocket client code to the dashboard template. Add this inside the {% block extra_js %} block:
// WebSocket connection for real-time updatesvar ws = new WebSocket("ws://localhost:7145/ws");โws.onopen = function () { ws.send(JSON.stringify({ type: "subscribe", channel: "tasks" }));};โws.onmessage = function (event) { var data = JSON.parse(event.data); if (data.type === "task_update") { loadTasks(); loadStats(); showAlert("Task " + data.event + ": " + (data.task.title || ""), "info"); }};โws.onclose = function () { // Reconnect after 3 seconds setTimeout(function () { ws = new WebSocket("ws://localhost:7145/ws"); }, 3000);};Any user creates, updates, or deletes a task. All connected dashboard users see the change.
8. Step 6: Email Notifications on Task Assignment#
A task is assigned to a user. Send them an email notification.
Create src/routes/notifications.php:
<?phpuse Tina4\Mail;โfunction sendTaskAssignmentEmail(Task $task, User $assignee, User $assigner): void{ $subject = "New task assigned: " . $task->title;โ $body = "Hi " . $assignee->name . ",\n\n" . $assigner->name . " has assigned you a new task:\n\n" . "Title: " . $task->title . "\n" . "Priority: " . strtoupper($task->priority) . "\n" . "Due: " . ($task->dueDate ?? "No due date") . "\n\n" . "Description:\n" . ($task->description ?: "(No description)") . "\n\n" . "View it at: http://localhost:7145/admin\n";โ Mail::send( $assignee->email, $subject, $body );}In the task creation and update routes, call this function when assigned_to is set:
// After setting assigned_to and savingif ($task->assignedTo && $task->assignedTo !== $userId) { $assignee = new User(); $assignee->load($task->assignedTo);โ $assigner = new User(); $assigner->load($userId);โ if (!empty($assignee->id) && !empty($assigner->id)) { sendTaskAssignmentEmail($task, $assignee, $assigner); }}Configure email in .env:
TINA4_MAIL_HOST=smtp.example.comTINA4_MAIL_PORT=587TINA4_MAIL_USERNAME=notifications@example.comTINA4_MAIL_PASSWORD=your-email-passwordTINA4_MAIL_FROM=notifications@example.comTINA4_MAIL_FROM_NAME=TaskFlowFor development, you can use a local mail trap like MailHog or Mailtrap.io so emails are captured without actually sending.
9. Step 7: Add Caching for the Dashboard#
The dashboard stats query runs on every page load. With many tasks, this gets slow. Cache the stats. Compute once. Serve from cache for subsequent requests.
Update the dashboard stats route:
/** * @secured */Router::get("/api/dashboard/stats", function ($request, $response) { $userId = $request->user["user_id"]; $cacheKey = "dashboard_stats_" . $userId;โ // Try cache first $cached = \Tina4\Cache::get($cacheKey); if ($cached !== null) { return $response->json($cached); }โ // Compute stats $task = new Task(); $filter = "(created_by = :uid OR assigned_to = :uid2)"; $params = ["uid" => $userId, "uid2" => $userId];โ $all = $task->select("*", $filter, $params);โ $stats = [ "total" => count($all), "todo" => 0, "in_progress" => 0, "review" => 0, "done" => 0 ];โ foreach ($all as $t) { if (isset($stats[$t->status])) { $stats[$t->status]++; } }โ // Cache for 60 seconds \Tina4\Cache::set($cacheKey, $stats, 60);โ return $response->json($stats);});Invalidate the cache when tasks change. Add this to the broadcastTaskUpdate function:
function broadcastTaskUpdate(string $event, array $taskData): void{ // Invalidate dashboard cache for all affected users if (isset($taskData["created_by"])) { \Tina4\Cache::delete("dashboard_stats_" . $taskData["created_by"]); } if (isset($taskData["assigned_to"]) && $taskData["assigned_to"]) { \Tina4\Cache::delete("dashboard_stats_" . $taskData["assigned_to"]); }โ \Tina4\WebSocket::broadcast("tasks", json_encode([ "type" => "task_update", "event" => $event, "task" => $taskData ]));}10. Step 8: Write Tests#
Create tests/TaskFlowTest.php:
<?phpuse Tina4\Test;โclass TaskFlowTest extends Test{ private ?string $token = null; private ?int $userId = null;โ public function setUp(): void { // Register a test user $email = "test-" . uniqid() . "@taskflow.test"; $regResponse = $this->post("/api/auth/register", [ "name" => "Test User", "email" => $email, "password" => "testpassword123" ]);โ $regBody = json_decode($regResponse->body, true); $this->userId = $regBody["id"] ?? null;โ // Login to get token $loginResponse = $this->post("/api/auth/login", [ "email" => $email, "password" => "testpassword123" ]);โ $loginBody = json_decode($loginResponse->body, true); $this->token = $loginBody["token"] ?? null; }โ // --- Auth Tests ---โ public function testRegistrationReturns201() { $response = $this->post("/api/auth/register", [ "name" => "New User", "email" => "new-" . uniqid() . "@test.com", "password" => "securepassword" ]); $this->assertEqual($response->statusCode, 201, "Registration should return 201"); }โ public function testRegistrationRejectsShortPassword() { $response = $this->post("/api/auth/register", [ "name" => "New User", "email" => "short-" . uniqid() . "@test.com", "password" => "abc" ]); $this->assertEqual($response->statusCode, 400, "Should reject short password"); }โ public function testLoginReturnsToken() { $this->assertNotNull($this->token, "Login should return a token"); $this->assertTrue(strlen($this->token) > 20, "Token should be substantial"); }โ public function testProfileRequiresAuth() { $response = $this->get("/api/profile"); $this->assertEqual($response->statusCode, 401, "Profile should require auth"); }โ public function testProfileWithToken() { $response = $this->get("/api/profile", [ "Authorization" => "Bearer " . $this->token ]); $this->assertEqual($response->statusCode, 200, "Profile should work with token"); }โ // --- Task Tests ---โ public function testCreateTask() { $response = $this->post("/api/tasks", [ "title" => "Test Task", "priority" => "high" ], ["Authorization" => "Bearer " . $this->token]);โ $this->assertEqual($response->statusCode, 201, "Should create task");โ $body = json_decode($response->body, true); $this->assertEqual($body["title"], "Test Task", "Title should match"); $this->assertEqual($body["priority"], "high", "Priority should match"); $this->assertEqual($body["status"], "todo", "Default status should be todo"); }โ public function testCreateTaskRequiresTitle() { $response = $this->post("/api/tasks", [ "priority" => "low" ], ["Authorization" => "Bearer " . $this->token]);โ $this->assertEqual($response->statusCode, 400, "Should reject task without title"); }โ public function testListTasks() { // Create a task first $this->post("/api/tasks", [ "title" => "List Test Task" ], ["Authorization" => "Bearer " . $this->token]);โ $response = $this->get("/api/tasks", [ "Authorization" => "Bearer " . $this->token ]);โ $this->assertEqual($response->statusCode, 200, "Should list tasks");โ $body = json_decode($response->body, true); $this->assertTrue($body["count"] > 0, "Should have at least one task"); }โ public function testUpdateTaskStatus() { // Create a task $createResponse = $this->post("/api/tasks", [ "title" => "Status Test Task" ], ["Authorization" => "Bearer " . $this->token]);โ $taskId = json_decode($createResponse->body, true)["id"];โ // Update status to done $updateResponse = $this->put("/api/tasks/" . $taskId, [ "status" => "done" ], ["Authorization" => "Bearer " . $this->token]);โ $this->assertEqual($updateResponse->statusCode, 200, "Should update task");โ $body = json_decode($updateResponse->body, true); $this->assertEqual($body["status"], "done", "Status should be done"); $this->assertNotNull($body["completed_at"], "Should have completion timestamp"); }โ public function testDeleteTask() { // Create a task $createResponse = $this->post("/api/tasks", [ "title" => "Delete Me" ], ["Authorization" => "Bearer " . $this->token]);โ $taskId = json_decode($createResponse->body, true)["id"];โ // Delete it $deleteResponse = $this->delete("/api/tasks/" . $taskId, [ "Authorization" => "Bearer " . $this->token ]);โ $this->assertEqual($deleteResponse->statusCode, 204, "Should return 204");โ // Verify it is gone $getResponse = $this->get("/api/tasks/" . $taskId, [ "Authorization" => "Bearer " . $this->token ]);โ $this->assertEqual($getResponse->statusCode, 404, "Should return 404 after deletion"); }โ public function testDashboardStats() { // Create tasks with different statuses $this->post("/api/tasks", ["title" => "Todo Task", "status" => "todo"], ["Authorization" => "Bearer " . $this->token]); $this->post("/api/tasks", ["title" => "In Progress Task", "status" => "in_progress"], ["Authorization" => "Bearer " . $this->token]); $this->post("/api/tasks", ["title" => "Done Task", "status" => "done"], ["Authorization" => "Bearer " . $this->token]);โ $response = $this->get("/api/dashboard/stats", [ "Authorization" => "Bearer " . $this->token ]);โ $this->assertEqual($response->statusCode, 200, "Should return stats");โ $body = json_decode($response->body, true); $this->assertTrue($body["total"] >= 3, "Should have at least 3 tasks"); $this->assertTrue($body["todo"] >= 1, "Should have at least 1 todo"); $this->assertTrue($body["in_progress"] >= 1, "Should have at least 1 in progress"); $this->assertTrue($body["done"] >= 1, "Should have at least 1 done"); }}Run the tests:
tina4 testRunning tests...โ TaskFlowTest [PASS] test_registration_returns_201 [PASS] test_registration_rejects_short_password [PASS] test_login_returns_token [PASS] test_profile_requires_auth [PASS] test_profile_with_token [PASS] test_create_task [PASS] test_create_task_requires_title [PASS] test_list_tasks [PASS] test_update_task_status [PASS] test_delete_task [PASS] test_dashboard_statsโ 11 tests, 11 passed, 0 failed (0.62s)11. Step 9: Deploy with Docker#
Create Dockerfile:
FROM dunglas/frankenphp:latest-php8.3-alpineโRUN install-php-extensions \ pdo_sqlite \ mbstring \ openssl \ fileinfoโWORKDIR /appโCOPY composer.json composer.lock ./RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer \ && composer install --no-dev --optimize-autoloader --no-interactionโCOPY . .โRUN mkdir -p data logs secrets \ && chown -R www-data:www-data data logs secretsโEXPOSE 7145โHEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD curl -f http://localhost:7145/health || exit 1โCMD ["tina4", "serve", "--production"]Create docker-compose.yml:
version: "3.8"โservices: app: build: . ports: - "7145:7145" environment: - TINA4_DEBUG=false - TINA4_LOG_LEVEL=WARNING - TINA4_TEMPLATE_CACHE_TTL=true - JWT_SECRET=${JWT_SECRET:-change-me-in-production} - TINA4_DATABASE_URL=sqlite:///data/app.db volumes: - taskflow-data:/app/data - taskflow-logs:/app/logs restart: unless-stopped stop_grace_period: 35sโvolumes: taskflow-data: taskflow-logs:Build and deploy:
# Builddocker compose buildโ# Startdocker compose up -dโ# Run migrations inside the containerdocker compose exec app tina4 migrateโ# Verifycurl http://localhost:7145/health{ "status": "ok", "database": "connected", "uptime_seconds": 5, "version": "3.0.0", "framework": "tina4-php"}12. The Complete Project Structure#
taskflow/โโโ .envโโโ .env.exampleโโโ .gitignoreโโโ composer.jsonโโโ composer.lockโโโ Dockerfileโโโ docker-compose.ymlโโโ vendor/โโโ src/โ โโโ routes/โ โ โโโ auth.php # Registration, login, profileโ โ โโโ tasks.php # Task CRUDโ โ โโโ dashboard.php # Dashboard stats + pageโ โ โโโ notifications.php # Email notification helpersโ โ โโโ websocket.php # WebSocket event handlersโ โโโ orm/โ โ โโโ User.php # User model with auth methodsโ โ โโโ Task.php # Task model with relationshipsโ โโโ migrations/โ โ โโโ 20260322000100_create_users_table.sqlโ โ โโโ 20260322000200_create_tasks_table.sqlโ โโโ templates/โ โ โโโ app/โ โ โ โโโ layout.html # Base layout with sidebarโ โ โ โโโ dashboard.html # Dashboard pageโ โ โโโ errors/โ โ โโโ 404.htmlโ โ โโโ 500.htmlโ โโโ public/โ โ โโโ css/โ โ โ โโโ tina4.cssโ โ โโโ js/โ โ โโโ frond.jsโ โโโ locales/โ โโโ en.jsonโโโ data/โ โโโ app.dbโโโ logs/โโโ secrets/โโโ tests/ โโโ TaskFlowTest.phpEvery file has a purpose. Every directory follows the convention. A new developer looks at this structure and knows where to find things.
13. What to Build Next#
TaskFlow is a solid foundation. Ideas for extending it:
Features:
- Task comments -- Add a Comment model with a
task_idforeign key. Display comments on the task detail page. - File attachments -- Let users upload files to tasks. Store them in
data/uploads/and serve them via a route. - Team management -- Add a Team model. Users belong to teams. Tasks are scoped to teams.
- Task labels/tags -- Many-to-many relationship between tasks and labels for categorization.
- Due date reminders -- Use the queue system to schedule reminder emails 24 hours before a task's due date.
- Activity log -- Record every change to a task (who changed what, when) for audit trails.
- Search -- Full-text search across task titles and descriptions.
- Calendar view -- Render tasks on a calendar based on their due dates.
- Mobile API -- The API already works for mobile apps. Add push notification support via Firebase Cloud Messaging.
Technical improvements:
- Rate limiting per user -- Replace the global rate limiter with per-user limits.
- Database upgrade -- Switch from SQLite to PostgreSQL for better concurrency.
- CI/CD pipeline -- Add GitHub Actions to run tests automatically on every push.
- API documentation -- Generate OpenAPI/Swagger docs from your route definitions.
- Internationalization -- Add
src/locales/files for multiple languages.
14. Closing Thoughts -- The Tina4 Philosophy#
You built a complete application. User auth. CRUD. Real-time updates. Email. Caching. Tests. Deployment. Your project has one dependency: tina4/tina4-php.
No ORM package. No template engine package. No authentication library. No WebSocket server. No caching library. No testing framework. No CLI tool. No CSS framework. No JavaScript helpers. All built in.
One framework. Zero dependencies. Everything you need.
The same patterns work in Python, Ruby, and Node.js. Same project structure. Same CLI commands. Same .env variables. Same template syntax. Learn Tina4 once. Use it everywhere.
Your vendor/ directory is small. Your composer.lock has one entry. When PHP 9.0 ships, you update one package. Everything works. No dependency tree to untangle. No abandoned transitive dependency to replace. No security advisory for a package four levels deep that you never knew you were using.
Simple does not mean limited. TaskFlow has authentication, real-time WebSocket, email, caching, GraphQL, and a test suite. It deploys in a Docker container. It handles thousands of concurrent users. All of this runs on a single zero-dependency framework package.
Build things. Ship them. Keep it simple.