fubber/mini

Forkable core PHP framework with zero dependencies beyond PSR interfaces: SQL-first database layer with a federated virtual SQL engine, PSR-15 routing, template inheritance, ICU i18n, RFC 5322 mail, JSON Schema validation — provides implementations for the PSR container, http-message, http-client, s

Maintainers

Package info

github.com/frodeborli/fubber-mini

pkg:composer/fubber/mini

Transparency log

Statistics

Installs: 66

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

v0.17.3 2026-06-09 22:56 UTC

README

Designed for decades, not release cycles.

Mini is a forkable core framework — a complete, zero-dependency foundation you can build on or own outright:

  • Built to sit underneath. Mini provides the generic building blocks — routing, HTTP, database, auth, i18n, validation, events. Opinionated conveniences (scaffolding, admin panels, a GenericCRUDAPI) belong in a "Maxi"-style layer on top of Mini, or in your application. The core stays small enough to understand end to end.
  • Built to be forked. A business that needs a head start it can maintain for a decade can fork Mini and own every line. There is no third-party abandonment risk to inherit: the require section is PHP itself, PSR interface packages, and two intl polyfill shims. The audit surface is Mini's own source.
  • Built on Lindy foundations. APIs that have survived decades — SQL, MIME, PSR contracts, the filesystem, ICU, LINQ-style immutable composition — over trendy framework idioms that trigger a rewrite every major version. If a pattern has worked for 40 years, it will likely work for 40 more.

LLMs and senior developers: read MINI-STYLE.md before working on Mini projects.

composer require fubber/mini
mkdir html _routes
echo '<?php require __DIR__."/../vendor/autoload.php"; mini\dispatch();' > html/index.php
echo '<?php return fn() => date("c");' > _routes/time.php
vendor/bin/mini serve

Visit http://localhost:8080/time - you're running.

Aspects: organizing your application

In Mini, an aspect is a self-contained unit of an application — a feature (blog, doc-editor, support tickets), a layer (authentication, search), or a presentation concern (theme). Aspects live at aspects/<name>/, are real Composer packages with their own composer.json and PSR-4 namespace, and contribute their resources (routes, views, static assets, config, translations, migrations, PHP code) to the host application via path-registry overlays.

vendor/bin/mini aspects        # scaffold composer.json + _bootstrap.php, sync state
vendor/bin/mini aspects list   # list discovered aspects

The host application's _routes/, _views/, etc. take precedence; aspect resources fill gaps. Because each aspect is already structured as a Composer package, extracting one into a standalone published package later is a configuration change, not a refactor.

For a single-route prototype or a microservice you may never reach for aspects. For anything larger they're the structural unit — even for features you don't intend to share between applications, because keeping each feature in its own folder is good design.

Run vendor/bin/mini aspects --help for the CLI details.

Modules

Mini provides composable, well-implemented functionality for many concerns in modern application development. All features lazy-load—nothing is loaded until touched.

Core Infrastructure

  • Service Container: Mini::$mini is a PSR-11 container managing service lifetimes (Singleton, Scoped, Transient), application phases, and dependency resolution. Access services via helper functions like db(), cache(), auth().

  • Dispatcher: Bootstraps PSR-7 requests from HTTP globals, routes requests, converts exceptions to responses, and emits PSR-7 responses. Entry point: mini\dispatch().

  • Router: File-based routing where paths map directly to _routes/ files. Supports wildcards (_.php), dynamic segments, controller mounting via __DEFAULT__.php, and PSR-15 handler integration. For pattern based routing, combine file based routing with Controllers.

  • Http: PSR-7 compliant request/response objects, HTTP exceptions, and customizable error pages.

  • Session: Transparent session management that replaces $_SESSION with a fiber-safe proxy. Auto-starts on access, integrates with async runtimes.

Security

  • Auth: Authentication facade where applications implement AuthInterface to define their auth scheme (sessions, JWT, API keys). Mini provides auth() for checking authentication, roles, and permissions.

  • Authorizer: Resource-based authorization answering "can this user do X to Y?" Check with can(Ability::Delete, $post) at collection, instance, or field level. Handlers resolve by type specificity.

Data Access

  • Database: PDO-backed database abstraction with db() for queries, transactions, and an immutable query builder (PartialQuery). Zero-config SQLite by default, environment-based MySQL/PostgreSQL configuration. Mini also provides a composable SQL 2003 query evaluator (INSERT, UPDATE, CREATE, DELETE with subqueries and joins and CTEs) in VirtualDatabase with predicate pushdown which can facilitate safe SQL access via APIs or be used internally as backends for entities. You can mount any PartialQuery as a table in a virtual table, and this works from real database backends and other virtual databases.

  • Table: Composable query builder for tabular data sources (arrays, CSV, JSON, database results) with a unified fluent API for filtering, sorting, and joining.

  • Cache: Zero-configuration PSR-16 caching that auto-selects the best driver (APCu → SQLite → Filesystem). Supports namespaced isolation and custom backends.

Web & API

  • Controller: Attribute-based routing with #[GET], #[POST], etc. Type-safe URL parameters, automatic return value conversion (arrays → JSON), and PSR-15 compatibility.

  • Template: Pure PHP templates with multi-level inheritance via $this->extend() and $this->block(). No template language to learn—just PHP.

  • Static: PSR-15 middleware serving static files from _static/ with HTTP caching, conditional requests (304), and multi-level path resolution.

  • Converter: Type conversion registry transforming return values, exceptions, and domain objects to HTTP responses. Resolves by type specificity with union type support.

Communication

  • Mail: RFC 5322-compliant email composition with MIME structure, HTML with inline images, attachments, and pluggable transports (mail, sendmail).

  • Logger: PSR-3 compatible logging with ICU MessageFormatter interpolation. Built-in handler writes to error_log; swap in Monolog or any PSR-3 logger.

Validation & Schema

  • Validator: JSON Schema-compatible validation with fluent API and attribute-based schemas. Composable, purpose-scoped (Create/Update), exportable to JSON Schema for client-side use.

  • Metadata: JSON Schema annotations for documenting classes via attributes—titles, descriptions, examples, UI hints—separate from validation rules.

Internationalization

  • I18n: Translation via t() using ICU MessageFormat (pluralization, gender, selects). File-per-source structure mirrors your code. Locale-aware formatting via fmt() for currency, dates, numbers.

Events & Extensibility

  • Hooks: Event dispatcher system with specialized patterns—Event (multi-fire), Trigger (one-time with memory), Handler (chain of responsibility), Filter (data pipeline), StateMachine.

Utilities

  • CLI: Argument parsing for command-line tools via ArgManager. Flags, options, subcommands, and delegation to external tools.

  • UUID: UUID generation with time-ordered v7 (database-friendly) as default, v4 for maximum randomness. ~200k UUIDs/second.

  • Async: Interface for async runtimes (phasync, Swoole, ReactPHP) to integrate with Mini's fiber-aware architecture.

  • Inference: Service interface for LLM-based structured evaluation—send prompts, receive schema-compliant JSON responses.

  • Util: Foundation utilities—IdentityMap (weak-ref object tracking), InstanceStore (typed singletons), Path (cross-platform paths), PathsRegistry (priority-based file resolution), QueryParser (SQL-like filtering), MachineSalt (zero-config cryptographic salt), and arbitrary-precision math (Decimal, BigInt).

CLI Tools

Mini includes command-line tools via vendor/bin/mini:

  • mini serve: Development server (PHP's built-in server with the correct document root)
  • mini aspects: Scaffold and sync aspect bundles with Composer
  • mini migrations: Migration runner with tracking, rollback, and make scaffolding
  • mini translations: Manage translation files—validate, add missing strings, add languages, remove orphans
  • mini docs: Browse PHP documentation for classes, functions, and namespaces
  • mini test: Run tests with pattern matching
  • mini db: Interactive SQL REPL for your database (-v for the VirtualDatabase shell over CSV/JSON sources)
  • mini benchmark: Benchmark framework performance

Philosophy

Mini is built on a Lindy perspective: if a pattern has worked for 40 years, it will likely work for 40 more. We reject patterns that trigger frequent redesign.

Use PHP's engine, not userland abstractions. Traditional frameworks reinvent locale handling, date formatting, routing, and templating in PHP code. Mini uses PHP's C-level engine: intl extension for ICU, file system for routing, PHP files for templates.

Dependency locator, not dependency injection. DI in PHP forces proxy classes, scattered configuration, and compilation steps. We locate dependencies via db(), cache(), auth() - simple functions resolving from Mini::$mini. Same testability (swap the container service), no proxy explosion.

Embrace PHP's short-lived request cycle. PHP bootstraps fresh for each request - no memory leaks, no stale state, predictable cleanup. We optimize for this reality instead of fighting it.

One routing contract. _routes/ files declare what handles a URL prefix: they must return a PSR-15 RequestHandlerInterface (typically a controller extending mini\Controller\AbstractController) or a PSR-7 ResponseInterface. A Closure is also accepted — it acts as an inline RequestHandler with typed parameter injection, and its return value (arrays, strings, domain objects) is converted to a response via the converter registry. Route files are not a place to produce output: echo/header() in a route file throws — direct output is tied to the SAPI process model and cannot survive the move to Fiber-based coroutine runtimes.

Fiber-safe globals. $_GET, $_POST, $_COOKIE, $_SESSION are ArrayAccess proxies routing to the current PSR-7 request context — works in FPM, Swoole, ReactPHP, and Fiber-based async. (Use these freely. echo, header(), die() are not available in routes at all — see "One Paradigm" below.)

Full-stack, lazy-loaded. ORM, auth, i18n, templates, validation — all included, nothing loads until touched. Hello World uses ~300KB.

Zero non-PSR dependencies. The require section is php >=8.3, seven psr/* interface packages, and two symfony/polyfill-intl-* shims that activate only when the intl extension is missing. Mini provides implementations for five PSR contracts (container, http-message, http-client, simple-cache, log). The audit surface is Mini's own source. Aspects and host applications can compose any PSR-compatible package from Packagist without conflict — the ecosystem is the ecosystem.

A core, not a kitchen sink. Generic building blocks belong in Mini; opinionated conveniences belong in a "Maxi"-style framework layered on top, or in your application. When evaluating whether something should be added to Mini, the question is: "does this belong in a forkable core, or in a layer on top?"

Vertically integrated scaling path. Mini today runs on PHP-FPM like any PHP framework. The fiber-safe globals, streaming dispatcher, and immutable query builder are designed for the phasync coroutine runtime and the upcoming Swerve application server — same author, same stack. Code written today on FPM is intended to run on Swerve without rewriting.

See docs/WHY-MINI.md for the design rationale behind each of these choices and their tradeoffs.

Engine-Native, Not Userland-Native

We use PHP's C-level engine, not userland reimplementations. Modern frameworks reimplement locale handling, date formatting, and number formatting in PHP code. Mini uses PHP's intl extension (ICU library in C) and native functions.

Engine-Level Performance

Internationalization:

// Mini: Use PHP's intl extension (C-level ICU)
\Locale::setDefault('de_DE');           // Sets locale for entire engine
echo fmt()->currency(19.99, 'EUR');     // "19,99 €" - formatted by ICU in C
echo t("Hello, {name}!", ['name' => 'World']);  // MessageFormatter in C

// Framework approach: Load massive translation arrays, parse ICU in PHP
$translator->trans('messages.welcome', ['name' => 'World'], 'en_US');

Routing:

// Mini: File system IS the routing table (OS-cached, instant lookup)
_routes/users/_.php  // Wildcard matches any ID, captured in $_GET[0]

// Framework approach: Parse regex routes on every request (slow)
$router->addRoute('GET', '/users/{id}', [UserController::class, 'show']);

Templates:

// Mini: PHP IS the template language (no parsing overhead)
<?= htmlspecialchars($user->name) ?>  // Direct output buffering, closure-based inheritance

// Framework approach: Parse string templates into PHP (Blade, Twig)
{{ $user->name }}

What We Use (And Why)

Request/Response:

  • $_GET, $_POST, $_COOKIE, $_SESSION — request-scoped ArrayAccess proxies, fiber-safe; use freely in any environment
  • header(), http_response_code(), echo, dienot available in routes; direct output during route handling throws. The dispatcher emits the Response you return. (Direct output is SAPI-only and cannot survive a move to a coroutine runtime.)
  • \Locale::setDefault(), date_default_timezone_set() — engine-level configuration

Helpers when they genuinely simplify:

$users = db()->query("SELECT * FROM users WHERE active = ?", [1])->all();
echo render('user/profile', ['user' => $user]);
echo t("Hello, {name}!", ['name' => 'World']);
$_SESSION['seen'] = true;  // Session auto-starts on access (fiber-safe proxy)

Lazy-Loading Architecture

All features exist, but nothing loads until touched:

mailer();                  // Mail transport for sending emails
db();                      // Opens the database connection on first use
auth()->isAuthenticated(); // Loads authentication system on demand

This "soft dependency" pattern means:

  • A "Hello World" app uses ~300KB of memory
  • Full-stack enterprise app uses what it needs
  • No bootstrap penalty for unused features

Configuration over code. Override framework services via config files or environment variables:

  • Set DATABASE_URL=mysql://user:pass@host/db for database (works out of the box)
  • Create _config/Psr/Log/LoggerInterface.php to return your logger
  • Framework loads these automatically - no service registration needed

One Paradigm: Declare a Handler, Return a Response

Route files declare what handles a URL prefix. They must return one of:

  • A PSR-15 RequestHandlerInterface — typically a controller extending mini\Controller\AbstractController with pattern-based sub-routing
  • A PSR-7 ResponseInterface — a direct response
  • A Closure — an inline handler with typed parameter injection; its return value is converted to a response via the converter registry
  • A mini\Http\ResponseAggregate — resolved via getResponse()
// _routes/users/__DEFAULT__.php — a controller handles the /users/* subtree
return new UserController();  // extends AbstractController, pattern-based sub-routing
// _routes/ping.php — direct response
return new \mini\Http\Message\Response('pong');
// _routes/time.php — inline handler; return value converted (here: array → JSON)
return fn() => ['time' => date('c')];

Direct output is an error. A route file that echos, calls header(), or returns nothing throws a RuntimeException. Returning scalars or arrays directly from a route file also throws — "return data and let the converter turn it into a response" is what Closure and controller-method return values are for.

Earlier versions of Mini also accepted the classical PHP pattern (echo + header() in the route file). It was removed: direct output ties an application to one-process-per-request SAPIs and cannot survive the move to Fiber-based coroutine runtimes (phasync-style async is where Mini is headed). It also invited poor architecture — output produced during routing instead of composable handlers.

The Fiber-Safe Globals Stay

Mini provides request-scoped proxies for $_GET, $_POST, $_COOKIE, $_SESSION that resolve against the current PSR-7 ServerRequest:

  • In SAPI environments (FPM, CGI, mod_php): proxies read from PHP's native superglobals
  • In non-SAPI environments (Swoole, ReactPHP, phasync with Fibers): proxies read from the per-coroutine PSR-7 request

Use them freely in handlers — they are environment-agnostic and remain the recommended way to read request data. It is only the output side that must go through a Response:

  • Sub-application mounting: PSR-15 sub-apps (Slim, Mezzio, etc.) plug in without conflicts because everyone speaks Response.
  • Future async support: the fiber-safe globals already work everywhere, and handlers that return a Response will Just Work in a coroutine runtime.

Installation

composer require fubber/mini

Quick Start

Create the entry point:

// html/index.php
<?php
require __DIR__ . '/../vendor/autoload.php';
mini\dispatch();

Create your first route:

// _routes/index.php
<?php
return new mini\Http\Message\HtmlResponse("<h1>Hello, World!</h1>");

Start the development server:

vendor/bin/mini serve

Visit http://localhost:8080 - you're running!

Building CLI Tools

Mini also provides argument parsing and logging for command-line tools:

composer require fubber/mini
#!/usr/bin/env php
<?php
require_once __DIR__ . '/../vendor/autoload.php';
use function mini\args;

args(args()
    ->withFlag('v', 'verbose')
    ->withRequiredValue('o', 'output')
);

if (args()->getUnparsedArgs()) {
    fwrite(STDERR, "Unknown: " . implode(', ', args()->getUnparsedArgs()) . "\n");
    exit(1);
}

$verbosity = args()->getFlag('verbose');  // 0, 1, 2, 3 for -v, -vv, -vvv
$output = args()->getOption('output');

See docs/cli-tools.md for subcommand patterns, verbosity-controlled logging, and complete examples.

Routing: File System as Routing Table

Mini uses the file system as its routing table. No regex parsing, no route compilation, no routing cache - just OS-level file lookups (microseconds, cached by the kernel).

File-Based Routing

Routes map directly to PHP files in _routes/:

_routes/index.php        → /
_routes/users.php        → /users
_routes/api/posts.php    → /api/posts

Wildcard Routing with _

Use _ as a filename or directory name to match any single path segment:

// _routes/users/_.php - Matches /users/123, /users/john, /users/anything
return function() {
    $userId = $_GET[0];  // Captured value: "123", "john", "anything"
    return db()->queryOne("SELECT * FROM users WHERE id = ?", [$userId]);  // → JSON
};
// _routes/users/_/posts/_.php - Matches /users/{userId}/posts/{postId}
return function() {
    $postId = $_GET[0];   // Rightmost wildcard (nearest to file)
    $userId = $_GET[1];   // Next wildcard to the left
    return db()->queryOne("SELECT * FROM posts WHERE id = ? AND user_id = ?", [$postId, $userId]);
};

Wildcard behavior:

  • _.php matches any single segment (e.g., /users/123)
  • _/index.php matches any single segment with trailing slash (e.g., /users/123/)
  • Exact matches take precedence over wildcards
  • Captured values stored in $_GET[0], $_GET[1], etc. (right to left - nearest wildcard is [0])
  • Wildcards match single segments only (won't match across /)

Examples:

URL: /users/123           → _routes/users/_.php          ($_GET[0] = "123")
URL: /users/123/          → _routes/users/_/index.php    ($_GET[0] = "123")
URL: /users/john/posts/5  → _routes/users/_/posts/_.php  ($_GET[0] = "5", $_GET[1] = "john")

Why right-to-left? If you move _routes/users/_/posts/_.php to _routes/_/posts/_.php, the code using $_GET[0] for post ID still works - only $_GET[1] changes.

Trailing Slash Redirects

The router automatically redirects to ensure consistency:

  • If only _.php exists: /users/123/ → 301 redirect to /users/123
  • If only _/index.php exists: /users/123 → 301 redirect to /users/123/
  • If both exist: Each URL serves its respective file (no redirect)

What route files must return (anything else — including no return or direct output — throws a RuntimeException):

  • PSR-15 RequestHandlerInterface — a controller extending AbstractController, or e.g. a mounted Slim/Mezzio app
  • PSR-7 ResponseInterface — a direct response
  • Closure — an inline handler with typed parameter injection (see "Mounting a module's handler method" in docs/web-apps.md); its return value is converted via the converter registry; great for one-line delegation to a service method
  • mini\Http\ResponseAggregate — resolved via getResponse()
// _routes/users.php - PSR-7 response
use mini\Http\Message\JsonResponse;
return new JsonResponse(['users' => db()->query("SELECT * FROM users")->all()]);
// _routes/users.php - Inline handler; returned array is converted to JSON
return fn() => ['users' => db()->query("SELECT * FROM users")->all()];

Controller-Based Routing

File-based routing doesn't mean "no OOP." Use __DEFAULT__.php to mount controllers with attribute-based routing:

// _routes/users/__DEFAULT__.php - Handles /users/*
use mini\Controller\AbstractController;
use mini\Controller\Attributes\{GET, POST, PUT, DELETE};
use Psr\Http\Message\ResponseInterface;

return new class extends AbstractController {
    #[GET('/')]
    public function index(): array
    {
        return db()->query("SELECT * FROM users")->all();
    }

    #[GET('/{id}/')]
    public function show(int $id): object
    {
        $user = db()->queryOne("SELECT * FROM users WHERE id = ?", [$id]);
        if (!$user) throw new \mini\Exceptions\NotFoundException();
        return $user;
    }

    #[POST('/')]
    public function create(): array
    {
        db()->exec(
            "INSERT INTO users (name, email) VALUES (?, ?)",
            [$_POST['name'], $_POST['email']]
        );
        return ['id' => db()->lastInsertId(), 'message' => 'Created'];
    }

    #[PUT('/{id}/')]
    public function update(int $id): array
    {
        db()->exec(
            "UPDATE users SET name = ?, email = ? WHERE id = ?",
            [$_POST['name'], $_POST['email'], $id]
        );
        return ['message' => 'Updated'];
    }

    #[DELETE('/{id}/')]
    public function delete(int $id): ResponseInterface
    {
        db()->exec("DELETE FROM users WHERE id = ?", [$id]);
        return $this->empty(204);
    }
};

Key benefits:

  • Scoped routing: /users/123/ becomes /{id}/ inside the controller
  • Type-aware parameters: int $id automatically extracts and casts URL parameter
  • Converter integration: Return arrays, strings, or domain objects - auto-converted to JSON/text
  • Attribute-based: Routes declared with method attributes (no manual registration)

URL mapping:

  • GET /users/index() → returns array → JSON response
  • GET /users/123/show(int $id)$id = 123 (typed!)
  • POST /users/create() → uses $_POST directly
  • DELETE /users/123/delete(int $id) → returns 204 No Content

When to use controllers:

  • Multiple related endpoints (CRUD operations)
  • Type-safe URL parameters
  • Return value conversion (arrays → JSON)
  • Clean, declarative routing

Exception Handling

Mini uses transport-agnostic exceptions that are mapped to appropriate responses by the dispatcher:

// Throw domain exceptions - dispatcher handles HTTP mapping
throw new \mini\Exceptions\NotFoundException('User not found');                 // → 404
throw new \mini\Exceptions\AuthenticationRequiredException('Login required');   // → 401
throw new \mini\Exceptions\AccessDeniedException('Admins only');                // → 403
throw new \mini\Exceptions\BadRequestException('Invalid email format');         // → 400

Debug mode shows detailed error pages with stack traces. In production, clean error pages are shown.

Custom error pages: Create _views/errors/404.php, _views/errors/500.php, etc. to override the framework defaults (they are ordinary templates, resolved application-first). The exception is available as $exception.

For complete coverage of routing, error handling, converters, and web app patterns, see docs/web-apps.md.

Dynamic Routes with __DEFAULT__.php

The usual way to handle a dynamic subtree is returning a controller (see above). For plain path remapping, __DEFAULT__.php can also throw a Reroute with pattern → request-path mappings:

// _routes/blog/__DEFAULT__.php
use mini\Router\Reroute;

throw new Reroute([
    '/' => '_index',                               // /blog/        → _index.php
    '/{slug}' => fn($slug) => "_post?slug=$slug",  // /blog/my-post → _post.php
    '/{year}/{month}' => fn($year, $month) => "_archive?year=$year&month=$month",
]);

Mounting Sub-Applications

Mini's zero-dependency design enables mounting entire frameworks as sub-applications without dependency conflicts. Each sub-app can have its own vendor/ directory with different dependency versions.

Example: Mount a Slim 4 Application

// _routes/api/__DEFAULT__.php
require_once __DIR__ . '/api-app/vendor/autoload.php';  // Slim's autoloader

use Slim\Factory\AppFactory;

$app = AppFactory::create();

// Define Slim routes
$app->get('/users', function ($request, $response) {
    $response->getBody()->write(json_encode(['users' => []]));
    return $response->withHeader('Content-Type', 'application/json');
});

$app->post('/users', function ($request, $response) {
    $data = $request->getParsedBody();
    // ... handle user creation
    return $response->withStatus(201);
});

// Return the Slim app (implements RequestHandlerInterface)
return $app;

Project structure with mounted apps:

project/
├── _routes/
│   ├── index.php              # Mini native route
│   ├── api/
│   │   ├── __DEFAULT__.php    # Mounts Slim app
│   │   └── api-app/           # Complete Slim application
│   │       ├── composer.json  # Slim's dependencies (guzzle 7.x)
│   │       └── vendor/        # Slim's vendor directory
│   └── admin/
│       ├── __DEFAULT__.php    # Mounts Symfony app
│       └── admin-app/         # Complete Symfony application
│           ├── composer.json  # Symfony's dependencies (guzzle 6.x)
│           └── vendor/        # Symfony's vendor directory
├── composer.json              # Mini (PSR interfaces only)
└── vendor/                    # Mini's vendor directory

How It Works

  1. Mini has no third-party implementation dependencies - only PSR interface packages and intl polyfills
  2. Sub-apps are isolated - each has its own vendor/autoload.php
  3. PSR-7 bridges everything - Mini provides ServerRequestInterface, sub-apps return ResponseInterface
  4. No conflicts - Slim can use guzzlehttp/psr7:7.x, Symfony can use 6.x, no collision

Supported Sub-Applications

Any framework/application that:

  • Implements Psr\Http\Server\RequestHandlerInterface (PSR-15), OR
  • Is a callable accepting ServerRequestInterface and returning ResponseInterface (PSR-7)

Examples:

  • Slim 4 - Native PSR-15 support
  • Mezzio (formerly Zend Expressive) - Native PSR-15 support
  • Symfony - Via PSR-15 adapters (e.g., symfony/psr-http-message-bridge)
  • Custom PSR-15 middleware stacks
  • Any PSR-7/PSR-15 compliant application

Why This Matters

Traditional monorepos fail when dependencies conflict. With Mini:

  • Marketing team uses Slim 4 with latest dependencies
  • Support team maintains legacy Symfony 4 app with old dependencies
  • API team writes new endpoints in Mini native code
  • All three run in one application - no Docker, no microservices, no reverse proxy routing

Database

Mini implements DatabaseInterface with two backends:

PDODatabase - Thin wrapper over PDO:

$users = db()->query("SELECT * FROM users WHERE active = ?", [1]);
$user = db()->queryOne("SELECT * FROM users WHERE id = ?", [123]);

db()->exec("INSERT INTO users (name, email) VALUES (?, ?)", ['John', 'john@example.com']);
db()->transaction(function() {
    db()->exec("INSERT INTO users (name) VALUES (?)", ['John']);
});

VirtualDatabase - SQL interface to non-SQL data (CSV, JSON, APIs):

use mini\Database\VirtualDatabase;
use mini\Table\CSVTable;

$vdb = new VirtualDatabase();
$vdb->registerTable('countries', CSVTable::fromFile('data/countries.csv'));

// Query CSV files with SQL
foreach ($vdb->query("SELECT * FROM countries WHERE continent = ?", ['Europe']) as $row) {
    echo $row['name'];
}

Security Pattern: ::mine() as Authorization Boundary

Prevent accidental data leaks by making authorization the default:

class User {
    public static function mine(): Query {
        $userId = auth()->getUserId();
        // Only return users accessible to current user
        return self::query()->where('id = ? OR EXISTS (...)', [$userId]);
    }
}

// Secure by default - always use ::mine()
$user = User::mine()->eq('id', 123)->one();  // Returns null if not authorized
$friends = User::mine()->limit(50);          // Only authorized users
db()->update(User::mine()->eq('id', 123), ['bio' => 'New']);  // Authorization enforced

// Key insight: ::mine() is shorter than ::query(), so developers naturally use the secure method!

See src/Database/README.md for complete documentation.

Internationalization

Best Practice: Use t() and fmt() everywhere to make your app translatable from day one.

// Always use t() for user-facing text (even in English)
echo t("Hello, {name}!", ['name' => $user->name]);
echo t("You have {count, plural, =0{no messages} one{# message} other{# messages}}",
    ['count' => $messageCount]);

// Always use fmt() for numbers, dates, and currency
echo fmt()->currency($price, 'USD');     // Locale-aware: "$1,234.56" or "1 234,56 $"
echo fmt()->dateShort($order->date);     // "11/15/2025" or "15.11.2025"
echo fmt()->number($revenue);            // "1,234,567.89" or "1.234.567,89"

Per-Request Locale/Timezone

Set locale and timezone per request based on user preferences:

// bootstrap.php (autoloaded via composer.json)
use mini\Mini;
use mini\Phase;

Mini::$mini->phase->onEnteringState(Phase::Ready, function() {
    // Get user's preferred locale from session, cookie, or Accept-Language header
    $locale = $_SESSION['locale'] ?? $_COOKIE['locale'] ?? 'en_US';
    $timezone = $_SESSION['timezone'] ?? 'UTC';

    // Set for this request
    \Locale::setDefault($locale);
    date_default_timezone_set($timezone);
});

Translation files mirror your source code structure in _translations/. For example, strings in _routes/index.php go to _translations/de/_routes/index.php.json:

{
    "Hello, {name}!": "Hallo, {name}!",
    "You have {count, plural, =0{no messages} one{# message} other{# messages}}":
        "Sie haben {count, plural, =0{keine Nachrichten} one{# Nachricht} other{# Nachrichten}}"
}

See src/I18n/README.md for complete documentation, including the vendor/bin/mini translations tool for managing translation files.

Authentication

auth() is a facade over an AuthInterface your application implements (sessions, JWT, API keys — Mini does not prescribe a user model). Register your implementation in _config/mini/Auth/AuthInterface.php:

// Check authentication
if (auth()->isAuthenticated()) {
    $userId = auth()->getUserId();
}

// Require login (throws AuthenticationRequiredException → 401 if not authenticated)
auth()->requireLogin();

// Role- and permission-based access (throw AccessDeniedException → 403 on failure)
auth()->requireRole('admin');
auth()->requirePermission('posts.edit');

// Non-throwing checks
auth()->hasRole('admin');
auth()->hasPermission('posts.edit');
auth()->getClaim('email');

How users log in and out (form + session, JWT issuance, ...) is your AuthInterface implementation's concern — see src/Auth/README.md for complete examples.

Templates

Pure PHP templates with inheritance support:

// Render a template
echo render('user/profile', ['user' => $user]);

Templates support multi-level inheritance:

// _views/user/profile.php
<?php $this->extend('layouts/main.php'); ?>
<?php $this->block('title', 'User Profile'); ?>
<?php $this->block('content'); ?>
    <h1><?= htmlspecialchars($user->name) ?></h1>
    <p><?= t("Member since {date}", ['date' => fmt()->dateShort($user->created)]) ?></p>
<?php $this->end(); ?>

// _views/layouts/main.php
<!DOCTYPE html>
<html>
<head><title><?php $this->show('title', 'Untitled'); ?></title></head>
<body><?php $this->show('content'); ?></body>
</html>

See src/Template/README.md for complete documentation.

Lifecycle Hooks

Hook into application lifecycle via phase transitions:

use mini\Mini;
use mini\Phase;

// Before Ready phase (locale, timezone, per-request setup)
Mini::$mini->phase->onEnteringState(Phase::Ready, function() {
    \Locale::setDefault($_SESSION['locale'] ?? 'en_US');
});

// After Ready phase entered (bootstrap complete, services registered)
Mini::$mini->phase->onEnteredState(Phase::Ready, function() {
    log()->info('Application ready');
});

Configuration

Environment Variables (.env)

Create a .env file in your project root for environment-specific configuration:

# .env - Not committed to version control

# Database (MySQL/PostgreSQL) — SQLite is the zero-config default
DATABASE_URL="mysql://myapp_user:secret_password@localhost/myapp"

# Or an explicit SQLite path
# DATABASE_URL="sqlite:///path/to/database.sqlite3"

# Mini framework settings
MINI_LOCALE="en_US"
MINI_TIMEZONE="America/New_York"
DEBUG=true

# Application salt for security (generate with: openssl rand -hex 32)
MINI_SALT="your-64-character-random-hex-string-here"

# Optional: Custom paths
MINI_ROOT="/path/to/project"
MINI_DOC_ROOT="/path/to/project/html"

No loader package needed. Mini parses .env in the project root automatically during bootstrap (Symfony-compatible semantics: existing environment variables are never overwritten, so real environment always wins over the file). Values land in $_ENV, $_SERVER, and getenv().

Bootstrap File (bootstrap.php)

Create a bootstrap file for application initialization, autoloaded via composer.json:

{
    "autoload": {
        "files": ["bootstrap.php"]
    }
}
// bootstrap.php - Runs before every request
// (.env is already loaded by Mini's bootstrap — no loader code needed here)

// Register lifecycle hooks
use mini\Mini;
use mini\Phase;

// Set locale/timezone per request from user session
Mini::$mini->phase->onEnteringState(Phase::Ready, function() {
    // Get user's preferred locale/timezone ($_SESSION auto-starts on access)
    $locale = $_SESSION['locale'] ?? $_ENV['MINI_LOCALE'] ?? 'en_US';
    $timezone = $_SESSION['timezone'] ?? $_ENV['MINI_TIMEZONE'] ?? 'UTC';

    \Locale::setDefault($locale);
    date_default_timezone_set($timezone);
});

// Global error handler (optional)
set_error_handler(function($severity, $message, $file, $line) {
    throw new ErrorException($message, 0, $severity, $file, $line);
});

Database Configuration

Mini works out of the box with SQLite (_database.sqlite3 in project root). Configure via environment variables:

# MySQL
DATABASE_URL=mysql://user:password@localhost:3306/myapp

# PostgreSQL
DATABASE_URL=postgresql://user:password@localhost/myapp

# SQLite (explicit path)
DATABASE_URL=sqlite:///var/data/myapp.db

Use MINI_DATABASE_URL to override DATABASE_URL (useful when coexisting with other frameworks).

Don't forget to run composer dump-autoload after modifying composer.json!

APCu Polyfill

Mini provides zero-configuration APCu polyfills enabling you to use apcu_* functions even when the APCu extension isn't installed. This is particularly useful for:

  • L1 caching - Sub-millisecond cache operations faster than filesystem or network I/O
  • Shared memory - Data shared across requests/workers (where supported)
  • Framework internals - Mini uses APCu for hot-path optimizations (e.g., PathsRegistry file resolution)

How It Works

Mini automatically provides APCu functionality through the best available driver:

  1. Native APCu (when apcu extension is installed) - Uses real shared memory
  2. Swoole\Table (when Swoole extension is installed) - Coroutine-safe shared memory
  3. PDO SQLite (when pdo_sqlite extension available) - Persistent storage in /dev/shm (tmpfs)
  4. Array fallback - Process-scoped only (no cross-request persistence)

No configuration required - the polyfill loads automatically and selects the best driver.

Usage

Use APCu functions as if the extension were installed:

// Store value with 60-second TTL
apcu_store('user:123', $userData, 60);

// Fetch value
$user = apcu_fetch('user:123', $success);
if ($success) {
    echo "Cache hit!";
}

// Atomic entry (fetch-or-compute pattern)
$config = apcu_entry('app:config', function() {
    return loadHeavyConfiguration();
}, ttl: 300);

// Check existence
if (apcu_exists('session:abc123')) {
    echo "Session exists";
}

// Delete
apcu_delete('user:123');

// Clear all
apcu_clear_cache();

Driver Configuration

Swoole Table Driver:

# .env
MINI_APCU_SWOOLE_SIZE=4096          # Number of rows (default: 4096)
MINI_APCU_SWOOLE_VALUE_SIZE=4096    # Max value size in bytes (default: 4096)

SQLite Driver:

# .env
MINI_APCU_SQLITE_PATH=/dev/shm/my_custom_cache.sqlite  # Custom path (optional)

By default, SQLite uses /dev/shm/apcu_mini_{hash}.sqlite on Linux (tmpfs-backed, RAM speed with persistence) or sys_get_temp_dir() otherwise.

Performance Characteristics

Driver Speed Persistence Cross-Request Cross-Process
Native APCu Fastest RAM only
Swoole\Table Very Fast RAM only ✓ (workers)
SQLite (/dev/shm) Fast
Array Instant None

When Mini Uses APCu

Mini uses APCu internally for L1 caching in performance-critical paths:

  • PathsRegistry (src/Util/PathsRegistry.php) - Caches file resolution results (views, routes, translations, config) with 1-second TTL
  • Future: Translation file loading, metadata caching (opt-in)

Garbage Collection

APCu polyfill drivers implement probabilistic garbage collection (similar to PHP sessions):

  • 1% chance of GC on each apcu_store() or apcu_entry() call
  • Automatically removes expired entries
  • No manual cleanup required

Complete API

All standard APCu functions are polyfilled:

  • apcu_add() - Store if key doesn't exist
  • apcu_cache_info() - Get cache statistics
  • apcu_cas() - Compare-and-swap (atomic update)
  • apcu_clear_cache() - Clear all entries
  • apcu_dec() - Decrement numeric value
  • apcu_delete() - Delete one or more keys
  • apcu_enabled() - Check if APCu is available
  • apcu_entry() - Atomic fetch-or-compute
  • apcu_exists() - Check if key(s) exist
  • apcu_fetch() - Fetch value(s)
  • apcu_inc() - Increment numeric value
  • apcu_key_info() - Get key metadata
  • apcu_sma_info() - Get shared memory info
  • apcu_store() - Store value(s)

Installation for Production

For best performance in production, install the native APCu extension:

# Debian/Ubuntu
sudo apt-get install php-apcu

# Alpine Linux (Docker)
apk add php83-apcu

# PECL
pecl install apcu

The polyfill automatically detects and uses native APCu when available.

Directory Structure

Directories starting with _ are not web-accessible:

project/
├── .env               # Environment variables (not committed)
├── bootstrap.php      # Application initialization (autoloaded)
├── composer.json      # Dependencies and autoload configuration
├── _routes/           # Route handlers
├── _views/            # Templates
├── _config/           # Service configuration
├── _translations/     # Translation files
├── html/              # Document root (web-accessible)
│   ├── index.php      # Entry point
│   └── assets/        # CSS, JS, images
└── vendor/            # Composer dependencies

Development Server

vendor/bin/mini serve                    # http://localhost:8080
vendor/bin/mini serve --port 3000        # Custom port
vendor/bin/mini serve --host 0.0.0.0     # Bind to all interfaces

Documentation

Essential Guides

  • docs/WHY-MINI.md - Why choose Mini? Honest discussion of trade-offs vs. Laravel/Symfony
  • PATTERNS.md - Service overrides, middleware patterns, output buffering
  • REFERENCE.md - Complete API reference
  • CHANGE-LOG.md - Breaking changes (Mini is in active development)

Feature Documentation

Detailed documentation for each framework feature:

CLI Documentation Browser

vendor/bin/mini docs --help              # See available commands
vendor/bin/mini docs mini                # Browse mini namespace
vendor/bin/mini docs "mini\Mini"         # Class documentation

License

MIT License - see LICENSE file.