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.

New to phponwheels? Start here.

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

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.

TargetProcess modelUsed for
./pow serveLong-lived, self-hostedDevelopment
FrankenPHP workerLong-livedProduction
nginx + PHP-FPMPer-requestProduction
ApachePer-requestProduction

“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

<?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

<?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:

Template

@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.

Shell

php pow serve --debug

Open 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 UPDATE or DELETE with no where() throws unless you call affectingEveryRow().
  • Uploads are judged by their bytes, never by their filename or declared type.
Where to go next

Start with Getting started, then explore routing, templates and forms. When you are ready to understand service lifetimes and worker safety, read Architecture.