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,fileinfoandsodium. 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_pgsqlorpdo_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:
php --version
composer --version
php -mThe 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.
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:
composer global require phponwheels/phponwheels
composer global config bin-dir --absoluteThe 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:
export PATH="$(composer global config bin-dir --absolute):$PATH"To update the globally installed CLI later, run:
composer global update phponwheels/phponwheelsThis 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:
pow new my-app
cd my-app
composer install
php pow migratepow 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.
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:
php pow serve --debugWait 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.
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:
$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:
{"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:
php pow make:controller Greet --viewThis 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
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\Controllersidentifies where this class belongs;uselines import the class names it needs.Handlerrequires ahandle()method. phponwheels calls it when the route matches.ServerRequestholds 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', ...)rendersgreet.onwheels.phpas an HTML response. The array passes$titleand$nameto 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:
use App\Controllers\GreetController;Then add this route inside that function, alongside the existing routes:
$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:
@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.
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:
php pow make:api Ping --public
php pow migrate
php pow routesRestart 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
| Path | What to put there |
|---|---|
app/routes.php | Connections 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.php | Startup 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 |
.env | Local settings and secrets; never commit it |
vendor/ | Composer-managed dependencies; do not edit them |
If something goes wrong
| Symptom | What to check |
|---|---|
phponwheels is not recognised | Check 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: phponwheels | Move into your project folder with cd my-app. You should see phponwheels and composer.json there. |
Missing vendor/autoload.php or Composer autoloader | Run composer install in that project folder and resolve any errors it reports. |
could not find driver | Enable pdo_sqlite in the PHP configuration shown by php --ini. Check php -m, then restart the server. |
| Port 8080 is already in use | Run php pow serve --port=9000 --debug and open http://127.0.0.1:9000 instead. |
| The browser cannot connect | Check 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 page | Check the exact URL and php pow routes. Restart after PHP changes; /greet/Ada matches, but /greet does not. |
| 500 / Internal Server Error | Use --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 exists | Edit that file rather than regenerating it. --force overwrites work; only use it if you intend to replace the file. |
| A generated API returns 401 | It 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 arrives | Check MAIL_DRIVER=smtp, the SMTP connection and APP_URL. With array, no mail is delivered. See authentication setup. |
| An AI call fails or appears buffered | Check 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:
php pow key:generateThis 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:
php pow migrate
php pow db:seed
php pow serve --debugOpen 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
- Routing and controllers: more URL patterns and request handlers.
- Templates: layouts, loops and reusable page parts.
- Forms: render inputs, validate submissions and keep CSRF protection enabled.
- Database, migrations and models: create tables and store application data.
- Testing: check your application with
composer testandcomposer stan. - 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.