Getting started
Install, migrate, seed, serve — then add your first route, controller and template.
Requirements
- PHP 8.3 or newer, with
pdo_sqlite,mbstringandfileinfo. - Composer.
socketsandpcntlfor the built-in server (present in most CLI builds).
Installing the CLI
Every project ends up with its own copy of orbit at its root, and that copy is what ./orbit serve, ./orbit migrate and the rest run through. To get a bare orbit command for scaffolding new projects before one exists, install it globally with Composer:
$ composer global require phporbit/phporbit
$ export PATH="$(composer global config bin-dir --absolute):$PATH" # add this line to your shell profileorbit new and orbit key:generate are the only commands it makes sense to run this way — neither needs a project to already exist. Every other command resolves paths against the project it belongs to, so those stay ./orbit ..., run from inside a scaffolded project's own directory. See The orbit CLI.
Starting a project
$ orbit new my-app # a blank application
$ orbit new my-app --demo # or the demo, with auth, uploads and the self-check
$ cd my-app
$ composer install
$ ./orbit migrate
$ ./orbit serveOpen http://127.0.0.1:8080. More on orbit new →
Running this repository
If you cloned phporbit itself rather than scaffolding from it:
$ composer install
$ cp .env.example .env
$ ./orbit migrate
$ ./orbit db:seed
$ ./orbit serveOpen http://127.0.0.1:8080. You get a self-check page that exercises routing, sessions, CSRF, migrations, the query builder, escaping and worker isolation, and reports pass or fail for each.
migrate created the schema and recorded it in a ledger table. db:seed created a demo account (demo@example.test / correct-horse-battery). serve started phporbit's own HTTP server, which applies any pending migrations first as a development convenience.
Your first route
Routes live in app/routes.php. Add one:
<?php
use PhpOrbit\Http\Response;
use PhpOrbit\Routing\RouteCollection;
return static function (RouteCollection $routes, bool $debug): void {
// ... existing routes ...
$routes->get('/ping', static fn (): Response => Response::json(['pong' => true]), 'ping');
};Reload. No build step, no cache to clear — the server re-reads the application on each request in development.
$ curl http://127.0.0.1:8080/ping
{"pong":true}Your first controller
A closure is fine for one-liners. Anything with dependencies belongs in a class implementing Handler:
<?php
// app/src/Controllers/GreetController.php
namespace App\Controllers;
use PhpOrbit\Http\Response;
use PhpOrbit\Http\ServerRequest;
use PhpOrbit\Routing\Handler;
use PhpOrbit\View\TemplateEngine;
final class GreetController implements Handler
{
// Constructor dependencies are resolved automatically, per request.
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',
]);
}
}<?php
$routes->get('/greet/{name}', GreetController::class, 'greet');Your first template
Templates are app/templates/*.orbit.php. {{ }} escapes; nothing else needed:
{# app/templates/greet.orbit.php #}
@extends('layout')
@section('content')
<h1>Hello, {{ $name }}</h1>
<p>Try /greet/<script>alert(1)</script> and view the source.</p>
@endsectionThe payload arrives as text, not markup, because escaping is what {{ }} does — not something this template remembered to ask for.
Storing something
Write a migration, run it, then use the query builder:
<?php
// database/migrations/0004_create_pings.php
use PhpOrbit\Database\Connection;
use PhpOrbit\Database\Migration;
return new class implements Migration {
public function up(Connection $database): void
{
$database->executeSchema(
'CREATE TABLE pings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
note TEXT NOT NULL,
created_at TEXT NOT NULL
)',
);
}
public function down(Connection $database): void
{
$database->executeSchema('DROP TABLE pings');
}
};$ ./orbit migrate
Applied 0004_create_pings<?php
use PhpOrbit\Database\Connection;
$id = $database->query('pings')->insert([
'note' => 'first',
'created_at' => gmdate('c'),
]);
$recent = $database->query('pings')
->where('note', '!=', '')
->orderBy('id', Direction::Descending)
->limit(10)
->get();Project layout
| Path | What lives there |
|---|---|
app/routes.php | Route declarations |
app/bootstrap.php | Boot phase: services, middleware, configuration |
app/src/ | Your classes (App\ namespace) |
app/templates/ | *.orbit.php templates |
database/migrations/ | Schema changes |
public/ | Document root: front controller, assets |
storage/ | Sessions, compiled templates, SQLite file (git-ignored) |
src/ | The framework itself |
Useful commands
$ ./orbit serve --port=9000 --debug # --debug shows exceptions, recompiles templates
$ ./orbit routes # print the compiled route table
$ ./orbit migrate:status # what has run, and what has not
$ composer test # the test suite
$ composer stan # static analysis at max levelDo not use php -S as your development server. It works, and it is a quick way to exercise the per-request path, but it is not one of the four supported targets and it does not share the pipeline that production uses. ./orbit serve runs the same code as FrankenPHP, which is what makes a bug reproducible on your machine.