The orbit CLI

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

Shell
$ ./orbit help
phporbit

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

new

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

Shell
$ orbit 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
  ./orbit migrate
  ./orbit 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
$ orbit 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
$ ./orbit migrate       # --demo also wants: ./orbit db:seed
$ ./orbit serve

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
$ orbit 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
$ orbit 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
$ orbit 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
$ orbit 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
$ orbit make:controller Admin/UserProfile --view
Created app/src/Controllers/Admin/UserProfileController.php
Created app/templates/admin/user-profile.orbit.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 PhpOrbit\Http\Response;
use PhpOrbit\Http\ServerRequest;
use PhpOrbit\Routing\Handler;
use PhpOrbit\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
$ orbit 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
$ orbit 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.orbit.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 PhpOrbit\Crypto\Signer;
use PhpOrbit\Form\Field;
use PhpOrbit\Form\Form;
use PhpOrbit\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
$ orbit 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
$ orbit make:form Contact --fields=website:url
Field "website" clashes with the honeypot's decoy field — rename it, or pass --no-honeypot.

$ orbit 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
$ orbit 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 PhpOrbit\Container\RequestScope;
use PhpOrbit\Http\Response;
use PhpOrbit\Http\ServerRequest;
use PhpOrbit\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
$ orbit make:migration create_articles_table
Created database/migrations/20260811143012_create_articles_table.php

Edit it, then run: orbit 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 TEXT 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 and PostgreSQL from the start, because that is the one part of the schema the three engines spell 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
$ orbit 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
$ orbit make:migration ../evil
Invalid migration name "../evil". A migration is named in words, not by path — try create_articles_table.

$ orbit 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
$ orbit 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 PhpOrbit\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
$ orbit 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
$ ./orbit serve
phporbit listening on http://127.0.0.1:8080 (production mode) — Ctrl-C to stop
GET / -> 200

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

Starts phporbit's 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.

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
$ ./orbit ui
Starting the admin UI — migrations, mail, routes, sessions, storage.
phporbit 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
$ ./orbit 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
$ ./orbit migrate
Applying pending migrations...
  0003_create_articles
  0004_add_articles_slug

$ ./orbit 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
$ ./orbit 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
$ ./orbit migrate:rollback
Reversed 0003_create_articles

$ ./orbit 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.

db:seed

Shell
$ ./orbit db:seed
Seeded demo account: demo@example.test / correct-horse-battery

Idempotent — safe to re-run. Seed data is not a migration, because it is not a schema change and should not be part of a rollback.

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
$ ./orbit 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 ./orbit mail:test --to=you@example.test
Sent to you@example.test via smtp.

$ ./orbit 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 send goes through the same Mailer every controller uses — see Every send is persisted — so a test message is one more row in orbit mail:list, and a failed one is resendable with orbit mail:resend once whatever was wrong is fixed, without composing a new message by hand.

mail:list

Shell
$ ./orbit 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     sent    1    grace@example.test                 Receipt                                  2026-01-01T09:12:44+00:00

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

Every message sent 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 or failed; --limit defaults to 20.

mail:resend

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

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

$ ./orbit 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.

storage:clear

Shell
$ ./orbit storage:clear
Removed 14 compiled templates.

$ ./orbit 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
$ ./orbit sessions:gc
Removed 3 expired sessions.

$ ./orbit 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 orbit. 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 orbit help stays honest.

The CLI is a SAPI boundary

orbit 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
$ ./orbit 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/orbit 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 orbit 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 phporbit/phporbit
$ export PATH="$(composer global config bin-dir --absolute):$PATH"

$ cd ~/code
$ orbit 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 ./orbit for everything else.