Alpha ZealPHP is early-stage and under active development. APIs may change between minor versions until v1.0. Feedback and bug reports welcome on GitHub.

Project Structure

Six directories and a single bootstrap. Here's where each kind of code goes.

You will learn

  • What lives in each ZealPHP directory — and why the convention exists
  • How to bootstrap a server in three lines
  • The decision rule: route vs api vs public vs src
  • How PSR-4 autoloading wires src/ to your namespace

The shape of a ZealPHP app

A fresh app (from composer create-project zealphp/project) lays out like this:

my-app/
├── app.php                ← bootstrap: middleware + $app->run()
├── composer.json          ← autoload "MyProject\\" => "src/"
├── public/                ← drop-in pages — filesystem is the router
│   ├── index.php
│   ├── about.php          → GET /about
│   └── css/site.css       → GET /css/site.css (static)
├── api/                   ← file-based REST API (auto-routed)
│   ├── users.php          → GET/POST /api/users
│   └── devices.php        → GET/POST /api/devices
├── route/                 ← explicit routes — loaded at startup
│   ├── ws.php             ← WebSocket endpoints
│   ├── streaming.php      ← $app->route() with custom paths
│   └── timers.php         ← App::tick() / after()
├── template/              ← view templates (rendered via App::render)
│   ├── _master.php
│   ├── components/
│   └── pages/
└── src/                   ← your business logic — PSR-4 autoloaded
    ├── Auth.php
    └── Notes.php

The three-line server

Every ZealPHP app begins with one file. Here’s the minimum viable bootstrap:

<?php
require_once __DIR__ . '/vendor/autoload.php';

ZealPHP\App::init('0.0.0.0', 8080)->run();

App::init() creates the server. $app->run() starts the event loop and blocks until you stop it. Add files to public/ and they become URLs immediately. No more configuration is required.

A realistic bootstrap

Real apps add middleware, the occasional inline route, maybe a Store table. app.php stays under 50 lines for most apps:

<?php
require_once __DIR__ . '/vendor/autoload.php';

use ZealPHP\App;
use ZealPHP\Store;
use ZealPHP\Middleware\{CorsMiddleware, ETagMiddleware, SessionStartMiddleware};

App::mode(App::MODE_COROUTINE);                     // coroutine mode (default for new apps)

$app = App::init('0.0.0.0', 8080);

$app->addMiddleware(new CorsMiddleware());          // outermost
$app->addMiddleware(new ETagMiddleware());
$app->addMiddleware(new SessionStartMiddleware());  // innermost (closest to handler)

// Shared-memory tables MUST be made before run() so workers inherit them.
Store::make('rate_limits', 10000, [
    'count' => [Store::TYPE_INT, 4],
    'reset' => [Store::TYPE_INT, 4],
]);

// Inline route — fine for one-off endpoints. Most routes live in route/ or api/.
$app->route('/health', fn() => ['ok' => true]);

$app->run();

Everything above $app->run() runs once, in the master process, before workers fork. Use it for setup that the whole server depends on: middleware, shared tables, timer registration. Anything per-request goes in a handler.

The Magic of ext-zealphp: Classic PHP Just Works

By default, new projects use App::mode(App::MODE_COROUTINE). This mode is extremely fast and completely avoids global state leaking across requests. However, if you're migrating an existing app or simply prefer the classic PHP-FPM mental model, you can flip one switch and install the C-extension:

Coroutine Mode (Default)
<?php
// 1. New syntax isolates per-request safely
$g = \ZealPHP\G::instance();
$g->session['visits'] ??= 0;
$g->session['visits']++;

$name = $g->get['name'] ?? 'Guest';
echo "Hello, $name! Visits: {$g->session['visits']}";
Coroutine-Legacy Mode
<?php
// 2. Classic PHP syntax just works!
session_start();
$_SESSION['visits'] ??= 0;
$_SESSION['visits']++;

$name = $_GET['name'] ?? 'Guest';
echo "Hello, $name! Visits: {$_SESSION['visits']}";

Powered by ext-zealphp, App::MODE_COROUTINE_LEGACY transparently intercepts OpenSwoole's scheduler and isolates $_GET, $_SESSION, and $GLOBALS per-coroutine. You get the blistering concurrency of coroutines without having to rewrite your entire app to use RequestContext variables!

The six directories — what goes where

public/ — pages, by filesystem

Drop a .php file in public/, get a URL. public/about.php responds at /about. Static files (CSS, JS, images) work the same way. This is the Apache-style convention: filesystem = router.

public/ is your document root — the Apache DocumentRoot equivalent that every implicit route and the static handler resolve against. It’s the default; point it at a different folder with App::documentRoot('…') before App::init().

api/ — REST endpoints, by action

File-based REST: api/device/list.php/api/device/list. Inside the file you define one closure whose variable name matches the filename ($list, $add, etc.). The framework auto-binds it with parameter injection. Cleanest pattern for JSON APIs.

route/ — explicit registrations

Files in route/ are loaded at startup. Use this for anything that doesn’t fit the filesystem convention: WebSocket endpoints ($app->ws()), URL params (/users/{id}), namespaced groups ($app->nsRoute('admin', ...)), or background timers (App::tick()). Each file registers many routes.

template/ — views only

Templates render HTML. Call them with App::render('pages/about', ['title' => ...]). The hard rule: no business logic, no inline <script> blocks, no style= attributes. Templates produce HTML; everything else lives in src/ or public/js/.

src/ — your business logic

Classes with proper namespaces, autoloaded via PSR-4. Your route handlers and API files should delegate here. A 50-line handler is a smell — extract a service class.

// composer.json
{
  "autoload": {
    "psr-4": {
      "MyProject\\": "src/"
    }
  }
}

The scaffold already has this configured. With it, src/Auth.php defining MyProject\Auth is reachable from anywhere as new \MyProject\Auth(). Run composer dump-autoload after adding new classes for the first time.

app.php — bootstrap, nothing else

Keep it thin. Middleware registration, Store::make() calls, the occasional inline route. If app.php grows past ~100 lines, you’re putting business logic where it doesn’t belong — move it.

Where do I put X?

I want to…Put it in…
Add a marketing page like /pricingpublic/pricing.php
Add a static CSS / JS filepublic/css/ or public/js/
Add a JSON API endpoint like /api/device/listapi/device/list.php
Add a URL-param route like /users/{id}route/users.php with $app->route(...)
Add a WebSocket endpointroute/ws.php with $app->ws(...)
Add a background timerroute/timers.php using App::tick()
Add a reusable HTML fragmenttemplate/components/_card.php
Add a layout used by many pagestemplate/_master.php or a new layout file
Add a class like AuthServicesrc/AuthService.php (namespaced)
Add new middlewaresrc/Middleware/MyMiddleware.php, register in app.php

The autoloader is shared across requests

One of ZealPHP’s quiet wins: the Composer autoloader runs once, when the worker starts. Every request after that gets your classes already mapped — no per-request file scan, no opcache warmup penalty. The cost of having a 500-file codebase drops to "the classes you actually use this request."

This is why we lean on PSR-4 hard. The framework is built so the autoloader does its work once and then steps out of the way.

You want to add a POST /api/login endpoint that returns JSON. Where does it go?

Key Takeaways

  • Bootstrap is one file: app.php. Three lines minimum; under 100 for most real apps.
  • public/ for filesystem-routed pages, api/ for REST, route/ for explicit routes, template/ for views, src/ for business logic.
  • Routes and APIs should be thin — delegate to src/ classes via PSR-4 autoloading.
  • Everything before $app->run() happens once per worker. Everything inside a handler happens per request.
  • Edits to template/, api/, public/ reload on next request. Edits to app.php, route/, framework middleware need a restart.