ornitophp / framework
A tiny educational PHP 8.4 framework — front controller, middleware, sessions, query builder. Built to be read.
Requires
- php: ^8.4
- firebase/php-jwt: ^7.1
- vlucas/phpdotenv: ^5.6
Requires (Dev)
- phpunit/phpunit: ^12.0
README
A tiny educational PHP 8.4 framework — built to be read.
What is this?
OrnitoPHP is a zero-magic PHP 8.4 framework built for one purpose: to show you how frameworks work from the inside.
Not a framework that abstracts away the complexity. One that explains it.
Born from sprinf, rebuilt from scratch. Same instincts, modern standards.
Installation
composer require ornitophp/framework
Quick start
<?php declare(strict_types=1); define('ORNITO_ROOT', dirname(__DIR__)); require dirname(__DIR__) . '/vendor/autoload.php'; $app = new \Ornito\Application(); $app->boot(); $app->run();
That's it. One front controller. No magic bootstrap, no hidden autoloading chains, no framework-specific IDE plugins required.
What's inside
| Component | Description |
|---|---|
| Router | Declarative route table, dynamic segments {name}, middleware per route |
| Middleware Pipeline | Global + route-level, composable onion-style |
| Request / Response | Superglobals quarantined into one value object; controllers return Response, kernel sends once |
| Session | Native PHP sessions, CSRF token generation, flash messages |
| Validation | Rule-string syntax: required|email|min:8 |
| Query Builder | Prepared statements, validated identifiers, where/whereInSub/orderBy/limit/offset + toSql()/getBindings() teaching extras |
| Database Commands | migrate, seed, db:fresh, create:model, create:controller, create:relation |
| Error Renderer | Branded HTML pages + JSON negotiation — same endpoint for browsers and APIs |
| Rate Limiter | File-backed, zero dependencies |
| JWT | Issue, verify, middleware — powered by firebase/php-jwt |
| CSS Presets | Dark theme, Bootstrap CDN, Tailwind CDN — one .env line switches |
See your SQL
The query builder never hides what it runs. Every builder can show the exact SQL it will execute:
$builder = User::query() ->where('active', 1) ->orderBy('created_at', 'DESC'); echo $builder->toSql(); // SELECT * FROM users WHERE active = ? ORDER BY created_at DESC print_r($builder->getBindings()); // [1] echo $builder->toPreviewSql(); // SELECT * FROM users WHERE active = 1 ORDER BY created_at DESC
toPreviewSql() renders values inline for logs and examples — display only, never executed. This is the "no hidden queries" promise made visible: what you read is exactly what runs. The full builder API (including toCountSql() and whereInSub() for N:M lookups) is in docs/database.md.
Security architecture
Security isn't a feature you bolt on. It's in the bones:
- Prepared statements everywhere — not a single string interpolation in the codebase
- SQL identifiers validated with regex before reaching the database (defense in depth)
- Superglobals quarantined — only
Request::capture()touches$_GET,$_POST,$_COOKIE - CSRF with timing-safe validation (
hash_equals), exempt for/apisegments - Open-redirect prevention —
AuthController::safeTarget()blocks//evil.comand/\\evil.com - Rate limiting — three layered buckets (account|ip, ip, account) so rotating emails or IPs cannot dodge the cap; file-backed, no extra services
- Timing-equalized login —
password_verify()always runs (dummy hash for unknown accounts), so response time cannot reveal registered emails - Registration throttled per IP — the taken-email answer is observable by
necessity; the probe volume is not (
LoginThrottle::fromBuckets())
SQL injection is not "prevented." It's structurally impossible.
The Response contract
Controllers return Response objects. The kernel calls send() exactly once. No echo. No die(). No "headers already sent."
// This is how every controller works: public function index(Request $request): Response { return $this->view('home', ['title' => 'Welcome']); }
This single decision eliminates an entire class of bugs. It's the kind of architectural choice that separates "I use PHP" from "I understand software design."
Dual error negotiation
The ErrorRenderer decides automatically between HTML and JSON:
Browser request → branded 404 page with layout
API request → {"error": "Not found", "status": 404}
Same endpoint. Same logic. Zero duplication. That's content negotiation — not a middleware you configure, but a renderer that reads the request.
Zero-dependency philosophy
Runtime: 2 packages. vlucas/phpdotenv + firebase/php-jwt.
Everything else is written from scratch:
- Router → your code, not nikic/php-parser
- Pipeline → your code, not symfony/http-kernel
- Request/Response → your code, not psr/http-message
- Session → your code, not symfony/session
- Validator → your code, not respect/validation
- RateLimiter → your code, not symfony/rate-limiter
Not because reinventing wheels is noble. Because understanding the blocks matters more than using them.
Console commands
php bin/ornito migrate # Apply pending SQL migrations php bin/ornito db:seed # Seed demo user php bin/ornito db:fresh # Destructive: drop → migrate → seed php bin/ornito create:model # Generate model + migration file php bin/ornito create:controller # Generate empty controller php bin/ornito create:relation # FK/pivot migration + relationship methods php bin/ornito show:auth-module # Enable login/register routes php bin/ornito hide:auth-module # Disable login/register routes
Test suite
composer test
192 tests. 572 assertions. The test suite is the living documentation — every edge case is tested, every behavior is documented. Read the tests to understand the framework.
Design rules
- Prepared statements only — values are bound as parameters; identifiers are validated against a strict regex as defense in depth.
- Controllers return
Response— the kernel callssend()exactly once; no controller ever echoes. - Single front controller — every request enters through
public/index.php. - Middleware pipeline — route middleware wraps the handler in an onion-style pipeline.
- Superglobals are quarantined — only
Request::capture()may read them.
Use the starter
If you want a working app with auth, views, migrations, and CSS presets out of the box:
composer create-project ornitophp/starter my-app
See ornitophp/starter for details.
License
MIT
