Monad: One Real App Later
Almost four months ago I wrote Monad: A Single-File PHP Micro-Framework. Self-explanatory title: one index.php with routing, dependency injection, templates, a database wrapper and a test runner, built out of curiosity for PHP 8 metaprogramming.
Only thing missing was actually using it for something. And so I did, for some small apps on my homelab. Contact with reality followed, and the repo went from an untagged experiment to 0.1.0, 0.1.1 and 0.1.2.
I also rewrote this blog with it, so it feels right to write up what I learned.
The Framework Grew
First things first: the engine was 866 lines when I published the first post and it’s double that number now. So the "less than 1000 lines" claim from last time is dead, but it’s worth it, I swear.
The whole engine is still one file, renamed to monad.php, and it contains everything: you can still copy one file and have a working app.
What also grew was the built-in feature set, and all of it is opt-in and lazy. An app that only routes and renders still only pays for routing and rendering.
The Engine Is Not the App
The original index.php was both the framework and the app entry point. To decide whether to dispatch, it checked whether it was the first included file: get_included_files()[0] === __FILE__.
That worked when you cloned the repo. It fell apart the moment you embedded Monad in a real project. The engine anchored every path to its own directory, so the blog got the blog's routes but the framework's config, views, database, cache and migrations. That also made Monad awkward to consume as a Composer dependency, forcing it to be used as a project starter.
Now a Monad app looks like this:
app/
├── public/
│ └── index.php # app entry point
├── config.php # app settings
└── vendor/
└── samuelezoncada/
└── monad/
└── monad.php # framework
public/index.php boots the app at its root, registers a route and dispatches:
<?php
require dirname(__DIR__) . '/vendor/autoload.php';
$app = Monad\App::boot(dirname(__DIR__));
$app->addRoute('GET', '/', fn ($app) => $app->response->text('Hello World!'));
$app->dispatch();
Of course you can still use it without Composer, but requiring monad.php now returns $app and does nothing else. Error and exception handlers are installed by dispatch() instead of at include time, so requiring the engine from a cron script no longer hijacks the host process.
It’s less “clone the repo and you’re ready” and closer to what PHP developers are accustomed to.
Configuration
The old monad.ini is gone (didn’t know what I was doing when I decided to use it). Configuration now lives in config.php at the app root and returns an array:
return [
'debug' => false,
'db' => ['path' => 'db.sqlite'],
'auth' => [
'table' => 'users',
'identifier' => 'email',
],
// ...
];
The ini file used to be mirrored into putenv() and $_ENV, which meant two sources of truth and a credential-filter heuristic to stop secrets from leaking into child processes. Both are gone. Values live in $app->config only. The file is optional, it must stay pure data because the container re-executes it on every reset, and relative paths resolve from the app root rather than the current working directory.
Real App Requirements and the Opt-In Philosophy
A blog is boring in the right way: it needs a login, sessions that survive a redirect, CSRF protection on every write, uploads, schema changes and a bit of caching. I figured these could be fairly common “nice-to-haves”.
So Monad now ships authentication, flash messages, a filesystem cache, migrations, CSRF enforcement, Server-Sent Events and out-of-band HTMX swaps. All of it is opt-in.
The auth service is session-based and reads whatever user table you configure:
if ($app->auth->attempt($email, $password)) {
$app->response->redirect('/admin');
}
$app->addRoute('GET', '/admin', [AdminController::class, 'index'], ['requireAuth']);
requireAuth knows the difference between a browser request, a JSON client and an HTMX request. It redirects the first, answers 401 to the second and sends an HX-Redirect to the third. user() returns the row without the password column, and logging out regenerates the session.
Flash messages are the missing half of POST → redirect → GET:
$app->flash->set('success', 'Post saved.');
$app->response->redirect('/admin/posts');
<?php if ($view->flash->has('success')): ?>
<p><?= $view->flash->get('success') ?></p>
<?php endif; ?>
Migrations are plain PHP files with up and down closures, executed inside a transaction, with state in a _migrations table:
return [
'up' => fn ($db) => $db->execute(
'CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT UNIQUE, password TEXT)'
),
'down' => fn ($db) => $db->execute('DROP TABLE users'),
];
./monad migrate
./monad migrate:status
./monad migrate:rollback
The HTMX side also grew up. oob() appends an out-of-band fragment after the main response, htmxRedirect() navigates the browser instead of swapping HTML, and stream() gives you an SSE loop where $emit() returns false when the client disconnects.
MagicValue Shenanigans
What I thought was the cleverest part of the first version turned out to be the worst when I used it.
Every scalar passed to a template was wrapped in MagicValue, so that I could write ->number(2) and ->date('Y') and feel very smart about it. The problem is that escaping an integer, a float, a boolean or null does nothing. Wrapping them only distorted PHP in invisible ways:
$view->data->count > 0 // notice, promoted to ErrorException
if ($view->data->zero) // true, an object is always truthy
empty($view->data->zero) // false, an object is never empty
$view->data->price * 2 // TypeError, multiplying an object…
number_format($view->data->price) // TypeError, formatting an object…
I originally did all this to escape strings, and only strings can carry markup, so now only strings are wrapped. Numbers, floats, booleans and null stay native, which means normal PHP works again:
<!-- Before -->
<p>Balance: <?= $view->data->user->balance->number(2) ?> €</p>
<!-- After -->
<p>Balance: <?= number_format($view->data->user->balance, 2) ?> €</p>
MagicValue::number() and ->date() are gone; use number_format() and date(). For strings, ->default(), ->json() and ->raw() remain. Arrays are still wrapped so their elements are escaped on read, which means they are objects: use count() or isEmpty() instead of empty().
Lesson learned: clean, expressive syntax must not come at the cost of butchering the language’s coherence. This wouldn’t have been the case with a compiled template engine, of course, but I’ll leave Twig & Co. to the non-experimental frameworks.
Deleted Features
Not every change was an addition. I removed a handful of defaults that never earned their place:
- The built-in
/healthroute. An embedded app should not inherit a URL it never registered, and a user route should be able to own/health. - The global
logmiddleware. It is still there as$app->use('log'), but logging is a decision, not a default. $app->dd().dump()already does the job without the CLI/HTTP ambiguity../monad routes. At the time, the command only loaded the engine while routes were registered in the app entry, so it always reported "No routes registered".
One Less Copy of the Docs
The monad skill for AI agents got removed. It used to be a condensed copy of the README, in a not-really-portable .agents folder. Now the README is the canonical manual, and AGENTS.md stays operational rules for AI agents.
What Didn't Change
The core is still the same: MagicObject and singleton services, onion middleware, HTMX-first rendering, automatic escaping for strings, a built-in test runner, no build step.
It is still zero-dependency in the sense that matters: PHP 8.0+ with PDO and pdo_sqlite; sessions are only needed if you use sessions, flash, CSRF or auth. It is still experimental, still pre-1.0, and still a bad fit for a two-hundred-file enterprise monolith. MagicObject still trades static analysis for flexibility. Docblocks help, but they are not a type system.
Links
Monad is on GitHub, MIT licensed, with a changelog that documents every break.
composer require samuelezoncada/monad
If you build something with it, tell me what broke!