Todo App Example
A full-stack example combining a Zero Framework module with a ShadowComponent frontend.
Intermediate
This example shows how Zero Framework and ShadowComponent work together in a realistic application. The backend module serves both HTML pages and a JSON API. The frontend component handles the interactive UI. They communicate through standard HTTP requests.
1 Architecture Overview
The todo app has two halves that communicate over HTTP:
The directory structure looks like this:
The module controller handles data operations (list, add, toggle, delete). The ShadowComponent handles the interactive UI (rendering the list, capturing input, sending requests). This clean separation means either half can be replaced independently.
2 The Module Controller
The Todo module uses an in-memory array for simplicity — no database required. Each endpoint returns JSON when called with the appropriate Accept header, which is exactly what the frontend component sends via fetch().
<?php
namespace Zero\Module;
use Zero\Core\Attribute\AllowedMethods;
use Zero\Core\HTTPError;
class Todo extends \Zero\Core\Module
{
/**
* In-memory todo storage (session-based for this example)
*/
private function getTodos(): array {
if (!isset($_SESSION['todos'])) {
$_SESSION['todos'] = [
['id' => 1, 'text' => 'Learn Zero Framework', 'done' => true],
['id' => 2, 'text' => 'Build a ShadowComponent', 'done' => false],
['id' => 3, 'text' => 'Combine both in an app', 'done' => false],
];
}
return $_SESSION['todos'];
}
private function saveTodos(array $todos): void {
$_SESSION['todos'] = array_values($todos);
}
/**
* Page: renders the view with the <todo-list> component
* GET /todo
*/
#[AllowedMethods('GET')]
public function index() {
$this->respond($this->viewPath . 'index.php');
}
/**
* API: return all todos as JSON
* GET /todo/list
*/
#[AllowedMethods('GET')]
public function list() {
$this->data = ['todos' => $this->getTodos()];
$this->respond($this->viewPath . 'index.php');
}
/**
* API: add a new todo
* POST /todo/add
*/
#[AllowedMethods('POST')]
public function add() {
$text = trim($_POST['text'] ?? '');
if ($text === '') {
throw new HTTPError(400, 'Todo text is required');
}
$todos = $this->getTodos();
$maxId = empty($todos) ? 0 : max(array_column($todos, 'id'));
$todos[] = [
'id' => $maxId + 1,
'text' => htmlspecialchars($text, ENT_QUOTES, 'UTF-8'),
'done' => false,
];
$this->saveTodos($todos);
$this->data = ['todos' => $todos];
$this->respond($this->viewPath . 'index.php');
}
/**
* API: toggle a todo's done state
* POST /todo/toggle/{id}
*/
#[AllowedMethods('POST')]
public function toggle($id) {
$todos = $this->getTodos();
foreach ($todos as &$todo) {
if ($todo['id'] == $id) {
$todo['done'] = !$todo['done'];
break;
}
}
$this->saveTodos($todos);
$this->data = ['todos' => $todos];
$this->respond($this->viewPath . 'index.php');
}
/**
* API: delete a todo
* POST /todo/delete/{id}
*/
#[AllowedMethods('POST')]
public function delete($id) {
$todos = $this->getTodos();
$todos = array_filter($todos, fn($t) => $t['id'] != $id);
$this->saveTodos($todos);
$this->data = ['todos' => $todos];
$this->respond($this->viewPath . 'index.php');
}
}
Every endpoint calls $this->respond() which returns JSON when the request includes Accept: application/json (which fetch() sends). The same endpoints serve both the HTML page and the API — no separate API routes needed.
This example stores todos in $_SESSION to keep things simple. In a real application, you would use a database model. The controller pattern stays the same — just swap getTodos() and saveTodos() for model calls.
3 The View File
The view is minimal. Its only job is to place the <todo-list> component on the page and provide the API base URL:
<div class="todo-page">
<h1>Todo List</h1>
<p>A full-stack example using Zero Framework + ShadowComponent.</p>
<todo-list api-base="/todo"></todo-list>
</div>
The api-base attribute tells the component where to send its requests. This keeps the component reusable — it doesn't hardcode any URLs.
4 The todo-list ShadowComponent
The component handles all user interaction: displaying todos, adding new ones, toggling completion, and deleting items. Here is the planned structure:
<template>
<div class="todo-app">
<form class="add-form">
<input type="text" placeholder="What needs to be done?" />
<button type="submit">Add</button>
</form>
<ul class="todo-items">
<!-- Rendered dynamically from API data -->
</ul>
<div class="todo-footer">
<span class="todo-count"></span>
</div>
</div>
</template>
<style>
.todo-app {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
overflow: hidden;
max-width: 500px;
}
.add-form {
display: flex;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.add-form input {
flex: 1;
padding: 0.75rem 1rem;
background: transparent;
border: none;
color: inherit;
font-size: 1rem;
outline: none;
}
.add-form button {
padding: 0.75rem 1.25rem;
background: rgba(var(--accent-primary-rgb, 212, 98, 43), 0.2);
border: none;
color: var(--accent-primary, #d4622b);
cursor: pointer;
font-weight: 600;
transition: background 150ms ease;
}
.add-form button:hover {
background: rgba(var(--accent-primary-rgb, 212, 98, 43), 0.35);
}
.todo-items {
list-style: none;
padding: 0;
margin: 0;
}
.todo-item {
display: flex;
align-items: center;
padding: 0.6rem 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
gap: 0.75rem;
}
.todo-item.done .todo-text {
text-decoration: line-through;
opacity: 0.5;
}
.todo-text {
flex: 1;
cursor: pointer;
}
.delete-btn {
background: none;
border: none;
color: rgba(217, 83, 79, 0.6);
cursor: pointer;
font-size: 1.2rem;
padding: 0 0.25rem;
opacity: 0;
transition: opacity 150ms ease;
}
.todo-item:hover .delete-btn {
opacity: 1;
}
.todo-footer {
padding: 0.5rem 1rem;
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.4);
}
</style>
<script>
class TodoList extends ShadowComponent {
static get observedAttributes() {
return ['api-base'];
}
get defaults() {
return { 'api-base': '/todo' };
}
async onConnected() {
this.shadowRoot.querySelector('.add-form').addEventListener('submit', (e) => {
e.preventDefault();
this.addTodo();
});
await this.loadTodos();
}
async loadTodos() {
const res = await fetch(this['api-base'] + '/list', {
headers: { 'Accept': 'application/json' }
});
const data = await res.json();
this.renderTodos(data.todos || []);
}
async addTodo() {
const input = this.shadowRoot.querySelector('.add-form input');
const text = input.value.trim();
if (!text) return;
const form = new FormData();
form.append('text', text);
await fetch(this['api-base'] + '/add', {
method: 'POST',
headers: { 'Accept': 'application/json' },
body: form
});
input.value = '';
await this.loadTodos();
}
async toggleTodo(id) {
await fetch(this['api-base'] + '/toggle/' + id, {
method: 'POST',
headers: { 'Accept': 'application/json' }
});
await this.loadTodos();
}
async deleteTodo(id) {
await fetch(this['api-base'] + '/delete/' + id, {
method: 'POST',
headers: { 'Accept': 'application/json' }
});
await this.loadTodos();
}
renderTodos(todos) {
const list = this.shadowRoot.querySelector('.todo-items');
list.innerHTML = '';
todos.forEach(todo => {
const li = document.createElement('li');
li.className = 'todo-item' + (todo.done ? ' done' : '');
const text = document.createElement('span');
text.className = 'todo-text';
text.textContent = todo.text;
text.addEventListener('click', () => this.toggleTodo(todo.id));
const del = document.createElement('button');
del.className = 'delete-btn';
del.textContent = '\u00d7';
del.addEventListener('click', () => this.deleteTodo(todo.id));
li.appendChild(text);
li.appendChild(del);
list.appendChild(li);
});
const remaining = todos.filter(t => !t.done).length;
this.shadowRoot.querySelector('.todo-count').textContent =
remaining + ' item' + (remaining !== 1 ? 's' : '') + ' remaining';
}
}
</script>
5 How They Communicate
The communication pattern is straightforward — standard HTTP with JSON:
| Component Action | HTTP Request | Module Endpoint |
|---|---|---|
| Load initial list | GET /todo/list |
Todo::list() |
| Add a todo | POST /todo/add |
Todo::add() |
| Toggle completion | POST /todo/toggle/3 |
Todo::toggle('3') |
| Delete a todo | POST /todo/delete/3 |
Todo::delete('3') |
The key insight is that $this->respond() in Zero Framework automatically returns JSON instead of HTML when the request includes Accept: application/json. The fetch() API sends this header by default. This means every endpoint is both an HTML page and a JSON API with no extra code.
Unlike frameworks that require separate API controllers or route prefixes, Zero's dual-response system means the same endpoint serves both the page load (HTML) and the component's data requests (JSON). One controller, one set of routes.
6 Key Takeaways
- Clean separation — The module handles data and business logic. The component handles rendering and interaction. They communicate through HTTP.
- Dual responses —
$this->respond()serves HTML for page loads and JSON for component requests. No duplicate routes needed. - Component as integration point — The view file is minimal. It just places the component and passes configuration via attributes.
- Standard APIs — No custom RPC, no WebSocket, no GraphQL. Just
fetch()to the same URLs a browser would visit. - Session for prototyping — Use
$_SESSIONto build working prototypes quickly, then swap in a database model without changing the controller pattern.
Once the full implementation is live, you will be able to interact with the todo app directly on this page. In the meantime, you can copy the code above into a Zero Framework project and it will work as shown. Start with the HelloWorld walkthrough if you haven't already.