Todo Example
A complete working PHP Todo application built with Tplix. Covers MVC structure, form validation, PDO database integration, and clean separation of presentation from business logic.
Building a To-Do List
A classic CRUD application that demonstrates Tplix's form handling, conditional rendering, dynamic CSS classes, and template inheritance — all in plain PHP with no extra libraries.
What We'll Build
A single-page to-do app where you can add tasks, mark them complete, and delete them. Every Tplix feature that makes templates clean and readable is on display here.
Add Tasks
Submit a form, validate input server-side, and redirect back — the Post/Redirect/Get pattern.
Toggle Complete
Completed tasks get a strikethrough style driven by a Tplix conditional class — no JavaScript needed.
Delete Tasks
A small POST form per task deletes the record and redirects, keeping the URL always clean.
Project Structure
Database Schema
One table with four columns — as simple as it gets.
CREATE TABLE tasks (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL,
done TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
The Model — Task.php
Four methods, one for each operation the app needs.
<?php
class Task
{
public function __construct(private PDO $pdo) {}
/** All tasks, oldest first */
public function all(): array
{
return $this->pdo
->query("SELECT * FROM tasks ORDER BY created_at ASC")
->fetchAll(PDO::FETCH_ASSOC);
}
/** Insert a new task */
public function create(string $title): void
{
$stmt = $this->pdo->prepare(
"INSERT INTO tasks (title) VALUES (?)"
);
$stmt->execute([trim($title)]);
}
/** Flip the done flag */
public function toggle(int $id): void
{
$stmt = $this->pdo->prepare(
"UPDATE tasks SET done = NOT done WHERE id = ?"
);
$stmt->execute([$id]);
}
/** Remove a task permanently */
public function delete(int $id): void
{
$stmt = $this->pdo->prepare(
"DELETE FROM tasks WHERE id = ?"
);
$stmt->execute([$id]);
}
}
Front Controller — index.php
Bootstraps the app and routes POST actions to the right controller method.
<?php
require 'vendor/autoload.php';
require 'Task.php';
require 'TaskController.php';
$pdo = new PDO('mysql:host=localhost;dbname=todo;charset=utf8mb4', 'user', 'pass', [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$tplix = new Tplix\Engine([
'template_dir' => __DIR__ . '/templates',
'compile_dir' => __DIR__ . '/cache',
'caching' => false,
]);
$controller = new TaskController($pdo, $tplix);
$action = $_POST['action'] ?? 'list';
match ($action) {
'add' => $controller->add(),
'toggle' => $controller->toggle(),
'delete' => $controller->delete(),
default => $controller->index(),
};
The Controller — TaskController.php
Each action validates input, delegates to the model, then redirects (PRG pattern) or renders the template.
<?php
class TaskController
{
public function __construct(
private PDO $pdo,
private Tplix\Engine $tplix
) {}
/** GET / — show all tasks + add form */
public function index(): void
{
$model = new Task($this->pdo);
$this->tplix->assign('tasks', $model->all());
$this->tplix->assign('error', $_SESSION['error'] ?? null);
unset($_SESSION['error']);
$this->tplix->display('tasks.tpl');
}
/** POST action=add */
public function add(): void
{
$title = trim($_POST['title'] ?? '');
if ($title === '') {
$_SESSION['error'] = 'Task title cannot be empty.';
} else {
(new Task($this->pdo))->create($title);
}
header('Location: /');
exit;
}
/** POST action=toggle */
public function toggle(): void
{
$id = (int)($_POST['id'] ?? 0);
if ($id > 0) {
(new Task($this->pdo))->toggle($id);
}
header('Location: /');
exit;
}
/** POST action=delete */
public function delete(): void
{
$id = (int)($_POST['id'] ?? 0);
if ($id > 0) {
(new Task($this->pdo))->delete($id);
}
header('Location: /');
exit;
}
}
The Templates
⬡ layout.tpl — base template
Minimal HTML shell. The only block child templates need to fill is content.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>To-Do List</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<header>
<h1>My To-Do List</h1>
<p>{$.now|date_format:"%A, %B %e %Y"}</p>
</header>
<main>
{block name="content"}{/block}
</main>
</body>
</html>
⬡ tasks.tpl — task list + add form
This is where Tplix shines: {if} for the error banner,
{foreach} for the list,
and {if $task.done} to apply a CSS class conditionally.
{extends file="layout.tpl"}
{block name="content"}
{* Error banner — only shown when $error is set *}
{if $error}
<div class="alert alert-error">{$error|escape}</div>
{/if}
{* Add task form *}
<form method="post" action="/">
<input type="hidden" name="action" value="add">
<input type="text" name="title" placeholder="What needs doing?" required>
<button type="submit">Add Task</button>
</form>
{* Task list *}
<ul class="task-list">
{foreach $tasks as $task}
<li class="task-item{if $task.done} done{/if}">
{* Toggle button *}
<form method="post" action="/">
<input type="hidden" name="action" value="toggle">
<input type="hidden" name="id" value="{$task.id}">
<button type="submit" class="btn-check"
title="{if $task.done}Mark incomplete{else}Mark complete{/if}">
{if $task.done}✅{else}⬜{/if}
</button>
</form>
{* Task title *}
<span class="task-title">{$task.title|escape}</span>
{* Added date *}
<span class="task-date">{$task.created_at|date_format:"%b %e"}</span>
{* Delete button *}
<form method="post" action="/">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="id" value="{$task.id}">
<button type="submit" class="btn-delete"
onclick="return confirm('Delete this task?')">✕</button>
</form>
</li>
{foreachelse}
<li class="task-empty">No tasks yet — add one above!</li>
{/foreach}
</ul>
{* Summary line *}
{if $tasks|count > 0}
<p class="task-summary">
{$tasks|count} task{if $tasks|count != 1}s{/if} total
</p>
{/if}
{/block}
Tplix Features Used in This Example
{extends} + {block}
The task list lives inside the shared layout without repeating a single line of HTML.
{foreach} + {foreachelse}
Iterates all tasks and falls through to a friendly empty-state message when the list is empty.
Inline {if} for CSS classes
class="task-item{if $task.done} done{/if}" applies the strikethrough style without any JavaScript.
|date_format filter
Formats MySQL datetime strings for display and renders today's date in the header via $.now.
|count modifier
Counts the tasks array directly in the template for the summary line — no extra variable from the controller needed.
|escape filter
Every user-supplied value — task titles, error messages — is HTML-escaped at the template layer, preventing XSS.
Where to Go Next
due_at column and use {if $task.due_at lt $.now} to highlight overdue items in red.
priority column and map values to badge colors with a Tplix custom modifier or a nested {if}/{elseif} block.
$filter variable from the controller and use {if $filter === 'active'} to show active/completed tabs in the template.
'caching' => true in the engine config and call clearCache() after any write action to keep pages fast.
Ready to build?
Five files, one table, zero dependencies beyond Tplix — and you have a fully working CRUD app. That is the Tplix promise: clean templates, clean controllers, clean code.