The phponwheels CLI
Every command, what it does, and how to add one of your own.
$ ./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 helpmake: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.
php pow make:api Products
php pow make:api Ping --public
php pow make:api CreateProduct --method=POST --version=2
php pow migrateEdit 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.
$ 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| Variant | What you get |
|---|---|
| default | A blank application: one route, one controller, one template, a starter test, no tables. |
--demo | The 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.
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
$ 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
$ cd my-app
$ composer install
$ ./pow migrate # --demo also wants: ./pow db:seed
$ ./pow servemake: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.
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.exampleNew 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.
$ 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:
| Flag | Lifetime | For |
|---|---|---|
| default | Autowired per request, registered nowhere | Almost everything. Dependencies must be object-typed. |
--scoped | Rebuilt for every request | Per-request state, or anything needing the session or the current user. |
--singleton | One instance per process | Connections, 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:
$ 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
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 type | You get |
|---|---|
Clock | App\Clock in app/src/Clock.php |
Notes/NoteRepository | App\Notes\NoteRepository, nested to match |
App\Notes\NoteRepository | the 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:
$ 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.
$ 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:
$ 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
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 type | You get |
|---|---|
Reports | App\Controllers\ReportsController |
ReportsController | the same — the suffix is added once, never twice |
Admin/Users | App\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:
$ pow make:controller ../evil
Invalid controller name "../evil". Use StudlyCase, optionally nested: Home, UserProfile, Admin/Users.An existing file is never overwritten without --force.
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.
$ 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
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
$ pow make:form Signup --fields=email:email,password:password,plan:select,terms:checkboxWritten 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
| Flag | Effect |
|---|---|
| default | Honeypot: a decoy field and a signed render clock. Costs a visitor nothing. |
--captcha | Adds MathCaptcha — arithmetic, no JavaScript, no third party. |
--no-honeypot | Leaves 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
$ 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:
$ 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(),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
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
$ pow make:migration create_articles_table
Created database/migrations/20260811143012_create_articles_table.php
Edit it, then run: pow migrateThe name decides the starting contents. That inference is only a convenience — every shape produces a valid, portable migration.
| Name | What you get |
|---|---|
create_articles_table | CREATE TABLE articles, with DROP TABLE to reverse it |
add_slug_to_articles | ALTER TABLE articles, both ways |
backfill_search_index | An empty up()/down() pair with guidance |
CreateArticlesTable and create articles table normalise to the same thing. --table=x overrides whatever the name implied.
<?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.
$ 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
$ 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
$ 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.
$ pow make:model Note --fields=title:string,views:int,archived_at:?string| Type | Property |
|---|---|
string | public string $x = ''; |
int | public int $x = 0; |
float | public float $x = 0.0; |
bool | public bool $x = false; |
any of the above, ?-prefixed | public ?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
$ ./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 --httpsStarts 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.
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
$ ./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 stopA 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
$ ./pow routes
GET / self-check
POST /articles articles.store
GET /articles/{id:\d+} articles.show
GET /health healthThe 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
$ ./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
$ ./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_slugmigrate:rollback
$ ./pow migrate:rollback
Reversed 0003_create_articles
$ ./pow migrate:rollback --batches=2Reverses 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
$ ./pow make:seed 0002_categories
Created database/seeders/0002_categories.php
Edit it, then run: pow db:seedCreates 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
$ ./pow db:seed
Seeded 0001_demo_accountRuns 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:
$ ./pow db:seed 0002_categories
Seeded 0002_categoriesA 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.
$ ./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
$ ./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=5Every 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
$ ./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
$ ./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.
$ pow cache:clear
Application cache cleared.storage:clear
$ ./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.
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
$ ./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
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.
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
| Code | Meaning |
|---|---|
0 | Success. |
1 | Unknown command, missing dependencies, missing bootstrap, or a configuration error. |
Configuration problems are reported before anything else runs:
$ ./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
$ php /srv/app/pow migratePaths 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:
$ composer global require phponwheels/phponwheels
$ export PATH="$(composer global config bin-dir --absolute):$PATH"
$ cd ~/code
$ pow new my-appkey: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.