ornitophp/framework

A tiny educational PHP 8.4 framework — front controller, middleware, sessions, query builder. Built to be read.

Maintainers

Package info

github.com/kheredia04/ornitophp-framework

pkg:composer/ornitophp/framework

Transparency log

Statistics

Installs: 34

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

v1.3.0 2026-08-30 11:05 UTC

This package is auto-updated.

Last update: 2026-08-30 11:31:21 UTC


README

OrnitoPHP

A tiny educational PHP 8.4 framework — built to be read.

Latest Stable Version License PHP Version Downloads

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 /api segments
  • Open-redirect preventionAuthController::safeTarget() blocks //evil.com and /\\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 loginpassword_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

  1. Prepared statements only — values are bound as parameters; identifiers are validated against a strict regex as defense in depth.
  2. Controllers return Response — the kernel calls send() exactly once; no controller ever echoes.
  3. Single front controller — every request enters through public/index.php.
  4. Middleware pipeline — route middleware wraps the handler in an onion-style pipeline.
  5. 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