Getting started

Create a project, build your first page, then choose a practical path into APIs, authentication or AI.

This guide takes you from an empty folder to a page that says Hello, Ada!. Start with a blank project and follow the numbered steps in order. You do not need a separate web server or database server.

Already have a running blank application? Jump to your first API, account registration and MFA or your first AI answer. These are optional paths, not extra prerequisites for building a page. Authentication needs working SMTP; remote AI needs a provider key and can incur costs.

1. Check your tools

You should be comfortable creating files, opening a terminal and writing basic PHP (variables, arrays and functions), plus a little HTML. We explain the framework-specific terms as they appear.

  • PHP 8.3 or newer, with pdo_sqlite, mbstring, fileinfo and sodium. SQLite stores your database in a file; no database server is needed for this guide.
  • For another supported database, install its PDO driver: pdo_mysql, pdo_pgsql or pdo_sqlsrv.
  • Composer, PHP's package manager. It downloads phponwheels and the libraries your application needs.
  • A text editor and a browser.

Open a terminal (for example, Terminal → New Terminal in VS Code) and run these commands, one line at a time:

Shell

php --version
composer --version
php -m

The first two commands should print version numbers. The last lists enabled PHP extensions; look for the four listed above. If php or composer is not recognised, install it and reopen your terminal before continuing. php --ini shows which configuration file your command-line PHP uses.

How to read the commands

Type the commands into your terminal, not into a PHP file. Code blocks here omit the prompt character so you can copy them directly. Run each next command only after the previous one succeeds. We use php pow for project commands: it works in Windows PowerShell, macOS and Linux. Other pages use ./pow, which is the macOS/Linux shorthand for the same script. You do not need pcntl or the sockets extension for the built-in server; it uses PHP streams, with optional signal handling when pcntl is available.

2. Create a blank project

Install the CLI (command-line interface) once:

Shell

composer global require phponwheels/phponwheels
composer global config bin-dir --absolute

The second command prints Composer's global executable directory. Add that directory to your user's PATH if pow is not found. PATH is the list of folders your terminal searches for commands.

On Windows, open Edit environment variables for your account, edit Path, add the printed directory, then open a new terminal. On macOS/Linux, run the following, and add the same line to your shell profile to keep it for future terminals:

Shell

export PATH="$(composer global config bin-dir --absolute):$PATH"

To update the globally installed CLI later, run:

Shell

composer global update phponwheels/phponwheels

This updates the CLI within the version constraint Composer has stored for the global installation. It does not update applications you have already created; run composer update phponwheels/phponwheels inside an existing application to update that application's framework dependency.

In the folder where you keep your projects, run:

Shell

pow new my-app
cd my-app
composer install
php pow migrate

pow new creates a folder named my-app containing your application. cd moves your terminal into that folder, and composer install downloads its dependencies into vendor/. migrate applies database schema changes (called migrations); the blank application has no application tables yet, which is normal.

The generator also creates .env, your local configuration file, and gives this project its own APP_KEY for signing and encryption. Do not copy .env.example over it, share its key or commit .env to Git. Keep the default SQLite settings for this tutorial.

Blank project, demo, or framework checkout?

Use pow new my-demo --demo in a different folder if you want a working example with sign-in, notes, uploads and a self-check page. After composer install and php pow migrate in that project, run php pow db:seed to create its demo account: demo@example.test / correct-horse-battery. These are local demo credentials, not production credentials. If you cloned phponwheels itself, use the checkout instructions below instead of scaffolding over it.

3. Start the application

From inside my-app (the folder containing pow and composer.json), run:

Shell

php pow serve --debug

Wait for the message saying phponwheels is listening, then open http://127.0.0.1:8080 in your browser. You should see the starter welcome page with the heading It works. 127.0.0.1 means your own computer; 8080 is the port the server listens on.

For local HTTPS, run php pow serve --https and open https://localhost:8080. The first run creates a self-signed certificate in storage/certificates/; your browser will warn that it is not trusted. This option is for development only, not production HTTPS.

The terminal stays busy because it is running the server. Leave it open and use a second terminal in the same project folder for other commands. To stop the server, return to its terminal and press Ctrl+C.

Restart after PHP or configuration changes

The built-in server keeps your application running between requests. After changing routes, controllers, other PHP classes or .env, stop it with Ctrl+C and run php pow serve --debug again. Refreshing the browser alone does not reload that code. Template edits are picked up on refresh in debug mode. Debug mode shows exception details: use it locally, never on a public deployment.

4. Add a first route

A route connects a URL and an HTTP method to PHP code. GET is the method a browser normally uses when opening a page.

Open app/routes.php. It already imports Response and RouteCollection. Add this line inside the existing return static function (...) { ... }; block, before its closing };. Keep the existing routes:

PHP

$routes->get('/ping', static fn (): Response => Response::json(['pong' => true]), 'ping');

'/ping' is the URL path, the function returns a JSON response, and the final 'ping' is a route name you can use when generating links later. JSON is a text format for structured data.

Save the file, restart the server, then open http://127.0.0.1:8080/ping. The response contains:

Output

{"pong":true}

In your second terminal, php pow routes should also list /ping. This helps distinguish a missing route from a server that has not been restarted.

5. Generate a controller and template

A controller is a PHP class that handles a request and returns a response. A template holds the HTML for a page. Keeping them separate lets you change a page's appearance without mixing it with application logic.

In the second terminal, run:

Shell

php pow make:controller Greet --view

This creates app/src/Controllers/GreetController.php and app/templates/greet.onwheels.php. It prints a route and an import, but does not add them to your route file. We will add a route with a name parameter in the next step.

Replace the generated controller's contents with this complete file:

PHP

<?php

declare(strict_types=1);

namespace App\Controllers;

use PhpOnWheels\Http\Response;
use PhpOnWheels\Http\ServerRequest;
use PhpOnWheels\Routing\Handler;
use PhpOnWheels\View\TemplateEngine;

final class GreetController implements Handler
{
    public function __construct(
        private readonly TemplateEngine $view,
    ) {
    }

    public function handle(ServerRequest $request): Response
    {
        return $this->view->respond('greet', [
            'title' => 'Greetings',
            'name' => $request->attribute('name') ?? 'world',
        ]);
    }
}
  • namespace App\Controllers identifies where this class belongs; use lines import the class names it needs.
  • Handler requires a handle() method. phponwheels calls it when the route matches.
  • ServerRequest holds information about the incoming request, including route parameters.
  • The constructor asks for TemplateEngine. phponwheels supplies it automatically; you do not construct it yourself. This is called dependency injection.
  • respond('greet', ...) renders greet.onwheels.php as an HTML response. The array passes $title and $name to the template.

6. Connect the controller to a URL

In app/routes.php, add this import alongside the existing use lines, above return static function:

PHP

use App\Controllers\GreetController;

Then add this route inside that function, alongside the existing routes:

PHP

$routes->get('/greet/{name}', GreetController::class, 'greet');

{name} captures one URL segment. For /greet/Ada, the controller reads Ada through $request->attribute('name'). Use this parameterised route instead of the generator's suggested /greet route.

7. Write the page's HTML

Replace app/templates/greet.onwheels.php with:

Template

@extends('layout')

@section('content')
<div class="prose">
    <h1>Hello, {{ $name }}!</h1>
    <p>You built a page with a route, a controller and a template.</p>
    <p><a href="/">Back to home</a></p>
</div>
@endsection

@extends('layout') uses the existing app/templates/layout.onwheels.php, which supplies the surrounding page. @section('content') fills that layout's content area. {{ $name }} prints the name with HTML escaping, so input is displayed as text instead of being treated as HTML. Keep this escaped form for user-provided values.

Save both files and your route changes, restart the server, then open http://127.0.0.1:8080/greet/Ada. You should see Hello, Ada! Try /greet/Sam to see the same controller and template render a different name.

Now change the sentence in the template and refresh. With --debug, this template-only edit does not need a restart. Note that /greet by itself returns 404: the route requires a name segment.

What you just built

Browser requests GET /greet/Ada → router selects GreetController → controller passes Ada to the template → template becomes an HTML response → browser displays the page.

Choose what to build next

Continue in the same my-app project. Keep your existing routes and files; you do not need another installation. Each path below links to its maintained reference for configuration, security rules and more examples.

  • An API: return JSON to another application, with generated routes and Swagger documentation.
  • User accounts: add verified registration, login, MFA and administrator management.
  • AI text generation: ask OpenAI or Claude a question from a server-side PHP script.

Build a public test API

In your second terminal, run:

Shell

php pow make:api Ping --public
php pow migrate
php pow routes

Restart php pow serve --debug, then open http://127.0.0.1:8080/api/v1/ping. You should receive {"message":"It works"}. This is separate from the /ping route you added earlier.

The generator writes app/src/Controllers/Api/V1/PingController.php and app/api/v1/ping.php. The first returns your JSON; the second defines the method, version, access policy and OpenAPI description. Both load automatically: do not add this API to app/routes.php again. Open /api/docs, expand the ping operation and use Try it out, then Execute, to call it.

--public is an explicit choice for this harmless test endpoint. Without it, generated APIs require Bearer authentication; a blank project first needs make:auth or your own token services. Never expose private data by removing the guard. See the API walkthrough to change the response, keep its schema accurate and add a protected endpoint.

Add verified accounts and MFA

For this blank project, run php pow make:auth once. It generates editable account pages, controllers, routes and migrations without replacing your existing page or API. Do not run it over a demo project, which already has authentication.

Before registering, configure APP_URL=http://127.0.0.1:8080 and SMTP in .env, apply migrations and restart the server. The default MAIL_DRIVER=array does not deliver verification messages. A registration is not a usable account until you confirm its email.

Follow the authentication setup for the SMTP example and the complete sequence: register at /register, confirm the email, sign in at /login, then enable MFA at /account/security. Save recovery codes privately. Administrator access is an explicit operator action, not a privilege given to the first visitor.

Ask an AI model from PHP

Enable ext-curl and choose a provider: OpenAI, Claude through Anthropic, or an OpenAI-compatible local service. No provider is contacted during project setup. A paid provider requires your own key and an available model; keep credentials in .env or the real environment, never in browser code.

Follow the runnable AI script to create ask-ai.php in the project root and run php ask-ai.php. It loads the autoloader and typed configuration explicitly, then prints one answer. The same guide shows Claude and live HTTP streaming. There is no global env() helper and no make:ai command.

Start with a fixed, non-sensitive prompt. Running the script sends it to the selected provider and can incur costs. An AI web endpoint additionally needs authentication, rate limits and a budget policy; do not turn the public ping example into an unrestricted AI proxy.

Where things go

PathWhat to put there
app/routes.phpConnections between URLs and handlers
app/api/vN/Automatically loaded API declarations and OpenAPI descriptions generated by make:api
app/src/Controllers/Your request-handling classes, under App\Controllers
app/templates/HTML templates ending in .onwheels.php
app/bootstrap.phpStartup configuration: services and middleware (layers around handlers)
database/migrations/Versioned changes to database tables
public/Browser-accessible assets and the production web entrypoint; keep secrets out
storage/Generated files: sessions, compiled templates and the SQLite database
.envLocal settings and secrets; never commit it
vendor/Composer-managed dependencies; do not edit them

If something goes wrong

SymptomWhat to check
phponwheels is not recognisedCheck Composer's global bin directory with composer global config bin-dir --absolute, add it to PATH, and open a new terminal.
Could not open input file: phponwheelsMove into your project folder with cd my-app. You should see phponwheels and composer.json there.
Missing vendor/autoload.php or Composer autoloaderRun composer install in that project folder and resolve any errors it reports.
could not find driverEnable pdo_sqlite in the PHP configuration shown by php --ini. Check php -m, then restart the server.
Port 8080 is already in useRun php pow serve --port=9000 --debug and open http://127.0.0.1:9000 instead.
The browser cannot connectCheck that the server terminal is still running and says it is listening. Match the URL's port to the server's port.
404 or an old version of a pageCheck the exact URL and php pow routes. Restart after PHP changes; /greet/Ada matches, but /greet does not.
500 / Internal Server ErrorUse --debug locally, retry the request and read the error and server terminal. Check imports, spelling and semicolons. Do not turn on debug on a public server.
The generator says a file existsEdit that file rather than regenerating it. --force overwrites work; only use it if you intend to replace the file.
A generated API returns 401It is protected by default. Install authentication and supply a valid Bearer token; use --public only when creating an intentionally public endpoint. See protected APIs.
No registration email arrivesCheck MAIL_DRIVER=smtp, the SMTP connection and APP_URL. With array, no mail is delivered. See authentication setup.
An AI call fails or appears bufferedCheck ext-curl, key/model access and the server log without sharing secrets. CLI scripts use send(); web handlers return response(). See AI errors and streaming configuration.

Alternative: run the framework's demo checkout

This path is for a clone of the phponwheels repository, not the my-app project above. From the repository root, run composer install. If .env does not already exist, copy .env.example to .env in your editor (PowerShell: Copy-Item .env.example .env; macOS/Linux: cp .env.example .env).

If APP_KEY in .env is blank, generate a key:

Shell

php pow key:generate

This prints a new key; it does not update a file. Paste the entire printed base64:... value after APP_KEY= in .env. Keep an existing key rather than replacing it: previously encrypted data still needs it. Then run:

Shell

php pow migrate
php pow db:seed
php pow serve --debug

Open http://127.0.0.1:8080 for the demo self-check page. Sign in with demo@example.test / correct-horse-battery. The checkout also serves these docs at /docs; generated applications do not include a documentation copy or a /docs route.

What to learn next

  1. Routing and controllers: more URL patterns and request handlers.
  2. Templates: layouts, loops and reusable page parts.
  3. Forms: render inputs, validate submissions and keep CSRF protection enabled.
  4. Database, migrations and models: create tables and store application data.
  5. Testing: check your application with composer test and composer stan.
  6. Deployment: publish using FrankenPHP, nginx + PHP-FPM or Apache, with debug disabled. The built-in server is for local development.

Prefer a local dashboard for generators and migrations? Run php pow ui in a second terminal and visit http://127.0.0.1:8081. It has no login: keep it bound to the default 127.0.0.1 and do not expose it publicly. See Admin UI and the CLI command reference.

Before adding services that hold state, read service lifetimes and architecture. In plain terms: a running worker serves many visitors, so never store the current visitor's data in static properties or singleton services. Use phponwheels' request-scoped services and sessions instead of PHP's global session functions.