Getting started

Install, migrate, seed, serve — then add your first route, controller and template.

Requirements

  • PHP 8.3 or newer, with pdo_sqlite, mbstring and fileinfo.
  • Composer.
  • sockets and pcntl for 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:

Shell
$ composer global require phporbit/phporbit
$ export PATH="$(composer global config bin-dir --absolute):$PATH"   # add this line to your shell profile
What this global install is for

orbit 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

Shell
$ 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 serve

Open http://127.0.0.1:8080. More on orbit new →

Running this repository

If you cloned phporbit itself rather than scaffolding from it:

Shell
$ composer install
$ cp .env.example .env
$ ./orbit migrate
$ ./orbit db:seed
$ ./orbit serve

Open 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.

What those commands did

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
<?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.

Shell
$ 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
<?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
<?php
$routes->get('/greet/{name}', GreetController::class, 'greet');

Your first template

Templates are app/templates/*.orbit.php. {{ }} escapes; nothing else needed:

Template
{# app/templates/greet.orbit.php #}
@extends('layout')

@section('content')
    <h1>Hello, {{ $name }}</h1>

    <p>Try /greet/&lt;script&gt;alert(1)&lt;/script&gt; and view the source.</p>
@endsection

The 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
<?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');
    }
};
Shell
$ ./orbit migrate
Applied 0004_create_pings
PHP
<?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

PathWhat lives there
app/routes.phpRoute declarations
app/bootstrap.phpBoot 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

Shell
$ ./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 level
One thing to avoid

Do 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.