stave.dev

Zero Framework

Zero routing configuration. Zero bloat. Zero dependencies. The framework stays out of your way so you can write your application.

Quick Start

Create a PHP class in modules/, give it a method, and it's an endpoint. That's it.

Installation

There is no installer, and 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 bundler. no build.

The core/ directory of this site is a clone of that repository, so “read the source” is a literal invitation rather than a slogan. Arrange the rest of the tree around it — the bootstrap finds everything by path, so the shape of the directory is the configuration:

mysite/ zero/ core/ ← the clone. 24 PHP files, 3,852 lines. app/ config/ config.ini ← WEB_ROOT and SITE_NAME live here constants.ini database.ini ← may be left empty; nothing connects until asked frontend/ frame/ ← head.php, header.php, sideNav.php, footer.php www/ ← the document root. VIEW_PATH is above it, not in it. index.php ← the 33-line bootstrap, below .htaccess ← anything that is not a real file goes to index.php modules/ ← your application. one directory per module. Index/ Index.php

That bootstrap is the whole of the framework's setup. It is reproduced here in full, because there is no more of it to show:

<?php
// Zero Framework Bootstrap
// Locates the zero/ directory by splitting on $SiteName in the path.
// The www/ directory must live inside zero/app/frontend/www/
$SiteName = "zero";
$root_path = explode($SiteName, __DIR__);
if (($c = count($root_path)) == 1 || $c > 2) {
    trigger_error(
        "Cannot find zero libraries from index directory." .
        " If this is your preferred setup, rename index.alt.php" .
        " to index.php and specify paths manually." .
        " Otherwise, revisit the setup portion of the documentation."
    );
}

define("ZERO_ROOT", $root_path[0] . $SiteName . "/");
define("ROOT_PATH", $root_path[0]);
define("VIEW_PATH", ZERO_ROOT . "app/frontend/frame/");
define("MODULE_PATH", ZERO_ROOT . "modules/");

require ZERO_ROOT . "core/Application.php";

$app = new Zero\Core\Application();

$app->registerAutoloaders();
$app->parseRequest();
$app->defineConstants();

$app->run(
    \Zero\Core\Request::$module,
    \Zero\Core\Request::$endpoint,
    \Zero\Core\Request::$args
);

Four constants and four calls. Note the order: parseRequest() runs before defineConstants(), which is what lets a module carry its own config.ini — those constants are defined only on requests routed to that module, and they are defined before the module class is loaded.

Two things the bootstrap needs from you

The path split is literal. explode("zero", __DIR__) must produce exactly two pieces, so your document root has to sit at zero/app/frontend/www/ and the string zero must appear exactly once in the absolute path. If it appears twice or not at all, the bootstrap calls trigger_error() and tells you to rename index.alt.php and set the paths by hand. That alternate file does not ship in this checkout, so write your own: define the same four constants, require core/Application.php, and make the same four calls.

WEB_ROOT must be set in app/config/config.ini. It is the directory a module symlinks its own assets/ into on the first request to it. Without it, a module's CSS and JS have nowhere to be published.

What is Zero Framework?

Zero is a minimal PHP MVC framework that maps URLs directly to PHP classes. There's no routing table to maintain, no YAML configuration to write, no dependency injection container to wire up. You create a module class, give it public methods, and the framework handles everything else.

The entire framework core is a handful of PHP files you can read in an afternoon. When something breaks, you'll know where to look — because there's nowhere to hide.

Both halves of that are checkable in under a minute, which is the point of stating them. Search the tree for a route file: there is no routes/web.php, no routes.yaml, no route cache and no #[Route] attribute anywhere in the framework. Then search it for a dependency: there is no composer.json, no composer.lock, no vendor/, no package.json and no node_modules/. Composer is supported, not required — registerAutoloaders() pulls in vendor/autoload.php only if that file happens to exist, and nothing notices when it doesn't.

How It Works

URL Request
Application
modules/{Module}/
Module::endpoint()
Response

A URL like /user-profile/edit/42 automatically resolves to UserProfile::edit('42'). Kebab-case URLs map to camelCase class names and methods. Arguments in the URL path become method parameters.

The middle box is the whole trick. Application::run() uppercases the first URL segment and requires modules/{Module}/{Module}.php — if, and only if, that file exists — then calls the second segment as a method on the instance. The filesystem is the lookup table. That is why there is nothing to register when you add a page, and nothing to re-cache when you deploy one.

It is also the security boundary. isModule() only ever builds paths underneath modules/, so a URL cannot reach a class outside it. Core says so in its own comment: “Doing this ensures the controller can't call things outside of the module path.”

Every row below is a live URL on this site, resolved by that mechanism and nothing else. Click one and check:

URL Resolves to
/zero Zero::index() — no endpoint segment, so index is the default
/zero/routing Zero::routing()
/examples/hello-world Examples::helloWorld() — kebab-case converted to camelCase
/shadow/gallery Shadow::gallery()

Everything after /module/endpoint/ is sliced into an array and splatted into the method, so PHP's own signature is the request contract. Required, optional and defaulted segments are declared where the code lives instead of in a route pattern — and a URL that omits a required parameter raises ArgumentCountError, which run() converts into a 400 before your method body executes. You never write if (!isset($args[0])) return 400;.

Core Features

Minimal Example

<?php
namespace Zero\Module;

class Hello extends \Zero\Core\Module {

    public function index() {
        $this->respond($this->viewPath . 'index.php');
    }

    // The signature is the request contract. Drop the default from $name and
    // /hello/greet becomes a 400 before this method body ever runs.
    public function greet(string $name = 'world', int $times = 1) {
        // respond() reports whatever $status holds. Leave it unset and the
        // JSON envelope says "unknown" -- see the next section.
        $this->status = 'success';
        $this->data = ['name' => $name, 'times' => $times];
        $this->respond($this->viewPath . 'greet.php');
    }
}

That's a complete module. Visit /hello to see the index, or /hello/greet/claude to greet someone. Send an Accept: application/json header and the same endpoint returns JSON.

This page is a Zero module

The framework describing itself here is the one that rendered the sentence you are reading. This page is Zero::index() in modules/Zero/Zero.php; its view is modules/Zero/view/index.php. Ten public methods, ten pages — the nine cards above, plus this one.

So the dual-response claim does not need a diagram. Point curl at a sibling page and watch one method produce three different documents. Every command and every response below is real output from this site:

curl https://stave.dev/zero/routing

The full framed page: head, header, the view, sideNav, footer. Its <title> comes back as Routing — Zero Framework — stave.dev, composed by a #[PageMeta] attribute on the method and another on the class. Nothing in the view writes it.

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>

The same method, with every frame piece suppressed. Response::add() returns early whenever the request carried that header, so you get the view and nothing else — no separate partial, no second route.

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 in your endpoint and it shows up.

One method, three shapes. There is no /api/* twin, no second controller, no if ($request->ajax()) branch in the view and no serializer layer. respond() is the only response primitive in the framework, and it branches on the request headers.

The same is true when nothing is found. Ask for a page that does not exist and the 404 is rendered through the module that was already running, so it keeps that module's own frame, view path and assets — and a JSON client gets a JSON error body from the identical throw:

curl -i https://stave.dev/zero/nothing-here
# HTTP/2 404 -- rendered inside Zero's own layout, not a generic framework page

curl -H "Accept: application/json" https://stave.dev/zero/nothing-here

{"message":"Not Found","status":"error","code":404}

And that 404 is the last resort, not the first. When the requested method does not exist, Response::__call searches six candidate view paths before it gives up — the module's view/{endpoint}.php in both spellings, the same two under the original module name, and modules/Index/view/{module}.php for flat top-level pages — and renders whichever it finds, framed. A content page can cost one file and an empty class; you add a controller method later, only when the page needs logic.