phponwheels
A safe PHP framework that runs on itself. One application, unchanged, on its own server, FrankenPHP, nginx+FPM or Apache.
phponwheels is a small PHP framework built around one constraint: the same application runs unchanged on four deployment targets, and it never has to know which one it is on.
A framework connects URLs to your PHP code and gives you tools for pages, forms and databases. Follow Getting started to install phponwheels, run a blank application and build a page that greets a visitor by name. It explains the terms, includes Windows instructions and shows what you should see after each step. Basic PHP and HTML are enough to begin; the architecture chapters can wait.
Choose your starting point
- New project: install and run a blank application, then build a page.
- APIs: generate your first JSON endpoint, then use the API reference for Bearer tokens, versioning, rate limits and Swagger.
- User accounts: generate login, verified registration and MFA, then add administrator management or explicitly linked Google/Entra sign-in.
- AI: run a complete PHP example with OpenAI, Claude or a compatible provider, then explore live streaming.
These pages are the maintained documentation, versioned with the framework. Tutorials link to the relevant reference rather than requiring a separate wiki or a second set of installation instructions. In generated projects, open vendor/phponwheels/phponwheels/docs/index.html; the application itself does not serve /docs.
| Target | Process model | Used for |
|---|---|---|
./pow serve | Long-lived, self-hosted | Development |
| FrankenPHP worker | Long-lived | Production |
| nginx + PHP-FPM | Per-request | Production |
| Apache | Per-request | Production |
“Runs on itself” is literal. ./pow serve is a real HTTP/1.1 server built on sockets, sharing the exact request pipeline used in production — not a router script in front of php -S.
How a page fits together
In a generated project, these three files illustrate a complete page. A route selects a controller, and the controller renders a template. For step-by-step instructions that preserve your starter routes, follow the first-page tutorial.
<?php
declare(strict_types=1);
// app/routes.php
use App\Controllers\HelloController;
use PhpOnWheels\Http\Response;
use PhpOnWheels\Routing\RouteCollection;
return static function (RouteCollection $routes, bool $debug): void {
$routes->get('/', static fn (): Response => Response::text('Hello.'));
$routes->get('/hello/{name}', HelloController::class, 'hello');
};
<?php
declare(strict_types=1);
// app/src/Controllers/HelloController.php
namespace App\Controllers;
use PhpOnWheels\Http\Response;
use PhpOnWheels\Http\ServerRequest;
use PhpOnWheels\Routing\Handler;
use PhpOnWheels\View\TemplateEngine;
final class HelloController implements Handler
{
public function __construct(private readonly TemplateEngine $view)
{
}
public function handle(ServerRequest $request): Response
{
// {{ }} escapes. The payload arrives as text, not markup.
return $this->view->respond('hello', [
'title' => 'Hello',
'name' => $request->attribute('name'),
]);
}
}Save the template as app/templates/hello.onwheels.php. It uses the generated project's existing layout:
@extends('layout')
@section('content')
<h1>Hello, {{ $name }}!</h1>
@endsection{{ $name }} prints the value supplied by the controller with HTML escaping. The title value supplies the browser tab's title in the layout.
php pow serve --debugOpen http://127.0.0.1:8080/hello/Ada to see Hello, Ada!. If the server is already running, stop it with Ctrl+C before starting it again to pick up PHP changes. Template-only edits appear on refresh in debug mode.
What it gives you
Two ideas worth knowing before you start
1. Worker safety is a correctness rule, not a deployment concern
The four targets split into two incompatible process models:
- Per-request (Apache, nginx+FPM) tears down the process after every response. Global state is free — nothing survives to leak.
- Long-lived workers (the built-in server, FrankenPHP) boot once and serve thousands of requests in one process. Anything mutable that outlives a request leaks across users.
Code that is safe under a worker is automatically correct per-request. The reverse is not true. So phponwheels assumes the worker model everywhere, and the framework is shaped to make the leak impossible rather than to warn you about it. How that is enforced →
2. The safe path is the default path
You do not opt into safety; you opt out of it, visibly:
- Templates escape with
{{ }}. Raw output needs the deliberately loud{!! !!}. - CSRF protection is on. A route opts out with
csrfExempt: true. - The query builder binds values as parameters. When writing SQL yourself, use placeholders rather than interpolating user input.
- An
UPDATEorDELETEwith nowhere()throws unless you callaffectingEveryRow(). - Uploads are judged by their bytes, never by their filename or declared type.
Start with Getting started, then explore routing, templates and forms. When you are ready to understand service lifetimes and worker safety, read Architecture.