First application
A form, a list and a test, end to end.
You build a page that lists notes kept in the session, with a form that adds one. It uses the pieces most applications need: a controller, a Latte layout, CSRF protection, validation, a flash message and a redirect after the form is posted. There is no database.
Start from an empty directory and the composer.json of the installation page, with PHPUnit added:
composer require --dev phpunit/phpunit
Add "autoload-dev": { "psr-4": { "Tests\\": "tests/" } } to composer.json and run composer dump-autoload.
Create phpunit.xml at the project root:
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
failOnWarning="true"
failOnRisky="true">
<testsuites>
<testsuite name="app">
<directory>tests</directory>
</testsuite>
</testsuites>
</phpunit>
Configure
Declare what the application promises. Each security key is enforced by a middleware you mount in the next step, and build() refuses to boot if one is missing.
application:
environment: !env APP_ENV, production
debug: !env APP_DEBUG, false
session:
name: NOTES_SESSION
same_site: Lax
security:
max_body_size: 1048576
csrf:
enabled: true
headers:
x_frame_options: DENY
referrer_policy: same-origin
localization:
locale: en
timezone: UTC
The environment defaults to production, so an unset APP_ENV never means development: you set APP_ENV=dev on your machine. debug defaults to off. The session cookie is named NOTES_SESSION and is SameSite=Lax. See Configuration.
Bootstrap
The bootstrap class, app/Kernel.php, builds the application and mounts the middleware, in order. The first one added runs outermost.
<?php
declare(strict_types=1);
namespace App;
use Zephyrus\Core\Application;
use Zephyrus\Core\ApplicationBuilder;
use Zephyrus\Core\Config\Configuration;
use Zephyrus\Http\Request;
use Zephyrus\Rendering\LatteEngine;
use Zephyrus\Routing\Router;
use Zephyrus\Security\CsrfConfig;
use Zephyrus\Security\CsrfMiddleware;
use Zephyrus\Security\MaxBodySizeMiddleware;
use Zephyrus\Security\SecureHeadersMiddleware;
use Zephyrus\Session\SessionCsrfTokenManager;
use Zephyrus\Session\SessionManager;
use Zephyrus\Session\SessionMiddleware;
final class Kernel
{
public static function run(string $root): void
{
$config = Configuration::fromYamlFile($root . '/config.yml');
// fromGlobals() does not read config.yml: pass the proxy settings yourself.
$request = Request::fromGlobals(
trustedProxies: $config->security->trustedProxies,
trustedHeaders: $config->security->trustedHeaders,
);
self::application($root, $config)->handle($request)->send();
}
public static function application(string $root, Configuration $config): Application
{
$session = new SessionManager();
$tokens = new SessionCsrfTokenManager($session);
$views = new LatteEngine($root . '/app/Views', $root . '/cache/latte');
$views->getLatteEngine()->addFunction('csrfToken', $tokens->getToken(...));
$router = (new Router())->discoverControllers('App\\Controllers', $root . '/app/Controllers');
// The first middleware added runs outermost. Each declared security key
// (headers, max_body_size, csrf) must be enforced here or build() throws.
return ApplicationBuilder::create()
->withConfiguration($config, basePath: $root)
->withRouter($router)
->withRenderEngine($views)
->withMiddleware(new SecureHeadersMiddleware($config->security->headers))
->withMiddleware(new MaxBodySizeMiddleware($config->security->maxBodySize))
->withMiddleware(new SessionMiddleware($config->session, $session))
->withMiddleware(new CsrfMiddleware($tokens, CsrfConfig::fromSecurityConfig($config->security)))
->build();
}
}
public/index.php calls it:
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
App\Kernel::run(dirname(__DIR__));
SecureHeadersMiddlewareadds the security headers declared undersecurity.headers.MaxBodySizeMiddlewarerefuses request bodies oversecurity.max_body_size.SessionMiddlewarestarts the session for the request. It comes before CSRF, which stores its token there.CsrfMiddlewarerefuses a state-changing request that does not carry the token. The view callscsrfToken()to render it.
Details are in Application, Middleware and Security.
The controller
<?php
declare(strict_types=1);
namespace App\Controllers;
use Zephyrus\Controller\Controller;
use Zephyrus\Http\Request;
use Zephyrus\Http\Response;
use Zephyrus\Rendering\RenderResponses;
use Zephyrus\Routing\Attribute\Get;
use Zephyrus\Routing\Attribute\Post;
use Zephyrus\Session\Flash;
use Zephyrus\Validation\FormValidator;
use Zephyrus\Validation\Rules;
final class NoteController extends Controller
{
use RenderResponses;
#[Get('/')]
public function index(): Response
{
return $this->renderPage();
}
#[Post('/notes')]
public function add(Request $request): Response
{
$form = new FormValidator([
'text' => [Rules::required(), Rules::notBlank(), Rules::maxLength(120)],
]);
$errors = $form->validate($request->body()->all());
if ($errors->hasErrors()) {
return $this->renderPage($errors->allMessages(), 422);
}
$notes = session('notes', []);
$notes[] = trim((string) $request->body()->get('text'));
session(['notes' => $notes]);
Flash::success('Note added.');
return $this->redirect('/');
}
/**
* @param list<string> $errors
*/
private function renderPage(array $errors = [], int $status = 200): Response
{
return $this->render('notes', [
'notes' => session('notes', []),
'errors' => $errors,
'flash' => Flash::readAll(),
], $status);
}
}
#[Get('/')]and#[Post('/notes')]register the routes (see Routing).FormValidatorandRulescheck the input (see Validation).Rules::required()accepts a string of spaces, sonotBlank()is added.- On errors the page is rendered again with a
422status and the messages. On success the note is stored withsession(), a flash message is queued, and the response is a redirect: a browser that reloads the page does not post the form again. Flash::readAll()reads the queued messages and clears them. See Sessions.
The note text is trimmed before it is stored, and it is escaped when displayed.
The views
app/Views/layout.latte holds the page frame:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{block title}Notes{/block}</title>
</head>
<body>
<main>
{block content}{/block}
</main>
</body>
</html>
app/Views/notes.latte fills it:
{layout 'layout.latte'}
{block title}My notes{/block}
{block content}
<h1>My notes</h1>
{foreach $flash->success as $message}
<p role="status">{$message}</p>
{/foreach}
{foreach $errors as $error}
<p role="alert">{$error}</p>
{/foreach}
<ul>
{foreach $notes as $note}
<li>{$note}</li>
{else}
<li>No notes yet.</li>
{/foreach}
</ul>
<form method="post" action="/notes">
<input type="hidden" name="_csrf_token" value="{csrfToken()}">
<label>Note <input name="text" maxlength="120" required></label>
<button>Add</button>
</form>
{/block}
Latte escapes {$note} and every other output. There is no automatic token injection: the form carries the CSRF field itself. See Views.
Run it
APP_ENV=dev php -S 127.0.0.1:8000 -t public
Open http://127.0.0.1:8000/, add a note, and the page redirects to the list with "Note added." shown once. Posting the form without the token is refused:
curl -i -d "text=Milk" http://127.0.0.1:8000/notes
HTTP/1.1 403 Forbidden
Test it
Application::handle() turns a Request into a Response in process, so a test needs no server.
<?php
declare(strict_types=1);
namespace Tests;
use App\Kernel;
use PHPUnit\Framework\TestCase;
use Zephyrus\Core\App;
use Zephyrus\Core\Application;
use Zephyrus\Core\Config\Configuration;
use Zephyrus\Http\Request;
final class NotesTest extends TestCase
{
private Application $app;
protected function setUp(): void
{
$root = dirname(__DIR__);
$this->app = Kernel::application($root, Configuration::fromYamlFile($root . '/config.yml'));
}
protected function tearDown(): void
{
$_SESSION = [];
App::reset();
}
public function testAnAddedNoteIsListedAfterTheRedirect(): void
{
$page = $this->app->handle(Request::fromArray('GET', '/'));
preg_match('/name="_csrf_token" value="([0-9a-f]+)"/', $page->body, $found);
$added = $this->app->handle(Request::fromArray('POST', '/notes', body: ['text' => 'Milk', '_csrf_token' => $found[1]]));
$list = $this->app->handle(Request::fromArray('GET', '/'));
self::assertSame(302, $added->status);
self::assertSame('/', $added->getHeader('Location'));
self::assertStringContainsString('<li>Milk</li>', $list->body);
self::assertStringContainsString('Note added.', $list->body);
}
public function testAPostWithoutTheTokenIsRefused(): void
{
$response = $this->app->handle(Request::fromArray('POST', '/notes', body: ['text' => 'Milk']));
self::assertSame(403, $response->status);
}
}
APP_ENV=dev vendor/bin/phpunit
OK (2 tests, 5 assertions)
The test reads the token from the rendered form, as a browser would, then posts it. The application is built in setUp(), so every test starts from a fresh one: App::reset() clears the statics between tests and only build() installs them again. $_SESSION = [] in tearDown() matters too: in process there is no cookie, and the PHP session lives for the whole process, so a note added by one test would show in the next. The testing page goes further.
Where to go next
- Routing and Controllers: parameters, groups and responses.
- Data: replace the session with PostgreSQL.
- Errors: what the client sees when something fails.