stave.dev

The lines upon which you compose Zero Framework maps URLs straight to PHP classes — no route file, no build step, no dependency tree. Twenty-four PHP files and 3,852 lines you can read in an afternoon. ShadowComponent and the Entity Framework are the optional layers that plug into it. terminal Start with Zero code Read the core school Examples

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() uppercases the first URL segment and requires modules/{Module}/{Module}.php if — and only if — that file 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 {}
modules/ Docs/ Docs.php the empty class above view/ install.php serves /docs/install, framed, 200 Index/ view/ about.php serves /about, framed, 200

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 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 (!isset($_SESSION['user'])) {
        throw new HTTPError(401);
    }
    if ($_SESSION['auth_level'] < 5) {
        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

#[AllowedMethods(['GET', 'POST'])]  // class level: runs for every endpoint
class Admin extends \Zero\Core\Module {

    #[AllowedMethods('POST')]        // narrows the class rule to POST alone
    #[RequireAuthLevel(5)]
    #[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. There are exactly two conventions: an attribute exposing apply($module) mutates module state, and anything else has handler() called to gate the request and 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], which is the apply() convention doing exactly that.

Nothing to install

The install is a git clone. There is no second step.

24 PHP files in core/
3,852 lines of framework
0 Composer or npm dependencies
33 lines of bootstrap
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 a two-line file_exists check — 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?

speed

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.

visibility

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.

music_note

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

Zero autoloads component HTML from a module's assets/component/ directory but ships no components, and it ships no model layer at all. These two fill those seats. Both are optional. Both are documented in full.

web ShadowComponent Zero's Response::getComponents() requires every component HTML file it finds in a module's assets/component/ directory. 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.
  • Shadow DOM encapsulation
  • Observable properties with auto-sync slots
  • Template-based rendering
  • 25+ ready-made components
  • Loaded by Zero, required by nothing
  • Read the ShadowComponent docs
    database Entity Framework Zero ships no Model class and no migrations; 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.
  • Two-table EAV storage
  • Auto-generated pivot view per type
  • Schema-on-write — add fields at runtime
  • Hand-code or programmatically define types
  • Unique constraints and default-value prefixes
  • Read the Entity Framework docs

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

    terminal Start with Zero