flightphp/core
Flight is a fast, simple, extensible PHP micro-framework. It offers straightforward routing, RESTful API support, middleware, view rendering, and a lightweight core that integrates easily with existing PHP libraries. Great for small apps, APIs, and prototypes.
Flight is a single-purpose micro-framework: one composer package, no service container you didn't write, no ORM, no console. Install it as flightphp/core (the Packagist name matches the repo, not the flight/... namespace you might guess):
composer require flightphp/core
A complete app is three files and about fifteen lines. public/index.php:
<?php
require __DIR__ . '/../vendor/autoload.php';
Flight::route('GET /health', function () {
Flight::json(['ok' => true, 'ts' => time()]);
});
Flight::route('POST /webhooks/stripe', function () {
$payload = Flight::request()->getBody();
// verify HMAC, enqueue work, etc.
Flight::json(['received' => true], 202);
});
Flight::start();
Plus a rewrite so everything funnels through index.php — Apache .htaccess (RewriteCond %{REQUEST_FILENAME} !-f → index.php) or nginx try_files $uri $uri/ /index.php?$query_string. Dev server: php -S localhost:8000 -t public (the package ships composer test-server for its own suite, which is a fine reference for a working router setup).
Where to look first, in order:
flightphp.com/learn) — the API surface is small enough to read end-to-end in an hour.flightphp/skeleton — an opinionated starter with a real app/ layout, config, and DI wiring. Read this before inventing your own structure; it saves you from reinventing Laravel badly.tests/ in the repo — the authoritative answer for edge cases (route matching, request parsing, halt semantics) when the docs are vague.Your first real use case should not be "replace a Laravel app." It should be the endpoint that doesn't deserve a full framework boot: a Stripe/GitHub webhook receiver, a /health and /metrics sidecar, an internal one-off tool behind basic auth, a tiny JSON API in front of an existing database. That's where Flight earns its place in a Laravel developer's day — 40ms → 3ms bootstrap, no bootstrap/app.php, no container compilation, no config cache warmup.
Mental model translation:
| Laravel | Flight |
|---|---|
routes/api.php |
Flight::route('GET /x', $callable) calls in a required file |
Service provider bind/singleton |
Flight::register('name', Class::class, $args, $afterCb) (lazy) |
app()->make() |
Flight::name() or Flight::get('name') |
config/*.php + config() |
plain array + Flight::set('config', $array) / Flight::get('config') |
| Middleware classes | route middleware arrays ([fn, 'middleware']) + before/after filters |
| Blade | native PHP views via Flight::render(), or register BladeOne/Latte/Twig |
| Eloquent | bring illuminate/database yourself (see below) |
php artisan |
nothing — write scripts |
Keep index.php a bootstrap, not an application. Mirror Laravel's discipline even though nothing forces it:
// public/index.php
require __DIR__ . '/../app/bootstrap.php'; // autoload, config, container bindings, error handlers
require __DIR__ . '/../app/routes.php'; // route table only
Flight::start();
Use Flight::register as a lazy container. It is not a DI container — it's a named-factory registry that only instantiates on first access, which is exactly the performance property you want (no PDO connection on a route that never touches the DB):
Flight::register('db', PDO::class, [$dsn, $user, $pass], function (PDO $pdo) {
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
});
Flight::route('GET /users/@id:[0-9]+', function ($id) {
$stmt = Flight::db()->prepare('SELECT * FROM users WHERE id = ?');
$stmt->execute([$id]);
$user = $stmt->fetch() ?: null;
$user ? Flight::json($user) : Flight::json(['error' => 'not found'], 404);
});
Controllers stay plain classes. Flight accepts array callables, so your handler logic is ordinary testable PHP — same instinct as Laravel's invokable controllers, minus the framework base class:
final class UserController
{
public function __construct(private PDO $db) {}
public function show(string $id): void
{
// ...
Flight::json($user);
}
}
Flight::route('GET /users/@id', [UserController::class, 'show']);
If you want constructor injection without hand-writing every new, register a tiny reflection-based resolver via Flight::map('make', ...) and call Flight::make(UserController::class). Ten lines, no league/container dependency. That is the spirit of the framework: compose, don't pull in a container.
The biggest leverage point for a Laravel dev: share Eloquent. illuminate/database works standalone, so a Flight service can read/write the same models, migrations, and schema as your Laravel app rather than duplicating a second data layer:
use Illuminate\Database\Capsule\Manager as Capsule;
$laravelDb = require '/var/www/laravel/config/database.php'; // plain arrays, require-able
$capsule = new Capsule;
$capsule->addConnection($laravelDb['connections']['mysql']);
$capsule->setAsGlobal();
$capsule->bootEloquent();
Point Composer's autoload at the shared App\Models namespace and User::find($id) behaves identically on both sides. Same trick for config: Laravel's config/*.php files return plain arrays, so require them from Flight's bootstrap and you get zero config drift — just strip Laravel-only keys (e.g. search_path) if Capsule rejects a connection array.
Hand work back to Laravel instead of reimplementing it. For anything async, write a row into Laravel's jobs table (or push the same payload to the same Redis queue) with the App\Jobs\... class name Laravel expects, and let your existing worker fleet do the processing. Flight stays a dumb, fast front door; Laravel stays the brain. Same logic for mail, PDF generation, and anything needing heavy dependencies.
Middleware is array-shaped, not class-shaped. Auth on a group:
Flight::route('GET /admin/*', [function () {
// ... verify token
if (!$ok) { Flight::json(['error' => 'unauthorized'], 401); Flight::stop(); }
return true; // continue
}, 'requireAuth']);
Wire the named function with Flight::map('requireAuth', fn() => ...) or pass closures directly. Flight::group('/api', function () { ... }) nests route definitions for prefixing.
Normalize the error envelope so clients can't tell which app answered:
Flight::map('error', function (Throwable $e) {
error_log((string) $e);
Flight::json(['error' => 'server_error'], 500);
});
Flight::map('notFound', fn() => Flight::json(['error' => 'not_found'], 404));
Views: default is native PHP templates (Flight::render('user', ['user' => $u]) with Flight::set('flight.views.path', __DIR__.'/views')). If Blade muscle memory matters more than dependency purity, register eftec/bladeone (single file, no Illuminate) through Flight::register('view', ...) and keep Flight::render() calls unchanged.
Testing: because handlers are plain classes, ~90% of your tests are ordinary PHPUnit unit tests with a PDO mock or SQLite. For HTTP-level tests, bootstrap Flight inside PHPUnit and capture output with ob_start(), or hit composer test-server with a real client. There's no RefreshDatabase/actingAs — you reset global state manually (next section).
Flight::set(), Flight::register(), and Flight::map() persist for the process lifetime. Harmless under PHP-FPM (process dies at request end), a landmine in PHPUnit (call Flight::set() cleanups in tearDown) and in long-running workers (RoadRunner/Swoole/FrankenPHP: you must re-run your bootstrap and clear the registry per request or state bleeds between users).Flight::json() and Flight::render() do not terminate the request. A later echo, or an after filter, corrupts the body — and it's silent until a client complains about trailing garbage. Always follow with return and Flight::stop() if there's any code path after it. Flight::halt($code) exists for the same job.route:cache equivalent — every request re-evaluates your route table. Keep it flat; if you're at 200 routes, you probably wanted Laravel.GET /users route does not serve /users/. Register both or normalize in the rewrite.Flight::request()->data is not a body-parser you can trust blindly. It handles form-encoded and JSON, but the shape (array vs stdClass) and content-type sniffing vary — for webhooks, always use Flight::request()->getBody() and json_decode(..., true) yourself, and verify the raw bytes for HMAC signatures before any decoding.return true. Anything falsy stops the chain in some versions; don't rely on implicit returns. If a middleware 401s, call Flight::json(..., 401) then Flight::stop() — don't exit, or your error/logging hooks never run.extract() your data and echo it raw. Every output needs htmlspecialchars($x, ENT_QUOTES). This is the single easiest way to ship an XSS bug when coming from Blade.SameSite=Lax/Strict on your own session_start() cookies, or keep it non-browser.Flight::set('flight.log_errors', true), register the error handler, and confirm display_errors is off in the php.ini/FPM pool — otherwise stack traces go to clients because there's no debug/production toggle.Flight::set('flight.base_url', '/tools') or Flight::url() and redirects will point at the domain root and land you in 404s that look like routing bugs.Content-Length for buffered output; for SSE, chunked downloads, or long polls, disable that behavior (Flight::set('flight.content_length', false)) and flush explicitly, or the browser waits for a length it will never get.Flight::map() and Flight::register(). Override or wrap core methods (a Flight::render that injects a layout, a Flight::json that adds an envelope) rather than forking. A "plugin" is just a required file that calls register/map — there's no provider lifecycle, which is the point and also why you must be disciplined about where those files run.artisan, you've left Flight's design envelope. Keep the Laravel app for that and let Flight own the one endpoint that benefits from a 3ms boot.How can I help you explore Laravel packages today?