The phponwheels CLI

Every command, what it does, and how to add one of your own.

Shell

$ ./pow help
phponwheels

Usage:
  pow new <directory> [--demo] [--force]
  pow key:generate
  pow make:auth
  pow make:api <Name> [--method=GET] [--version=1] [--public]
  pow auth:admin <email>
  pow make:class <Name> [--singleton|--scoped] [--force]
  pow make:controller <Name> [--view] [--force]
  pow make:form <Name> [--fields=a:text,b:email] [--captcha]
                         [--no-honeypot] [--controllers] [--force]
  pow make:middleware <Name> [--force]
  pow make:migration <name> [--table=x] [--sequential] [--force]
  pow make:seed <name> [--force]
  pow make:model <Name> [--table=x] [--fields=a:string,b:int] [--force]
  pow serve [--host=127.0.0.1] [--port=8080] [--debug] [--https]
  pow ui [--host=127.0.0.1] [--port=8081] [--debug]
  pow routes
  pow migrate
  pow migrate:status
  pow migrate:rollback [--batches=1]
  pow db:seed [name]
  pow mail:test --to=x@example.test [--from=x@example.test]
  pow mail:list [--status=sent|failed|pending] [--limit=20]
  pow mail:send-pending [--limit=50]
  pow mail:resend <id>|--failed [--limit=50]
  pow cache:clear
  pow storage:clear
  pow sessions:gc
  pow help

make:api

Generates a single JSON endpoint with automatic route registration and OpenAPI documentation. No code needs to be pasted into app/routes.php. Bearer authentication is required unless --public is explicitly supplied.

Shell

php pow make:api Products
php pow make:api Ping --public
php pow make:api CreateProduct --method=POST --version=2
php pow migrate

Edit the generated controller and its app/api/vN/*.php declaration, restart the server, and open /api/docs. GET is the default; POST, PUT, PATCH and DELETE are also supported. Existing files are never overwritten. For protected endpoints in a blank project, run make:auth once to provide token authentication. Public endpoints work without it. See APIs for setup and examples.

new

Creates a project. Two shapes, both complete and runnable.

Shell

$ pow new my-app
Writing entrypoints, configuration and tooling
Writing a blank application — one route, one controller, one template
Copied .env.example to .env
Created my-app — a blank application — one route, one controller, one template

  20 files written

Next:
  cd my-app
  composer install
  ./pow migrate
  ./pow serve
VariantWhat you get
defaultA blank application: one route, one controller, one template, a starter test, no tables.
--demoThe demo application: sessions, authentication, uploads, notes and the live self-check page.

Both include the entrypoints for all four deployment targets, a configured .env, PHPUnit and PHPStan, and a README.md.

Neither includes this documentation

Docs belong to the framework, not to every project built on it — a copy in each application is a copy that goes stale. app/bootstrap.php mounts /docs only when the directory exists, so a scaffolded project simply has no such route and no dead link to it.

It refuses to overwrite

Shell

$ pow new existing-project
Directory "existing-project" is not empty (13 entries). Pass --force to write into it anyway.

Scaffolding over a project would replace its bootstrap and routes without warning, so an occupied directory is an error rather than a merge. --force writes anyway, leaving files the scaffold does not itself produce — including an existing .env — untouched.

After scaffolding

Shell

$ cd my-app
$ composer install
$ ./pow migrate       # --demo also wants: ./pow db:seed
$ ./pow serve

make:auth

Generates an editable authentication module for a blank project: login/logout, registration with mandatory email verification, optional TOTP and recovery codes, password reset, magic links, profile editing and administrator-only user management. Google and Entra account linking are included and remain disabled until configured.

Shell

php pow make:auth
# Configure APP_URL and SMTP in .env.

php pow migrate
php pow serve
# Visit /register, verify the email, then:

php pow auth:admin your@email.example

New blank projects load the generated app/auth.php automatically. Older projects get the boot registration line to add by hand. The generator does not rewrite your bootstrap, routes, layout, configuration or existing files, and has no --force option. Existing demo authentication is refused rather than overlaid. See authentication scaffolding for the files, routes, setup requirements and integration constraints.

auth:admin

Grants administrator access to an existing, verified, unblocked account in the generated auth_users table. Run locally as the site operator; no first-user-wins rule, default administrator password or public elevation endpoint exists. Promotion revokes existing sessions; sign in again afterwards.

make:class

Writes a plain class under App\ — a repository, a service, a form definition, a small value object: everything an application holds that is neither a controller nor a migration.

Shell

$ pow make:class Notes/NoteRepository
Created app/src/Notes/NoteRepository.php

App\Notes\NoteRepository — autowired per request, so nothing to register

Inject it where you need it:

  use App\Notes\NoteRepository;
  private readonly NoteRepository $noteRepository,

That is the whole story for the default: an unregistered class is constructed by the RequestScope when something asks for it, and discarded when the request ends. A controller naming it as a constructor parameter gets one — no bootstrap edit, and no way for it to outlive the request that built it.

The lifetime is the argument

It is the one decision a new class here cannot avoid, because a long-lived worker shares anything that outlives a request with the next visitor. So it is a flag on the command rather than something to discover later:

FlagLifetimeFor
defaultAutowired per request, registered nowhereAlmost everything. Dependencies must be object-typed.
--scopedRebuilt for every requestPer-request state, or anything needing the session or the current user.
--singletonOne instance per processConnections, compiled tables, configuration — and it must be stateless.

The two flags name one lifetime each, so passing both is refused rather than resolved silently. With either, the registration line is printed for app/bootstrap.php:

Shell

$ pow make:class Support/Clock --singleton
Created app/src/Support/Clock.php

App\Support\Clock — a singleton: one instance per process, so it must be stateless

Add to app/bootstrap.php:

  use App\Support\Clock;
  $app->container->singleton(Clock::class, static fn (): Clock => new Clock());

Inject it where you need it:

  use App\Support\Clock;
  private readonly Clock $clock,

The class comment states the constraint that lifetime carries, so it is in front of you while you write the methods — not in a document you read once:

PHP

<?php
namespace App\Support;

/**
 * A singleton: one instance for the whole process.
 *
 * Under a long-lived worker — this framework's own server and FrankenPHP —
 * that one instance is shared by every request the process serves, so it
 * must be stateless. Anything mutable stored on it leaks from one visitor
 * to the next, and the leak is invisible under Apache or nginx+FPM, where
 * the process dies after each response.
 *
 * Hold connections, compiled tables and configuration here. Hold request
 * data in a scoped class instead.
 */
final class Clock
{
}

Names

You typeYou get
ClockApp\Clock in app/src/Clock.php
Notes/NoteRepositoryApp\Notes\NoteRepository, nested to match
App\Notes\NoteRepositorythe same — a leading App is not repeated

StudlyCase, like make:controller, and for the same reason: a name arriving from a shell argument must never place a file outside app/src. Reserved words are refused too, because writing a file PHP cannot parse is worse than answering the question:

Shell

$ pow make:class List
Invalid class name "List": "List" is a reserved word in PHP and cannot name a class or a namespace.

An existing file is never overwritten without --force, and the bootstrap file is never edited — the registration line is printed for you to paste, exactly as make:controller prints its route.

make:controller

Writes a controller class, and optionally the template it renders.

Shell

$ pow make:controller Reports
Created app/src/Controllers/ReportsController.php

Add to app/routes.php:

  use App\Controllers\ReportsController;
  $routes->get('/reports', ReportsController::class, 'reports');

With --view it also writes the template and injects the engine:

Shell

$ pow make:controller Admin/UserProfile --view
Created app/src/Controllers/Admin/UserProfileController.php
Created app/templates/admin/user-profile.onwheels.php

Add to app/routes.php:

  use App\Controllers\Admin\UserProfileController;
  $routes->get('/admin/user-profile', UserProfileController::class, 'admin.user-profile');
PHP

<?php
namespace App\Controllers\Admin;

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

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

    public function handle(ServerRequest $request): Response
    {
        return $this->view->respond('admin/user-profile', [
            'title' => 'User Profile',
        ]);
    }
}

Names

You typeYou get
ReportsApp\Controllers\ReportsController
ReportsControllerthe same — the suffix is added once, never twice
Admin/UsersApp\Controllers\Admin\UsersController, template admin/users

Names must be StudlyCase. Anything else is refused rather than cleaned up — a name arriving from a shell argument must never be able to place a file outside app/src/Controllers:

Shell

$ pow make:controller ../evil
Invalid controller name "../evil". Use StudlyCase, optionally nested: Home, UserProfile, Admin/Users.

An existing file is never overwritten without --force.

It does not edit your routes file

The route line is printed for you to paste. Rewriting a file you own means parsing and re-emitting your code, and getting that subtly wrong is worse than leaving one line to add — in the place where you can see it.

make:form

Writes a form definition: one class whose build() returns the Form. That single declaration is both the markup and the validation, which is the property worth generating — a form written by hand is where a field ends up rendered but never checked, because the input and the rule live in different files and only one of them got updated.

Shell

$ pow make:form Contact --controllers
Created app/src/Forms/ContactForm.php
Created app/src/Controllers/ContactController.php
Created app/src/Controllers/SubmitContactController.php
Created app/templates/contact.onwheels.php

App\Forms\ContactForm — 3 fields: name, email, message

Add to app/routes.php:

  use App\Controllers\ContactController;
  use App\Controllers\SubmitContactController;
  $routes->get('/contact', ContactController::class, 'contact');
  $routes->post('/contact', SubmitContactController::class, 'contact.submit');

Paste those two lines and the page works: it renders, validates, redisplays what was typed when a rule fails, and redirects after a successful submission. Without --controllers only the definition is written, for a page you are writing yourself.

PHP

<?php
namespace App\Forms;

use PhpOnWheels\Crypto\Signer;
use PhpOnWheels\Form\Field;
use PhpOnWheels\Form\Form;
use PhpOnWheels\Form\Honeypot;

final class ContactForm
{
    public function __construct(
        private readonly Signer $signer,
    ) {
    }

    public function build(): Form
    {
        return Form::post('/contact')
            ->add(
                Field::text('name')->required()->max(120),
                Field::email('email')->required()->max(120),
                Field::textarea('message')->required()->max(2000),
            )
            // The decoy and the signed clock ask a person for nothing and
            // stop the scripts that post to every form they find.
            ->protectWith(new Honeypot($this->signer));
    }
}

Nothing registers this class: its dependencies are singletons the bootstrap already defines, so the request scope autowires it into both controllers. The CSRF token is not in the declaration either — a post() form emits one because it is a POST, not because someone remembered.

Fields

Shell

$ pow make:form Signup --fields=email:email,password:password,plan:select,terms:checkbox

Written as name:type, with text assumed when the type is left off. The available types are the ones Field has a factory for: text, email, password, number, url, tel, date, checkbox, textarea, select. The default set is name:text,email:email,message:textarea.

Each field arrives with rules attached — required(), a length bound, and min(12)->max(72) on a password because 72 bytes is where bcrypt truncates and PasswordHasher refuses more. The particular numbers are a starting point; that the rules exist is the point.

Protections are on by default

FlagEffect
defaultHoneypot: a decoy field and a signed render clock. Costs a visitor nothing.
--captchaAdds MathCaptcha — arithmetic, no JavaScript, no third party.
--no-honeypotLeaves both off, for a form behind a login.

A protection that has to be added afterwards is one that gets added after the first spam run, so the default path is the protected one. Be clear about what the captcha buys, though: it stops undirected scripts, not someone who has decided to attack you — a language model solves arithmetic.

Refusals

Shell

$ pow make:form Contact --fields=website:url
Field "website" clashes with the honeypot's decoy field — rename it, or pass --no-honeypot.

$ pow make:form Contact --fields=name:wat
Unknown field type "wat" for "name". Available: text, email, password, number, url, tel, date, checkbox, textarea, select.

The first one matters more than it looks. A field named website beside the honeypot means every real visitor fills the decoy, so every genuine submission is rejected as automated — and the generic message they get explains nothing. The clash is silent at runtime, which is why it is answered here. The same applies to _token, _rendered and the captcha's fields, and to declaring one name twice.

Names follow make:controller: StudlyCase, optionally nested. Contact and ContactForm both give ContactForm; Admin/Invite nests the namespace, the template (admin/invite) and the route (/admin/invite). Because the action and the printed route come from one derivation, the form cannot post somewhere the route does not answer.

An existing file is never overwritten without --force — and every target is checked before the first is written, since a half-generated slice is worse than none.

make:middleware

Writes a class implementing Middleware, with a body that passes the request straight through:

Shell

$ pow make:middleware RequestId
Created app/src/Middleware/RequestIdMiddleware.php

App\Middleware\RequestIdMiddleware

Add to the $app->middleware(...) list in app/bootstrap.php:

  use App\Middleware\RequestIdMiddleware;
  new RequestIdMiddleware(),
It does not edit your middleware list

Unlike a controller or an autowired class, middleware is never resolved by the container — $app->middleware(...) takes constructed objects directly, in the exact order they run. That order is meaning, not plumbing: SessionMiddleware has to precede CsrfMiddleware, which reads the session it publishes. Inserting a line automatically would mean guessing where it belongs, so the entry is printed for you to place instead — the same reasoning as the route line make:controller leaves to paste.

The generated class:

PHP

<?php
namespace App\Middleware;

use Closure;
use PhpOnWheels\Container\RequestScope;
use PhpOnWheels\Http\Response;
use PhpOnWheels\Http\ServerRequest;
use PhpOnWheels\Middleware\Middleware;

final class RequestIdMiddleware implements Middleware
{
    public function process(ServerRequest $request, RequestScope $scope, Closure $next): Response
    {
        return $next($request);
    }
}

Names follow make:controller: StudlyCase, optionally nested by /, with the Middleware suffix added once whether or not it was typed. Reserved words are refused for the same reason as make:class — writing a file PHP cannot parse is worse than answering the question at the command line.

make:migration

Shell

$ pow make:migration create_articles_table
Created database/migrations/20260811143012_create_articles_table.php

Edit it, then run: pow migrate

The name decides the starting contents. That inference is only a convenience — every shape produces a valid, portable migration.

NameWhat you get
create_articles_tableCREATE TABLE articles, with DROP TABLE to reverse it
add_slug_to_articlesALTER TABLE articles, both ways
backfill_search_indexAn empty up()/down() pair with guidance

CreateArticlesTable and create articles table normalise to the same thing. --table=x overrides whatever the name implied.

PHP

<?php
// The generated create-table migration
return new class implements Migration {
    public function up(Connection $database): void
    {
        $database->executeSchema(sprintf(
            'CREATE TABLE articles (
                id %s,
                created_at VARCHAR(40) NOT NULL
            )',
            $database->driver()->autoIncrementPrimaryKey(),
        ));
    }

    public function down(Connection $database): void
    {
        $database->executeSchema('DROP TABLE articles');
    }
};

Note the primary key: generated migrations are portable across SQLite, MySQL, PostgreSQL and SQL Server from the start, because that is the one part of the schema each engine spells differently.

The filename prefix

Migrations run in filename order, so the prefix decides the order. The default is a timestamp, which is what lets two developers on separate branches add migrations without coordinating — they get different numbers, and merging is deterministic.

Shell

$ pow make:migration create_tags_table --sequential
Created database/migrations/0004_create_tags_table.php

--sequential continues the 0001, 0002 counter instead, which reads better in a small repository where collisions are not a concern.

Refusals

Shell

$ pow make:migration ../evil
Invalid migration name "../evil". A migration is named in words, not by path — try create_articles_table.

$ pow make:migration create_things --table='a; DROP TABLE users'
Invalid table name "a; DROP TABLE users". Identifiers may contain letters, digits and underscores.

Punctuation and casing in a name are normalised freely, because a name is words. A path separator is not: it means the caller was aiming somewhere, and quietly rewriting it would hide that rather than answer it. The table name is validated rather than escaped, because no driver can bind an identifier — it reaches the SQL directly.

make:model

Shell

$ pow make:model Note --fields=title:string,body:string,views:int
Created app/src/Models/Note.php

App\Models\Note — table "notes", 3 fields

Once, in app/bootstrap.php, right after $database is built:

  use PhpOnWheels\Database\Model;
  Model::useConnection($database);

Then use it directly — no injection, no registration:

  use App\Models\Note;
  App\Models\Note::find($id);

Writes a Model subclass under App\Models — a typed property and matching fromRow()/toRow() line per field. There is no lifetime flag: a model is never resolved from the container, so there is nothing to register.

The table name is guessed from the class — Note becomes notes, Category becomes categories — and is only a starting point; --table= overrides it, and table() in the generated class is the one line to edit if the guess is wrong.

Shell

$ pow make:model Note --fields=title:string,views:int,archived_at:?string
TypeProperty
stringpublic string $x = '';
intpublic int $x = 0;
floatpublic float $x = 0.0;
boolpublic bool $x = false;
any of the above, ?-prefixedpublic ?T $x = null;

Unlike make:form's field types, these name storage rather than an input — a model field is a column, not a control. --fields is optional; without it, the generated class has no properties beyond id and a comment showing the flag to add them.

Every generated model needs Model::useConnection() called once, which the command prints but does not write — the same reasoning make:controller leaves the route line to paste rather than editing a file it does not own.

serve

Shell

$ ./pow serve
phponwheels listening on http://127.0.0.1:8080 (production mode) — Ctrl-C to stop
GET / -> 200

$ ./pow serve --port=9000 --debug
$ ./pow serve --host=0.0.0.0 --port=8080
$ ./pow serve --https

Starts phponwheels' own HTTP/1.1 server — a long-lived process sharing the exact pipeline used in production, so a state leak shows up on your machine rather than in production.

--debug sets APP_DEBUG in the process environment, which exposes exception detail in responses and recompiles templates on every render. Pending migrations are applied first, as a development convenience.

--https creates and reuses a self-signed certificate under storage/certificates/; open https://localhost:8080. Browsers show a trust warning because the certificate is for local development, not a publicly trusted site.

Binding to 0.0.0.0

That exposes the server to your whole network. It serves connections sequentially in one process, which is fine for development and exactly why it is not a production target — one slow request blocks every other. Use FrankenPHP, nginx+FPM or Apache for anything real.

ui

Shell

$ ./pow ui
Starting the admin UI — migrations, mail, routes, sessions, storage.
phponwheels listening on http://127.0.0.1:8081 (production mode) — Ctrl-C to stop

A second, self-contained web application — never wired into app/routes.php — for running migrations, resending failed mail, browsing routes, sessions and the template cache, and every make:* generator on this page, from a browser instead of a terminal. Binds to 127.0.0.1 by default and warns if told to bind anywhere else: there is no login, so treat it like a database console left open on your own machine. Full write-up, including why it is a separate application rather than a few extra routes: The admin UI.

routes

Shell

$ ./pow routes
GET     /                                        self-check
POST    /articles                                articles.store
GET     /articles/{id:\d+}                       articles.show
GET     /health                                  health

The compiled table — method, pattern, name — sorted by path. This is what the router will actually match, so it is the fastest way to check whether a route landed where you expected.

migrate

Shell

$ ./pow migrate
Applying pending migrations...
  0003_create_articles
  0004_add_articles_slug

$ ./pow migrate
Nothing to migrate.

Applies everything pending, each in its own transaction, grouped into one batch. Run it as a deploy step: the production entrypoints deliberately never touch the schema, because several workers booting at once would race.

migrate:status

Shell

$ ./pow migrate:status
  applied   0001_create_users            batch 1
  applied   0002_create_auth_attempts    batch 1
  applied   0003_create_articles         batch 2
  pending   0004_add_articles_slug

migrate:rollback

Shell

$ ./pow migrate:rollback
Reversed 0003_create_articles

$ ./pow migrate:rollback --batches=2

Reverses the most recent batch — one deployment's worth of changes. Every migration in the batch is loaded and checked before anything is undone, so a batch containing an irreversible migration fails without half-rolling-back the rest.

make:seed

Shell

$ ./pow make:seed 0002_categories
Created database/seeders/0002_categories.php

Edit it, then run: pow db:seed

Creates a PHP file returning a Seeder with an empty run(Connection $database): void method. Edit it to add idempotent data writes. The generator does not boot the application, connect to the database or run any seeders.

Names use lowercase letters, digits and underscores, starting with a letter or digit. The name becomes the filename exactly; no timestamp or number is added automatically. Use a prefix such as 0002_ when order matters, because db:seed runs files in filename order. Paths and file extensions are refused. Existing files are never overwritten unless --force is passed.

db:seed

Shell

$ ./pow db:seed
Seeded 0001_demo_account

Runs PHP files from database/seeders/ in filename order. Each file returns a PhpOnWheels\Database\Seeder with run(Connection $database): void. Available in both blank and demo projects; an empty or absent directory prints Nothing to seed..

By default every invocation runs all seeders again, so make their writes idempotent. Pass a seeder's exact filename without .php to run only that seeder:

Shell

$ ./pow db:seed 0002_categories
Seeded 0002_categories

A requested name that does not exist is an error. Each selected seeder gets its own transaction; a failure rolls back that seeder and exits nonzero. In an all-seeders run, later seeders stop and earlier successful seeders stay committed. Run migrate first. There is no seeding ledger, automatic run at boot or seed rollback command. See Migrations and seeding for a complete example. The demo seeder creates the local demo login only if it is absent.

mail:test

Sends one message through whatever MAIL_DRIVER is actually configured. Worth being direct about why this exists: nothing about SmtpSettings or SmtpSession has ever been exercised against a real mail server as part of building this framework — see Sending email — so this is the first thing that proves an SMTP configuration works, rather than merely parses.

Shell

$ ./pow mail:test --to=you@example.test
Accepted by the "array" driver — nothing left this machine. Set MAIL_DRIVER=smtp to test real delivery.

$ MAIL_DRIVER=smtp ./pow mail:test --to=you@example.test
Sent to you@example.test via smtp.

$ MAIL_QUEUE=true ./pow mail:test --to=you@example.test
Queued for delivery to you@example.test.

$ ./pow mail:test --to=you@example.test
Send failed (driver: smtp): Could not connect to tcp://smtp.example.test:587: ...

The sender comes from MAIL_FROM_ADDRESS by default; --from= overrides it, and the command refuses outright if neither is set — a test send needs a sender the same as any other. Read directly from configuration rather than left to the mailer's own default, because only SmtpMailer applies one; mail:test has to behave the same way regardless of driver.

The message goes through the same Mailer every controller uses — see Every send is persisted — so it is one more row in pow mail:list. With MAIL_QUEUE=true it remains pending until mail:send-pending runs; otherwise it is delivered immediately and a failed send is resendable with pow mail:resend.

mail:list

Shell

$ ./pow mail:list
5     sent    2    carol@example.test                 Reminder 2                               2026-01-01T09:14:11+00:00
4     failed  1    bob@example.test                   Reminder                                 2026-01-01T09:14:02+00:00
3     pending 0    grace@example.test                 Receipt                                  2026-01-01T09:12:44+00:00

$ ./pow mail:list --status=failed --limit=5

Every message passed through Mailer is recorded — see Sending email for what mail_log holds — and this is how to read that log back: id, status, attempts, recipients, subject, last-updated, most recent first. --status narrows to sent, failed or pending; --limit defaults to 20.

mail:send-pending

Shell

$ ./pow mail:send-pending --limit=50
Sent #3
1 sent, 0 failed.

Sends up to 50 pending messages (or the requested --limit) synchronously through the configured mail driver. Each row is updated in place to sent or failed, and failed rows retain the server error. The command continues after individual failures and exits non-zero if any remain failed. Use a system scheduler to run it periodically; phponwheels does not run a worker or retry automatically.

mail:resend

Shell

$ ./pow mail:resend 4
Resent #4 to bob@example.test.

$ ./pow mail:resend --failed
2 resent, 0 still failing.

$ ./pow mail:resend 3
Mail #3 has status "sent"; only failed mail can be resent.

Takes an id, or --failed to resend everything currently failed (bounded by --limit, default 50). Only failed mail can be resent — a message already marked sent is refused, because resending it would deliver it twice with no record that either send happened. A resend that fails again updates the same row rather than adding a new one; attempts grows and the error moves to the latest attempt.

--failed does not stop at the first failure: it works through every entry and reports how many were resent successfully and how many are still failing, exiting non-zero only if at least one remains failed — the shape a cron entry or a deploy step would check.

cache:clear

Clears application data in the configured file, Redis or Memcached cache, only within this application's CACHE_PREFIX. Reads .env without booting the application or connecting to its database. It never flushes the entire Redis or Memcached server, and it does not remove compiled templates.

Shell

$ pow cache:clear
Application cache cleared.

storage:clear

Shell

$ ./pow storage:clear
Removed 14 compiled templates.

$ ./pow storage:clear
Removed 0 compiled templates.

Deletes everything under storage/cache/views — see Templates for what lives there. Safe to run at any time: TemplateEngine recompiles a template the moment it finds no compiled file waiting for it, so the very next render replaces whatever this removed. Nothing else in storage/ is touched.

When this actually matters

alwaysRecompile — which --debug sets — makes this unnecessary in development. In production the staleness check compares file modification times, and a deploy method that preserves them (some rsync and tarball-extraction flows do) can leave a stale compiled template being served after an edit that should have replaced it. This is the manual escape hatch for that case.

sessions:gc

Shell

$ ./pow sessions:gc
Removed 3 expired sessions.

$ ./pow sessions:gc
Removed 0 expired sessions.

Deletes session files past their expiry. FileSessionStore already refuses an expired file on read, so nothing is served incorrectly without this — it exists to stop storage/sessions growing forever on a machine that never runs a scheduler. There is nothing to configure: the directory is the same fixed convention app/bootstrap.php uses, so the command does not boot the application at all.

A deployment with cron or a systemd timer would run it on a schedule; one without either can simply run it by hand occasionally, since a slightly late collection costs disk space, not correctness.

Adding a command

The CLI is a plain switch in phponwheels. Add a case:

PHP

<?php
case 'stats':
    /** @var Application $app */
    $app = require $bootstrap;

    printf("%d routes compiled.\n", count($app->router()->routes()));
    exit(0);

Add it to the usage text too, so pow help stays honest.

The CLI is a SAPI boundary

phponwheels is one of the few files allowed to use STDERR, STDOUT and superglobals, because it refuses to run anywhere but the CLI. Code you call from it — anything in src/ or app/ — must still work on every target, so use StreamLogger::standardError() rather than the STDERR constant there.

Exit codes

CodeMeaning
0Success.
1Unknown command, missing dependencies, missing bootstrap, or a configuration error.

Configuration problems are reported before anything else runs:

Shell

$ ./pow routes
Configuration error: Setting "APP_DEBUG" is not a valid boolean.
Accepted values: true/false, 1/0, yes/no, on/off.

Running it from anywhere

Shell

$ php /srv/app/pow migrate

Paths are resolved from the script's own location and, for settings, against the project root — so a command behaves the same from cron, a deploy script or your shell, regardless of the working directory.

A global install

That same rule means a globally installed phponwheels cannot run serve, migrate or any other project command for whatever project happens to be in your current directory — it would resolve paths against the global install itself, not your project. The one thing worth installing globally is new, which takes its target directory from where you run it rather than from its own location:

Shell

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

$ cd ~/code
$ pow new my-app

key:generate works the same way, for the same reason — it prints a key and touches no files. Once a project exists, use its own ./pow for everything else.