Skip to content
Greg Bowler edited this page May 18, 2026 · 5 revisions

Almost every language or framework ends up with a to-do list tutorial somewhere. The reason is simple: it exercises just enough of the stack to be useful without becoming overwhelming.

Here, the to-do list introduces form handling, state, repeated output, and a little application structure.

What we'll build

This version keeps persistence simple by storing the list in a local database using SQLite. That lets us focus on the WebEngine workflow without the complexities of adding a remote data source, but while being representative of a real world application.

The tutorial introduces:

  • form handling
  • database-backed state
  • list rendering
  • a small model represented by application classes

Build stages

The order of the tutorial is:

  1. add a form to create items
  2. handle the button clicks
  3. represent the list and items with application classes
  4. persist the data with SQL
  5. render the list back into the page

This is usually the point where the value of thin page logic becomes very clear, because the page needs to coordinate several actions while still staying readable.

Step 1 - add a form to create items

In the index.html, let's mark up the layout of a repeating list of to-do items:

<!doctype html>

<html lang="en">
<head>
	<meta charset="utf-8" />
	<meta name="viewport" content="width=device-width, initial-scale=1" />
	<title>To do list</title>
</head>
<body>

<h1>To do list</h1>
<p>What do you want to do?</p>

<ul>
	<li data-bind:class="status">
		<form method="post">
			<input type="hidden" name="id" data-bind:value="id" />
			
			<label>
				<input type="checkbox" name="completed" data-bind:checked="?completed" />
			</label>
			
			<label>
				<input name="title" required data-bind:value="title" autocomplete="off" />
			</label>
			
			<button name="do" value="save">Save</button>
			<button name="do" value="delete">Delete</button>
		</form>
	</li>
</ul>

</body>
</html>

Taking a look at this page in the browser looks fairly underwhelming, and of course there's no interactivity yet, but take note of all the elements and their attributes. It should be clear where data will be bound to the page when we hook it up to PHP. Let's go over some of the points in the HTML:

The <li> element has the following attrubute: data-bind:class="status". This means the element's class attribute will be set to the status of the item being bound. This is covered in Binding data to the DOM.

The hidden input has its value attribute bound to the id of the item being bound, and the checkbox gets the checked attribute set to the item's completed property. The last thing being bound is the title input - the value attribute will be bound to the title of the item.

At the bottom of the form are two buttons, both with name attributes set to do. We'll go over those in more detail later.

Step 1A - make it look good!

This isn't a CSS tutorial, so we'll just paste in the CSS to the style/style.css file. Feel free to read through the CSS and its comments, but we'll not cover the ins and outs of CSS here.

CSS file contents
/** Here is where all the default palette variables are defined **/
:root {
	--pal-base: #3f51b5;
	--pal-base-strong: #2f3f9f;
	--pal-base-soft: #e7eafd;
	--pal-page-bg1: #eef2ff;
	--pal-page-bg2: #f7f9fc;

	--pal-surface: rgba(255, 255, 255, 0.82);
	--pal-surface-strong: #ffffff;
	--pal-border: rgba(63, 81, 181, 0.14);
	--pal-shadow: 0 18px 40px rgba(48, 63, 159, 0.14);
	--pal-shadow-soft: 0 8px 18px rgba(31, 41, 55, 0.08);

	--pal-text: color-mix(in srgb, black, var(--pal-base));
	--pal-text-muted: #667085;

	--pal-danger: #c62828;
	--pal-danger-soft: #fdecec;

	--radius-panel: 1.5rem;
	--radius-row: 1rem;
}

/** html and body set the basics, along with a gradient background on the page */
html {
	font-size: 20px;
	height: 100%;
}

body {
	height: 100%;
	margin: 0;
	font-family: sans-serif;
	color: var(--pal-text);
	background-image:
		radial-gradient(circle at top left, rgba(63, 81, 181, 0.18), transparent 28rem),
		radial-gradient(circle at bottom right, rgba(79, 195, 247, 0.16), transparent 24rem),
		linear-gradient(160deg, var(--pal-page-bg1), var(--pal-page-bg2) 45%, #ffffff);
	background-position: bottom;
}

h1, p {
	text-align: center;
}

h1 {
	margin: 0;
	margin-top: 2rem;
	font-size: clamp(2.4rem, 5vw, 3.6rem);
	font-weight: bold;
	color: var(--pal-text);
}

p {
	margin: 0.75rem auto 2rem;
	max-width: 24rem;
	font-size: 0.92rem;
	line-height: 1.6;
	color: var(--pal-text-muted);
}

button {
	/*
	All buttons should take on a consistent look and feel, but below each
	button has its own style applied.
	 */
	min-width: 4rem;
	padding: 0;
	margin-left: 0;
	border: 0;
	border-radius: 999px;
	font: inherit;
	font-size: 0.78rem;
	font-weight: 700;
	letter-spacing: 0.03em;
	cursor: pointer;
	align-items: center;
	justify-content: center;
	transition:
		transform 160ms ease,
		box-shadow 160ms ease,
		background-color 160ms ease,
		color 160ms ease;

	&:hover {
		transform: translateY(-1px);
	}

	&:active {
		transform: translateY(0);
	}

	&:focus-visible {
		outline: none;
		box-shadow: 0 0 0 4px rgba(63, 81, 181, 0.18);
	}

	/*
	The individual buttons can be identified by their value attributes.
	 */
	&[value=save] {
		background: linear-gradient(135deg, var(--pal-base), var(--pal-base-strong));
		color: white;
		box-shadow: 0 10px 22px rgba(63, 81, 181, 0.28);

		&:hover {
			box-shadow: 0 14px 28px rgba(63, 81, 181, 0.32);
		}
	}

	&[value=delete] {
		background-color: transparent;
		color: var(--pal-danger);

		&:hover {
			background-color: var(--pal-danger-soft);
		}
	}
}

/*
The ul represents the entire list. Note that the list includes the "new" to do
item at the bottom of the page, which is styled slightly differently.
 */
ul {
	max-width: 32rem;
	margin: 0 0.5rem;
	padding: 0.5rem;
	list-style-type: none;
	border: 1px solid rgba(255, 255, 255, 0.7);
	border-radius: var(--radius-panel);
	background-color: var(--pal-surface);
	backdrop-filter: blur(18px);
	box-shadow: var(--pal-shadow);

	li {
		border-radius: var(--radius-row);
		transition:
			transform 160ms ease,
			background-color 160ms ease,
			box-shadow 160ms ease;

		&:hover {
			background-color: rgba(63, 81, 181, 0.05);
		}

		button[value=save] {
			display: none;
		}

		button[value=delete] {
			display: none;
		}

		label {
			&:first-of-type {
				display: flex;
				justify-content: center;
				width: 2rem;
			}
		}

		/*
		Here's the definition for the "new" item, at the bottom of the list.
		We don't want to display the checkbox or the delete button (because there's no
		item to check or delete yet!)
		 */
		&.new {
			input[type=checkbox] {
				display: none;
			}

			input[name=title] {
				border: 1px solid rgba(63, 81, 181, 0.24);
				background-color: color-mix(in srgb, var(--pal-base), white 95%);
				box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9);
			}

			button[value=save] {
				display: inline-flex;
			}

			button[value=delete] {
				display: none;
			}

			form:focus-within {
				button[value=delete] {
					display: none;
				}
			}
		}

		/*
		The final alteration is when a to do item is marked as completed, we should
		draw it in a different colour and strike out the text.
		 */
		&.completed {
			background-color: rgba(148, 163, 184, 0.08);

			input[name=title] {
				text-decoration: line-through;
				color: color-mix(in srgb, var(--pal-text-muted), white 10%);
			}
		}


	}
}

/*
Here we're breaking out of the nesting to define some form styling that's
applied to each item in the list.
 */
form {
	display: flex;
	justify-content: stretch;
	align-items: stretch;
	gap: 0.5rem;
	padding: 0.5rem;
	accent-color: var(--pal-base);
	border-radius: var(--radius-row);
	transition:
		background-color 160ms ease,
		box-shadow 160ms ease,
		transform 160ms ease;

	&:has(input[name=title]:focus,button:focus) {
		background-color: rgba(255, 255, 255, 0.78);
		box-shadow: var(--pal-shadow-soft);
		transform: translateY(-1px);

		button[value=delete] {
			display: inline-flex;
		}
	}

	label:nth-of-type(1) {
		display: flex;
		align-items: center;
		justify-content: center;
		padding-left: 0.25rem;
	}

	label:nth-of-type(2) {
		flex-grow: 1;
	}

	input[type=checkbox] {
		width: 1.2rem;
		height: 1.2rem;
		cursor: pointer;
	}

	input[name=title] {
		box-sizing: border-box;
		width: 100%;
		padding: 0.85rem 1rem;
		font: inherit;
		font-size: 0.92rem;
		line-height: 1.3;
		color: var(--pal-text);
		border: 1px solid transparent;
		border-radius: 0.9rem;
		background-color: transparent;
		transition:
			border-color 160ms ease,
			background-color 160ms ease,
			box-shadow 160ms ease;

		&::placeholder {
			color: #94a3b8;
		}

		&:hover {
			background-color: rgba(255, 255, 255, 0.7);
		}

		&:focus {
			outline: none;
			border-color: rgba(63, 81, 181, 0.45);
			background-color: var(--pal-surface-strong);
			box-shadow:
				0 0 0 4px rgba(63, 81, 181, 0.12),
				0 6px 18px rgba(63, 81, 181, 0.12);
		}
	}
}

/*
The styling is complete at this point, but so far everything's been designed
to display on a small screen - here we can add some extra styles for when larger
screens are available. This is referred to as mobile first design.
A media query only applies the styles within once a certain width is reached.
 */
@media(min-width: 42rem) {
	html {
		font-size: 24px;
	}
	h1 {
		margin-top: 4rem;
	}
	ul {
		margin: 0 auto;
		padding: 1rem;
	}
	form {
		gap: 1rem;
		padding: 1rem;
	}
	button {
		min-width: 5rem;
	}
}

Once the CSS file is in place, we can import it by adding the following line to our <head> element, straight after the <title>:

<link rel="stylesheet" href="/style/style.css" />

And one last thing to do to add to the client-side responsiveness is automatically click the save button whenever a change is made to a to-do item. This prevents the user from tabbing out and forgetting to save.

Change the opening <form> tag to the following:

<form method="post" onchange="this.querySelector('[value=save]').click()">

Step 2: handle the button clicks

Inside index.php, we can add a function that's fired when the save button is pressed called do_save. Because the button in the HTML has a name attribute of do, and a value attribute of save, this function is invoked automatically by WebEngine whenever the user clicks the corresponding button.

function do_save():void {
}

So, what needs to go inside the function?

Well, first we need to request the class dependencies from the service container. This is done by including the class names as parameters. We'll use our own class called TodoList to represent the list, the Input class to get the user input from the browser, and the Response class to reload the page.

Import these classes like this:

use App\Todo\TodoList;
use GT\Input\Input;
use GT\Http\Response;

function do_save(TodoList $todoList, Input $input, Response $response):void {
}

Keep in mind that we haven't created the TodoList class yet, so refreshing the page now will cause an error.

The contents of the do function can be kept short and simple: if there's an ID supplied in the input, we shall update the existing record. If there's not an ID, we shall create a new record. After we're done, reload the page:

function do_save(TodoList $todoList, Input $input, Response $response):void {
	if($id = $input->getString("id")) {
// If there's an ID, we shall update the existing record.
		$todoList->update(
			$id,
			$input->getString("title"),
			$input->getBool("completed")
		);
	}
	else {
// If there's not an ID, we shall create a new record.
		$todoList->create(
			$input->getString("title"),
		);
	}

	$response->reload();
}

The above code should be immediately readable by any developer, and the intentions of the code should be clear.

We can add the handler for the delete button too:

function do_delete(TodoList $todoList, Input $input, Response $response):void {
	$todoList->delete($input->getString("id"));
	$response->reload();
}

Step 3: represent the list and items with application classes

We're going to create two classes: TodoList and TodoItem. Their titles should explain what their responsibilities are.

Let's create the simpler of the two first, TodoItem. It will be a simple data-transfer-object, with public properties for id, title and completed, and a single function for getting its status. Create it at class/Todo/TodoItem.php:

<?php
namespace App\Todo;

use GT\DomTemplate\BindGetter;

class TodoItem {
	public function __construct(
		public readonly string $id,
		public readonly string $title,
		public readonly bool $completed,
	) {}

	#[BindGetter]
	public function getStatus():string {
		if($this->id === "") {
			return "new";
		}
		if($this->completed) {
			return "completed";
		}

		return "";
	}
}

The three properties will automatically bind to the document because they're public, and the getStatus function has been attributed as a BindGetter, meaning the document can bind the status key and it'll call this function to get the status. This probably isn't necessary here, but it's worth showing as part of the complete tutorial.

Next, we'll implement the TodoList class. It'll remain as simple as possible, but will need to have a function for every operation we can perform on the list: getAll will return a list of TodoItem objects, update will update the title and completed status of an item, create will create a new item, and delete will delete an item.

All of these functions will defer the work to an SQL file within the query directory, and to let the code work with the TodoItem class from above, we'll have another function called rowToToDoItem that converts a database row to our TodoItem class. This is a common technique in the Repository-Entity pattern.

class/Todo/TodoList.php:

<?php
namespace App\Todo;

use Gt\Database\Database;
use Gt\Database\Result\Row;
use Gt\Ulid\Ulid;

class TodoList {
	public function __construct(
		private readonly Database $db,
	) {
	}

	/** @return array<TodoItem> */
	public function getAll():array {
		$todoItemArray = [];

		foreach($this->db->fetchAll("todo/retrieve") as $row) {
			array_push($todoItemArray, $this->rowToTodoItem($row));
		}

		return $todoItemArray;
	}

	public function update(string $id, string $title, ?bool $completed):void {
		$this->db->update("todo/update", $title, $completed ?? false, $id);
	}

	public function create(string $title):void {
		$id = new Ulid();
		$this->db->insert("todo/create", $title, $id);
	}

	public function delete(string $id):void {
		$this->db->delete("todo/delete", $id);
	}

	private function rowToToDoItem(?Row $row = null):?TodoItem {
		if(!$row) {
			return null;
		}

		return new TodoItem(
			$row->getString("id"),
			$row->getString("title"),
			$row->getBool("completed"),
		);
	}
}

The class is constructed with reference to the Database - but where are we constructing the class from? That's done by our Service container, to make the TodoList class available anywhere in our page logic.

Create the ServiceLoader class at class/ServiceLoader.php:

<?php
namespace App;

use App\Todo\TodoList;
use Gt\Database\Database;
use GT\WebEngine\Service\DefaultServiceLoader;

class ServiceLoader extends DefaultServiceLoader {
	public function loadTodoList():TodoList {
		return new TodoList(
			$this->container->get(Database::class)
		);
	}
}

That's all the application class coding done, but to see our application working, we need to render the list to the page.

Step 4: persist the data with SQL

We're going to use an SQL database to persist the data. For simplicity, we'll use SQLite which stores the database as a single file in the project's root directory.

There will be only one table required: todo, and the table will have three fields: id, title and completed - just like our TodoItem class.

To create the table, we create a numbered file in the query/_migration directory. The point of a migration is so large projects can have all their database changes tracked over time. Even though we're just adding one, we'll do it anyway as it's a good habit to form.

query/_migration/0001-todo.sql:

create table todo
(
    id text constraint todo_pk primary key,
    title text not null,
    completed integer default 0 not null
);

Then run gt migrate in the terminal to set the database up. This command only has to be run once after a migration file has been added.

There are four database queries to write: create, retrieve, update, delete. The names are intentional - this is a simple CRUD application.

query/todo/create.sql:

insert into todo (title, id)
values (?, ?)

query/todo/retrieve.sql:

select
	id,
	title,
	completed
from
	todo

query/todo/update.sql:

update todo
set
	title = ?,
	completed = ?

where
	id = ?

query/todo/delete.sql:

delete from todo
where id = ?

Step 5: render the list back into the page

With the above code in place, we have a database-driven to-do list... it just doesn't do anything yet! But there's only a few tweaks to the code needed now to complete everything.

Refresh the page now in the browser, and you'll see an empty to do list with no functionality. Let's change that.

The first thing we need is to mark the <li> element in the HTML with the data-list attribute. Adding this attribute tells PHP to extract the element from the page, so in our last bit of code we can provide the Binder with the TodoList, and for each item in the list, the <li> element will be cloned - automatically having all the bind attributes bound for us.

The changed line of HTML looks like this:

<li data-list data-bind:class="status">

Now refreshing the page does something different: removes the item from the <ul> element, so the list is completely empty. That shows that the list binder is working - there should be no elements in the list - because we haven't told it to bind the list yet.

The final piece to complete this project is to output the list when the page renders. This is done in the go function of the index.php.

As before, we'll use the parameters of the go function to request the TodoList, the Binder and the HTMLDocument from the service loader. The TodoList already has all the functionality we need - the getAll function returns a list of TodoItem objects from the database. We'll call getAll and then push one extra item into the list, representing the "new" to do item at the bottom of the page.

Finally, we'll bind the list of items, and use the HTMLDocument to set the autofocus attribute of the "new" item, so the cursor is automatically always flashing in the new item's input.

Here's the go function:

function go(TodoList $todoList, Binder $binder, HTMLDocument $document):void {
	$todoListItems = $todoList->getAll();
	array_push($todoListItems, new TodoItem("", "", false));
	$binder->bindList($todoListItems);
	$document->querySelector("li.new input[name='title']")->autofocus = true;
}

Taking it further

Once the database-backed version works, it is worth asking what changes are required if the list is to be shared between multiple users. We'll cover these questions in the address book tutorial next.

If you want to approach the same tutorial with tests first, there's also a todo list TDD tutorial.


We'll build on the code introduced in the todo list to build a full multi-user database-driven application in the address book tutorial. There's also an extension of this tutorial that adds testing from the start in todo list TDD tutorial.

Clone this wiki locally