The URL is the lookup
There is no routes file in this framework. There is no route cache, no route attribute, and nothing to register.
<?php
namespace Zero\Module;
class Hello extends \Zero\Core\Module {
public function index() {
$this->respond($this->viewPath . 'index.php');
}
public function greet(string $name, int $times = 1) {
// $status is what the JSON envelope reports; unset, it exports as "unknown"
$this->status = 'success';
$this->data = ['name' => $name, 'times' => $times];
$this->respond($this->viewPath . 'greet.php');
}
}
| /zero | Zero::index() |
| /zero/routing | Zero::routing() |
| /shadow/gallery | Shadow::gallery() |
| /examples/hello-world | Examples::helloWorld() (kebab → camelCase) |
| /playground | Playground::index() |
Application::run() capitalises the first URL segment and requires modules/{Module}/{Module}.php (or modules/{module}/{Module}.php) if — and only if — one of them exists, then calls the second segment on the instance. The filesystem is the lookup table, and it doubles as the security boundary: isModule() only ever builds paths under modules/, so a URL cannot reach a class outside it.
Everything after /module/endpoint/ is splatted into the method, so PHP's own signature is the request contract. /hello/greet/claude/3 calls greet('claude', 3), with the declared int coercing the segment. /hello/greet/claude uses the default. /hello/greet raises ArgumentCountError, which run() converts to a 400 before the method body executes.
One method. Three responses.
This page you are reading, /zero/routing, and every other page on stave.dev are the same method served three ways. Run the commands yourself.
curl https://stave.dev/zero/routing
The full framed HTML page — head, header, sideNav, footer.
curl -H "X-Requested-With: XMLHttpRequest" https://stave.dev/zero/routing
<div class="doc-header">
<h1>Routing</h1>
<p class="doc-lead">URLs map to classes automatically. No routing table. No configuration.</p>
</div>
Response::add() returns early for every frame piece when the request carried that header, so you get the view and nothing else.
curl -H "Accept: application/json" https://stave.dev/zero/routing
{"data":[],"status":"unknown","code":200}
data is empty because this endpoint sets no $this->data, and status reads "unknown" because it never assigned $this->status. respond() reports what it was handed, not what you hoped for. Name it and it shows up.
One method. No /api/* twin, no separate API controller, no if (request()->ajax()) branch, no serializer layer. respond() is the only response primitive; it branches on the request headers.
You can skip the controller
A content page costs one file.
<?php
namespace Zero\Module;
class Docs extends \Zero\Core\Module {}
When the requested method does not exist, Response::__call searches six candidate view paths — the module's view/{endpoint}.php in both camelCase and kebab-case spellings, the same two under the original module spelling, and modules/Index/view/{module}.php in both spellings for flat top-level pages — and renders whichever it finds inside the full layout. Add a controller method later, only when the page needs logic.
Only after all six candidates miss does it throw HTTPError(404), which unwinds to run() and calls renderError() on the module instance that was already resolved — so the error page keeps that module's own frame, view path and assets.
curl -i https://stave.dev/zero/nothing-here
# 404, rendered inside Zero's own layout
curl -H "Accept: application/json" https://stave.dev/zero/nothing-here
# {"message":"Not Found","status":"error","code":404}
Declared above the method, not wired in a kernel
Method security is one line above the method, where you will actually read it.
Instead of this
public function save() {
if (!\Zero\Core\User::isLoggedIn()) {
throw new HTTPError(401);
}
if (!\Zero\Core\User::hasPermission('allow.admin')) {
throw new HTTPError(403);
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
throw new HTTPError(405);
}
if (!isset($_POST['title'])) {
throw new HTTPError(400);
}
$title = strip_tags(trim($_POST['title']));
// ...finally, the actual logic
}
Do this
<?php
namespace Zero\Module;
use Zero\Core\Attribute\AllowedMethods;
use Zero\Core\Attribute\RequirePermission;
use Zero\Core\Attribute\RequiredParams;
use Zero\Core\Attribute\Sanitize;
#[AllowedMethods(['GET', 'POST'])] // class level: runs for every endpoint
class Admin extends \Zero\Core\Module {
#[AllowedMethods('POST')] // narrows the class rule to POST alone
#[RequirePermission('allow.admin')]
#[RequiredParams(['title'])]
#[Sanitize(['title'])]
public function save() {
// every check above already passed - just write the logic
}
}
Before dispatch, run() reflects the class and the target method, merges class-level attributes ahead of method-level ones, and runs each. Every one of them runs, so the effect is cumulative rather than an override — a class rule is the envelope, and a method rule narrows inside it. In rmvanda/zero.core there is one convention: each attribute has handler() called to gate the request, and it throws HTTPError to abort. No kernel, no pipeline object, no middleware registration list, no service container.
The <title> of every page on this site — including this one — is composed by #[PageMeta], and that attribute is stave.dev's own. It lives in this site's core/attribute/, it is not in rmvanda/zero.core yet, and it is the only file in this site's core/ that upstream does not carry. It needs no change to the framework to work: the dispatcher calls handler() on every attribute in the Zero\Core\Attribute namespace, so PageMeta supplies one and resolves its own values by reflection. Everything else under core/ is the published repository, byte for byte.
Nothing to install
The install is a git clone. There is no second step.
git clone git@github.com:rmvanda/zero.core mysite/zero/core # that is the install. no composer install, no npm install, no build.
No composer.json, no composer.lock, no vendor/, no package.json and no node_modules anywhere in the tree. Composer support is two file_exists checks — if a vendor/autoload.php happens to be there it gets loaded, and if it is not, nothing notices. The bootstrap defines four path constants and calls four methods. No dependency graph to audit, no lockfile to resolve, no transitive CVE surface, nothing to reinstall on deploy.
This documentation site loads Google Fonts and Material Symbols over the network. The framework does not — that is a choice made by stave.dev's frame/head.php, not by core.
Why Stave?
Zero Bloat
No dependency tree to audit. No node_modules. No build pipeline. The entire framework is a handful of PHP files you can read in an afternoon.
Own Your Stack
Every line is readable. Every decision is traceable. When something breaks, you'll know where to look — because there's nowhere to hide.
Compose, Don't Configure
URL maps to class. Filename maps to asset. Attribute maps to a check. Convention over configuration means you spend time building, not wiring.
The seats Zero leaves empty
Core's Response::getComponents(), called from your footer frame, requires every component HTML file in a module's assets/component/ directory and in the global app/frontend/www/shadow-component/components/ path hardcoded in core/Response.php. Core ships no components to put in either, and no model layer at all. These two fill those seats. Both are optional. Both are documented in full.
Response::getComponents(), called from the footer frame, requires every component HTML file in a module's assets/component/ directory and in app/frontend/www/shadow-component/components/. ShadowComponent is what stave.dev puts there — a base class for Web Components with no build step, no bundler and no virtual DOM. Swap it for anything, or use nothing.
Database is a lazy PDO singleton that never opens a connection until something asks. The Entity Framework is one way to fill that gap — an EAV layer where schema, records and class definition are one mechanism. Plain PDO is the other way, and Zero is happy with either.
Create a PHP class in modules/, give it a method, and it's an endpoint.
Routing · Attributes · Errors · Examples · Playground · ShadowComponent · Entity Framework