Controller & Templates

The final step is to create a controller and its templates. In the controller, we will use the Task state machine and the repository we defined earlier. So, before getting to the controller itself, let us look at a few examples of how to use the Smalldb state machines.

Browsing the state machine space

To load a list of all task, we ask the TaskRepository:

$taskRepository = new TaskRepository(...);
$tasks = $taskRepository->findAll();

Our simple Task repository does not have much more to offer. Larger applications may have dozens of such “find something” methods.

Smalldb Bundle also registers ReferenceValueResolver, which converts an ID from a route to a state machine reference. This allows us to simply use the Task $task argument of the controller methods.

class TaskController {
	/** @Route("/task/{id}/mark-done", name = "task_mark_done") */
	public function markDone(Task $task) { /* ... */ }
}

The resolver detects the Task argument, and because it finds an ID in the route, it creates a Task reference using the found ID.

Invoking a transition

To invoke a transition, we take a state machine reference, and call the corresponding method of the transition:

$task = $taskRepository->ref(1);
$task->editDescription($newDescription);
$task->markDone(new \DateTimeImmutable());
$task->markIncomplete();
$task->delete();

The question is, what to do when there is no task yet and thus we do not have a reference? In such cases, we need a state machine that is in the “Not Exists” state. To do so, we use a null reference to invoke the transition:

$taskRepository->ref(null)->add($description);

The null reference points to a random state machine of a given type, which is in the “Not Exists” state. The transitions invoked on null references usually change the null ID to a value assigned by the database. The reason behind this approach is a simple fact that state machines have no concept of constructors and destructors; therefore, all state machines exist all the time — we just do not store any data about those in the “Not Exists” state.

This also means that we can have a reference to any state machine, no matter whether it is in the “Not Exists” state or not. The ref() method will always succeed as long as the ID is a valid value. For example, let us assume the database holds the three tasks from our initial setup. Then we can read the states of the state machines:

$taskRepository->ref(1)->getState(); // Returns "Done"
$taskRepository->ref(2)->getState(); // Returns "Todo"
$taskRepository->ref(3)->getState(); // Returns "Todo"

// Tasks in the Not Exists state.
$taskRepository->ref(null)->getState(); // Returns "" (ReferenceInterface::NOT_EXISTS)
$taskRepository->ref(10)->getState(); // Returns ""

Note that the “Not Exists” state is denoted by an empty string (see ReferenceInterface::NOT_EXISTS constant).

If we wish to add a task of a particular ID, we can obtain a reference using this ID and invoke the “add” transition:

$taskRepository->ref(null)->add("Foo"); // Adds a task using AUTO INCREMENT.
$taskRepository->ref(10)->add("Bar");   // Adds a task with ID = 10.

If we try to read properties when a state machine is in the “Not Exists” state, we get a NotExistsException:

$taskRepository->ref(1)->getDescription(); // Returns "Get some chocolate".
$taskRepository->ref(null)->getDescription(); // Throws NotExistsException.
$taskRepository->ref(1000)->getDescription(); // Throws NotExistsException.

Rendering a button

To invoke a transition, we also need some user interface, for example, an “edit” button to open a form where we can change the description of the task. However, such a button should only appear when we actually can edit the description. To do so, we can use isTransitionAllowed() method in our template:

{% if task.isTransitionAllowed('editDescription') %}
    <a href="{{ path("task_edit_description", {id: task.id}) }}" class="button">
        Edit
    </a>
{% endif %}

This way the template asks the state machine definition whether a given action is allowed, but the template does not know anything about the conditions the transition needs to meet. It helps to keep the code clean and security-related conditions in a single place.

The Task Controller

The complete controller implementation may look like this:

<?php // src/Controller/TodoController.php
declare(strict_types = 1);

namespace App\Controller;

use App\StateMachine\Task;
use App\StateMachine\TaskRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;


class TodoController extends AbstractController
{

	/**
	 * @Route("/", name = "index")
	 */
	public function index(Request $request, TaskRepository $taskRepository)
	{
		$form = $this->createTaskForm("add");
		$form->handleRequest($request);

		if ($form->isSubmitted() && $form->isValid()) {
			['description' => $description] = $form->getData();
			$taskRepository->ref(null)->add($description);
			return $this->redirectToRoute('index');
		} else {
			$tasks = $taskRepository->findAll();
			return $this->render("todo.html.twig", [
				"form" => $form->createView(),
				"tasks" => $tasks,
			]);
		}
	}


	/**
	 * @Route("/task/{id}/edit-description", name = "task_edit_description")
	 */
	public function editDescription(Request $request, Task $task)
	{
		$form = $this->createTaskForm("save", $task);
		$form->handleRequest($request);

		if ($form->isSubmitted() && $form->isValid()) {
			['description' => $newDescription] = $form->getData();
			$task->editDescription($newDescription);
			return $this->redirectToRoute("index");
		} else {
			return $this->render("task_edit_description.html.twig", [
				'form' => $form->createView()
			]);
		}
	}


	private function createTaskForm(string $buttonName, ?Task $task = null): FormInterface
	{
		// We should define a TaskType class and use it to create the form,
		// but for now, we will create the form directly using the form builder.
		$formBuilder = $this->createFormBuilder([
			'description'=> $task ? $task->getDescription() : ""
		]);
		$formBuilder->add("description", TextType::class)
			->setRequired(true);
		$formBuilder->add($buttonName, SubmitType::class, ["attr" => ["class" => "add"]]);
		return $formBuilder->getForm();
	}


	/**
	 * @Route("/task/{id}/mark-done", name = "task_mark_done")
	 */
	public function markDone(Task $task)
	{
		$task->markDone(new \DateTimeImmutable());
		return $this->redirectToRoute('index');
	}


	/**
	 * @Route("/task/{id}/mark-incomplete", name = "task_mark_incomplete")
	 */
	public function markIncomplete(Task $task)
	{
		$task->markIncomplete();
		return $this->redirectToRoute('index');
	}


	/**
	 * @Route("/task/{id}/delete", name = "task_delete")
	 */
	public function delete(Task $task)
	{
		$task->delete();
		return $this->redirectToRoute('index');
	}

}

The Templates

Just to make our example complete, there are the templates.

The base of the HTML page

{# templates/base.html.twig #}
<!DOCTYPE html>
<html>
<head>
	<meta charset="UTF-8">
	<title>{% block title %}Welcome!{% endblock %}</title>
	<link rel="stylesheet" href="/style.css">
	{% block stylesheets %}{% endblock %}
</head>
<body>

<h1>To Do List</h1>

{% block body %}{% endblock %}
{% block javascripts %}{% endblock %}
</body>
</html>

The task list

{# templates/todo.html.twig #}
{% extends "base.html.twig" %}

{% block title %}To Do List{% endblock %}

{% block body %}
	<ul>
		<li class="form">
			{{ form_start(form) }}
			<span>
				{{ form_widget(form.description) }}
				{{ form_widget(form.add) }}
			</span>
			{{ form_end(form) }}
		</li>

		{% for t in tasks %}
			<li>
				{% if t.state == 'Todo' %}
					<form action="{{ path("task_mark_done", {id: t.id}) }}" method="post" class="checkbox">
						<button type="submit">
							&nbsp;
						</button>
					</form>
				{% else %}
					<form action="{{ path("task_mark_incomplete", {id: t.id}) }}" method="post" class="checkbox">
						<button type="submit">
							&nbsp;
							<svg width="2.5em" height="2.5em" version="1.1" viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg">
								<g transform="translate(-46.689 -78.673)">
									<path d="m54.036 142.94c18.169 11.851 27.12 24.895 35.478 43.09 18.249-53.363 40.137-77.975 76.398-101.2"
										fill="none" stroke="#4c8" stroke-width="20"/>
								</g>
							</svg>
						</button>
					</form>
				{% endif %}

				<span class="description">{{ t.description }}</span>

				{% if t.isTransitionAllowed('editDescription') %}
					<a href="{{ path("task_edit_description", {id: t.id}) }}" class="button">
						Edit
					</a>
				{% endif %}

				{% if t.isTransitionAllowed("delete") %}
					<form action="{{ path("task_delete", {id: t.id}) }}" method="post" class="button">
						<button type="submit" class="delete"
							onclick="return confirm('Do you wish to delete this task?\n' + {{ t.description|json_encode }})">
							Delete
						</button>
					</form>
				{% endif %}
			</li>
		{% endfor %}
	</ul>
{% endblock %}

The form to edit a task description

{# templates/task_edit_description.html.twig #}
{% extends "base.html.twig" %}

{% block title %}To Do List{% endblock %}

{% block body %}
	<ul>
		<li class="form">
			{{ form_start(form) }}
			<span>
				{{ form_widget(form.description) }}
				{{ form_widget(form.save) }}
			</span>
			{{ form_end(form) }}
		</li>
	</ul>
{% endblock %}

The Style

Finally, to make our application beautiful, we add some style:

/* public/style.css */

* {
	box-sizing: border-box;
}

body {
	display: flex;
	flex-flow: column;
	margin: 3em auto;
	padding: 0em 2em;
	max-width: 50em;
	color: #000;
	background: #fff;
	font-size: 12pt;
}

h1 {
	text-align: center;
	font-weight: normal;
	font-size: 300%;
	font-variant: small-caps;
	color: #aaa;
	margin: 0em 0em 1em 0em;
}

ul, li {
	display: flex;
	margin: 0em;
	padding: 0em;
}

ul {
	flex-flow: column nowrap;
	border: 1px solid #aaa;
	border-radius: 0.5em;
	background: #eee;
	box-shadow: 0em 0.2em 0.5em rgba(0, 0, 0, 0.2);
}

li {
	flex-flow: row nowrap;
	align-items: baseline;
	padding: 1.5em 1em;
}

li + li {
	border-top: 1px solid #aaa;
}

form, form span, li span {
	display: flex;
	flex-flow: row nowrap;
	align-items: baseline;
	margin: 0em;
	padding: 0em;
}

li.form form, form span, li span {
	flex-grow: 1;
}

input, button, a.button {
	border: 1px solid #aaa;
	border-radius: 0.25em;
	padding: 0.5em;
	font: inherit;
	margin: 0em 0.5em;
}

input {
	flex-grow: 1;
	background: #fff;
	color: #000;
}

input::placeholder {
	color: #aaa;
}

button, a.button {
	background: #aaa;
	color: #fff;
	min-width: 5em;
	text-align: center;
	text-decoration: none;
	cursor: pointer;
	flex-grow: 0;
}

button:hover, a.button:hover,
button:focus, a.button:focus {
	background: #888;
	border-color: #888;
}


.add {
	background: #4c0;
	border-color: #4c0;
	color: #fff;
}

.add:hover, .add:focus {
	background: #3a0;
	border-color: #3a0;
}

.delete:hover,
.delete:focus {
	background: #a30;
	border-color: #a30;
}

form.checkbox button {
	position: relative;
	background: #fff;
	border: 2px solid #aaa;
	width: 2.7em;
	min-width: 2.7em;
	margin-right: 1em;
}

form.checkbox button svg {
	position: absolute;
	width: 90%;
	height: 90%;
	top: 5%;
	left: 5%;
}