fubber / mini
Minimalist PHP micro-framework for simple web applications with enterprise-grade i18n, caching, and database abstraction
Installs: 4
Dependents: 0
Suggesters: 0
Security: 0
Stars: 0
Watchers: 0
Forks: 0
Open Issues: 0
pkg:composer/fubber/mini
Requires
- php: >=7.4
- psr/container: ^1.1 || ^2.0
- psr/log: ^3.0
- psr/simple-cache: ^1.0 || ^2.0 || ^3.0
- symfony/dotenv: ^7.3
- symfony/polyfill-intl-icu: ^1.25
- symfony/polyfill-intl-messageformatter: ^1.25
Suggests
- ext-intl: For full internationalization features with all languages and locales
Provides
- psr/container-implementation: 1.1|2.0
- psr/simple-cache-implementation: 1.0|2.0|3.0
README
A PHP framework that stays close to native PHP. Complete with routing, database, i18n, caching, and migrations—without wrapping everything in layers of abstraction.
Performance: ~0.2-0.5ms per request on PHP's built-in server
Why Mini?
We made a conscious decision to stay close to native PHP rather than building abstractions for the sake of abstractions. This means:
- Use
$_POSTand$_GETdirectly - No request object wrappers. PHP already handles request scope correctly. - Use
\Locale::setDefault()directly - No framework wrappers for what PHP does well. - Use
new \Collator()directly - Native PHP intl classes work fine. - Use PDO with light helpers - Direct SQL when you need it, query builders when convenient.
This makes Mini fundamentally different from Laravel, Slim, or Symfony:
- Runs much faster - No overhead from unnecessary abstractions
- O(1) constant time routing - File-based routing scales to tens of thousands of routes
- Easy to add features - Use Symfony packages or any PSR-compatible library
- Shallow - We don't wrap your code in dozens of functions and magic
If you don't like this approach, pick a different framework. We're not trying to be everything to everyone.
Quick Start
Installation
composer require fubber/mini
Project Structure
your-app/
├── composer.json
├── vendor/
├── _config/ # Config files (outside web root)
│ └── bootstrap.php # Optional app initialization
├── _routes/ # Route handlers (outside web root)
│ ├── index.php # Handles /
│ ├── users.php # Handles /users
│ └── api/
│ └── posts.php # Handles /api/posts
├── _errors/ # Error pages (outside web root)
│ ├── 404.php
│ ├── 401.php
│ └── 500.php
├── _views/ # Template files (outside web root)
│ ├── layout.php # Main layout
│ ├── users.php # User list template
│ └── admin/
│ └── dashboard.php # Admin dashboard template
├── _translations/ # Translation files (outside web root)
├── _migrations/ # Database migrations (outside web root)
├── _database.sqlite3 # Database (outside web root)
└── html/ # Document root (web-accessible)
├── index.php # Entry point
└── assets/ # CSS, JS, images
Security: Everything except html/ is outside the web root.
Entry Point
Create html/index.php:
<?php require_once __DIR__ . '/../vendor/autoload.php'; mini\router(); // Handles routing and bootstraps framework
Development Server
Using Mini CLI (recommended):
composer exec mini serve # Starts server on http://127.0.0.1:8080 # Custom host and port: composer exec mini serve --host 0.0.0.0 --port 3000
Using PHP directly:
# Run from project root
php -S 127.0.0.1:8080 -t ./html/
Visit: http://127.0.0.1:8080
Note: The dev server is for development only. Use Apache/Nginx in production.
Routing
File-Based Routing
URLs map directly to files in _routes/:
/ → _routes/index.php
/users → _routes/users.php
/users/ → _routes/users/index.php
/api/posts → _routes/api/posts.php
/api/posts/ → _routes/api/posts/index.php
Performance: Route lookup is O(1) constant time - only limited by filesystem scalability. Works efficiently with tens of thousands of routes.
Example: _routes/api/posts.php
<?php // No bootstrap() needed - router() already called it header('Content-Type: application/json'); $posts = db()->query('SELECT * FROM posts ORDER BY created_at DESC')->fetchAll(); echo json_encode($posts);
Pattern-Based Routing
For dynamic routes, create _config/routes.php:
<?php return [ "/users/{id:\d+}" => fn($id) => "_routes/users/detail.php?id={$id}", "/posts/{slug}" => function(string $slug) { $postId = cache()->get("post_slug:{$slug}") ?? db()->queryField('SELECT id FROM posts WHERE slug = ?', [$slug]); if (!$postId) { http_response_code(404); return "_errors/404.php"; } return "_routes/posts/detail.php?id={$postId}"; }, // Route different HTTP methods to different handlers "/api/users" => function() { return match($_SERVER['REQUEST_METHOD']) { 'GET' => "_routes/api/users/list.php", 'POST' => "_routes/api/users/create.php", default => throw new mini\Http\MethodNotAllowedException() }; } ];
Request Handling
Access request data directly with native PHP:
<?php // _routes/users/create.php $nonce = csrf('create-user'); if ($_SERVER['REQUEST_METHOD'] === 'POST') { // Verify CSRF token if (!$nonce->verify($_POST['__nonce__'])) { throw new mini\Http\BadRequestException('Invalid CSRF token'); } $username = $_POST['username'] ?? ''; $email = $_POST['email'] ?? ''; db()->exec( 'INSERT INTO users (username, email) VALUES (?, ?)', [$username, $email] ); redirect(url('users')); } // Show form echo render('users/create.php', [ 'title' => t('Create User'), 'nonce' => $nonce ]);
In _views/users/create.php:
<form method="POST"> <?= $nonce ?> <!-- Outputs hidden input field --> <label><?= t('Username') ?></label> <input name="username" required> <label><?= t('Email') ?></label> <input name="email" type="email" required> <button type="submit"><?= t('Create User') ?></button> </form>
Database
Basic Queries
// Get database instance (request-scoped) $db = db(); // Fetch all rows $users = $db->query('SELECT * FROM users WHERE active = 1')->fetchAll(); // Fetch one row $user = $db->queryOne('SELECT * FROM users WHERE id = ?', [$userId]); // Fetch single value $count = $db->queryField('SELECT COUNT(*) FROM users'); // Fetch column $ids = $db->queryColumn('SELECT id FROM users WHERE active = 1'); // Execute statement $db->exec('UPDATE users SET last_login = NOW() WHERE id = ?', [$userId]); // Get last insert ID $userId = $db->exec('INSERT INTO users (name) VALUES (?)', [$name]); $newId = $db->lastInsertId();
Transactions
$result = db()->transaction(function($db) use ($userId, $amount) { // Deduct from user account $db->exec('UPDATE accounts SET balance = balance - ? WHERE user_id = ?', [$amount, $userId]); // Create transaction record $db->exec('INSERT INTO transactions (user_id, amount, type) VALUES (?, ?, ?)', [$userId, $amount, 'withdrawal']); return true; });
Tables (Query Builder & Repository Pattern)
Mini provides a fluent query builder with repository pattern support in src/Tables/:
// Using the table() query builder table('users') ->eq('status', 'active') ->gte('created_at', $date) ->order('name') ->limit(10) ->all(); // Get one record $user = table('users')->eq('id', 123)->first(); // Count records $count = table('posts')->eq('published', true)->count(); // Pagination $page = table('users')->page(2, 20); // Page 2, 20 per page
Repository Pattern:
DatabaseRepository- Full CRUD with automatic SQL generationReadonlyRepositoryInterface- Read-only data accessCsvRepository- CSV file data sourcesScalarRepository- Single-value repositories- Type-safe hydration via
ObjectHydrationTrait
See src/Tables/README.md for advanced usage.
Migrations
Running Migrations
composer exec mini migrations
Creating Migrations
Create files in _migrations/ directory with sequential naming:
<?php // _migrations/001_create_users_table.php return function($db) { $db->exec("CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username VARCHAR(50) NOT NULL UNIQUE, email VARCHAR(100) NOT NULL UNIQUE, password_hash VARCHAR(255) NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP )"); echo "Created users table\n"; };
Seeding Data
<?php // _migrations/002_seed_initial_users.php return function($db) { $users = [ ['admin', 'admin@example.com', password_hash('admin', PASSWORD_DEFAULT)], ['user', 'user@example.com', password_hash('user', PASSWORD_DEFAULT)] ]; foreach ($users as [$username, $email, $hash]) { $db->exec( "INSERT INTO users (username, email, password_hash) VALUES (?, ?, ?)", [$username, $email, $hash] ); } echo "Seeded " . count($users) . " users\n"; };
Internationalization (i18n)
Translation Files
Structure:
_translations/
├── default/ # Auto-generated source strings
│ └── controller.php.json
├── nb/ # Norwegian
│ └── controller.php.json
└── es/ # Spanish
└── controller.php.json
Basic Translation
In your route handler:
// _routes/welcome.php $username = $_SESSION['username'] ?? 'Guest'; echo t("Hello, {name}!", ['name' => $username]);
Translation file _translations/nb/welcome.php.json:
{
"Hello, {name}!": "Hei, {name}!"
}
How it works:
t()creates aTranslatableobject with source text and variables- Framework looks up translation in
_translations/{language}/{source-file}.json - If found, uses translated text; otherwise falls back to source text
- Variables are interpolated into the final string
Auto-generating translation files:
# Scans codebase for t() calls and creates translation files composer exec mini translations add-missing # Creates: _translations/default/welcome.php.json with source strings # Creates: _translations/nb/welcome.php.json (empty, ready for translation)
ICU MessageFormat (Plurals, Ordinals)
// Plurals echo t("{count, plural, =0{no messages} =1{one message} other{# messages}}", ['count' => $messageCount]); // Ordinals echo t("You finished {place, selectordinal, one{#st} two{#nd} few{#rd} other{#th}}!", ['place' => 21]); // Output: "You finished 21st!" // Gender/Select echo t("{gender, select, male{He} female{She} other{They}} replied", ['gender' => $user->gender]);
Formatting Before Translation
// Format values in PHP before passing to t() echo t("Price: {amount}", [ 'amount' => fmt()->currency(19.99, 'USD') ]); echo t("Size: {size}", [ 'size' => fmt()->fileSize($bytes) ]);
Translation Management CLI
composer exec mini translations # Validate translations composer exec mini translations add-missing # Add missing strings composer exec mini translations add-language nb # Create Norwegian translations composer exec mini translations remove-orphans # Clean up unused translations
Complete Translation Workflow Example
1. Write your code with t() calls:
// _routes/products/list.php $products = db()->query('SELECT * FROM products')->fetchAll(); echo render('products/list.php', [ 'title' => t('Product Catalog'), 'products' => $products, 'empty_message' => t('No products found.') ]);
2. Auto-generate translation files:
composer exec mini translations add-missing
Creates:
_translations/default/products/list.php.json(source strings)_translations/nb/products/list.php.json(empty, ready for translation)
3. Translate strings:
Edit _translations/nb/products/list.php.json:
{
"Product Catalog": "Produktkatalog",
"No products found.": "Ingen produkter funnet."
}
4. Add more languages:
composer exec mini translations add-language es
Creates: _translations/es/products/list.php.json
Edit and translate:
{
"Product Catalog": "Catálogo de Productos",
"No products found.": "No se encontraron productos."
}
5. Users see content in their language:
// _config/bootstrap.php configured with LOK cookie // User with LOK=nb_NO sees: "Produktkatalog" // User with LOK=es_ES sees: "Catálogo de Productos" // User with LOK=en_US sees: "Product Catalog" (original)
Locale & Formatting
Configuring Locale from Cookies
Here's a practical example using cookies to store user preferences:
// _config/bootstrap.php // Read user's locale and timezone from cookies $locale = $_COOKIE['LOK'] ?? 'en_US'; // e.g., 'nb_NO' $timezone = $_COOKIE['TZ'] ?? 'UTC'; // e.g., 'Europe/Oslo' // Set locale for formatting (affects Fmt methods, number/date formatters) \Locale::setDefault($locale); // Set timezone for date/time operations date_default_timezone_set($timezone); // Extract language code from locale for translations (nb from nb_NO) $language = \Locale::getPrimaryLanguage($locale); // 'nb' from 'nb_NO' translator()->trySetLanguageCode($language); // Now all formatting and translations use the user's preferences: // - Fmt::currency() formats according to $locale // - Fmt::dateShort() formats according to $locale // - t() translates to $language // - new DateTime() uses $timezone
Setting the cookies (from a user settings page):
// _routes/settings/save.php $locale = $_POST['locale'] ?? 'en_US'; // e.g., 'nb_NO', 'en_US', 'de_DE' $timezone = $_POST['timezone'] ?? 'UTC'; // e.g., 'Europe/Oslo', 'America/New_York' // Validate timezone if (!in_array($timezone, \DateTimeZone::listIdentifiers())) { $timezone = 'UTC'; } // Set cookies (1 year expiration) setcookie('LOK', $locale, time() + 31536000, '/'); setcookie('TZ', $timezone, time() + 31536000, '/'); // Redirect to apply new settings redirect(url('settings'));
Priority order for detecting locale:
// _config/bootstrap.php // Priority: URL param > Cookie > Browser > Default $locale = $_GET['lang'] // ?lang=nb_NO (highest priority) ?? $_COOKIE['LOK'] // LOK cookie ?? \Locale::acceptFromHttp($_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? '') // Browser ?? 'en_US'; // Default fallback \Locale::setDefault($locale); $language = \Locale::getPrimaryLanguage($locale); translator()->trySetLanguageCode($language);
Complete Settings Page Example:
// _routes/settings.php $currentLocale = $_COOKIE['LOK'] ?? 'en_US'; $currentTimezone = $_COOKIE['TZ'] ?? 'UTC'; echo render('settings.php', [ 'title' => t('Settings'), 'locale' => $currentLocale, 'timezone' => $currentTimezone, 'locales' => [ 'en_US' => t('English (United States)'), 'en_GB' => t('English (United Kingdom)'), 'nb_NO' => t('Norwegian (Norway)'), 'de_DE' => t('German (Germany)'), 'es_ES' => t('Spanish (Spain)'), 'fr_FR' => t('French (France)') ], 'timezones' => [ 'UTC' => 'UTC', 'Europe/Oslo' => 'Europe/Oslo', 'Europe/London' => 'Europe/London', 'America/New_York' => 'America/New_York', 'America/Los_Angeles' => 'America/Los_Angeles', 'Asia/Tokyo' => 'Asia/Tokyo' ] ]);
// _views/settings.php <?php $content = ob_start(); ?> <h1><?= h($title) ?></h1> <form method="POST" action="<?= url('settings/save') ?>"> <label> <?= t('Language & Region') ?> <select name="locale"> <?php foreach ($locales as $code => $name): ?> <option value="<?= h($code) ?>" <?= $code === $locale ? 'selected' : '' ?>> <?= h($name) ?> </option> <?php endforeach; ?> </select> </label> <label> <?= t('Timezone') ?> <select name="timezone"> <?php foreach ($timezones as $tz => $label): ?> <option value="<?= h($tz) ?>" <?= $tz === $timezone ? 'selected' : '' ?>> <?= h($label) ?> </option> <?php endforeach; ?> </select> </label> <button type="submit"><?= t('Save Settings') ?></button> </form> <?php $content = ob_get_clean(); echo render('layout.php', compact('title', 'content')); ?>
Formatting
use mini\I18n\Fmt; // All methods use \Locale::getDefault() // Currency echo Fmt::currency(19.99, 'USD'); // $19.99 (en_US) or USD 19,99 (nb_NO) // Dates echo Fmt::dateShort(new DateTime()); // 10/28/2025 echo Fmt::dateLong(new DateTime()); // October 28, 2025 echo Fmt::timeShort(new DateTime()); // 2:30 PM echo Fmt::dateTimeShort(new DateTime()); // 10/28/2025, 2:30 PM // Numbers echo Fmt::number(1234.56, 2); // 1,234.56 (en_US) or 1 234,56 (nb_NO) echo Fmt::percent(0.85, 1); // 85.0% (en_US) or 85,0 % (nb_NO) // File sizes echo Fmt::fileSize(1048576); // 1.0 MB
Caching
Basic Caching
$cache = cache(); // Root cache // Set with TTL (in seconds) $cache->set('user:123', $userData, 3600); // Get value $data = $cache->get('user:123'); // Get with default $data = $cache->get('user:123', ['default' => 'value']); // Delete $cache->delete('user:123'); // Has if ($cache->has('user:123')) { // ... }
Namespaced Caching
$userCache = cache('users'); $postCache = cache('posts'); $userCache->set('user:123', $userData, 3600); $postCache->set('post:456', $postData, 3600); // Isolated namespaces $userCache->get('user:123'); // Returns userData $postCache->get('user:123'); // Returns null (different namespace)
Logging
Basic Logging
// Get logger instance (PSR-3 compatible) and log directly log()->debug('Debug message'); log()->info('Info message'); log()->notice('Notice message'); log()->warning('Warning message'); log()->error('Error message'); log()->critical('Critical message'); log()->alert('Alert message'); log()->emergency('Emergency message');
Context & Interpolation
// With context log()->info('User {user} logged in from {ip}', [ 'user' => $username, 'ip' => $_SERVER['REMOTE_ADDR'] ]); // With exception try { // ... code } catch (\Exception $e) { log()->error('Operation failed: {message}', [ 'message' => $e->getMessage(), 'exception' => $e ]); }
Templates
Templates are stored in _views/ directory and resolved using the path registry.
Simple Templates
// _routes/users.php $users = db()->query('SELECT * FROM users ORDER BY name')->fetchAll(); echo render('users.php', [ 'title' => t('User List'), 'users' => $users ]);
<?php // _views/users.php ?> <h1><?= h($title) ?></h1> <ul> <?php foreach ($users as $user): ?> <li> <a href="<?= url("users/{$user['id']}") ?>"> <?= h($user['name']) ?> </a> </li> <?php endforeach; ?> </ul>
Template Inheritance
Mini supports Twig-like template inheritance using pure PHP. Child templates extend parent layouts and define named blocks.
Parent Layout (_views/layout.php):
<!doctype html> <html lang="en"> <head> <meta charset="utf-8"> <title><?php $show('title', 'My Site'); ?></title> </head> <body> <header> <h1><?php $show('header', 'Welcome'); ?></h1> </header> <main> <?php $show('content'); ?> </main> <footer> <?php $show('footer', '© ' . date('Y')); ?> </footer> </body> </html>
Child Template (_views/users.php):
<?php // Extend parent layout $extend('layout.php'); // Define title block (inline syntax) $block('title', 'User List'); // Define content block (buffered syntax) $block('content'); ?> <h2>All Users</h2> <ul> <?php foreach ($users as $user): ?> <li><?= h($user['name']) ?></li> <?php endforeach; ?> </ul> <?php $end();
Template Helpers:
$extend('file.php')- Extend parent layout$block('name', ?string $value = null)- Define block (dual-use: inline or buffered)$end()- End buffered block capture$show('name', 'default')- Output block with optional default
Dual-Use $block() Syntax:
// Inline: set block to value directly <?php $block('title', 'My Page'); ?> // Buffered: capture complex content <?php $block('content'); ?> <p>Complex HTML here</p> <?php $end(); ?> // Including sub-templates (partials) <?= mini\render('_user-card.php', ['user' => $currentUser]) ?>
Benefits:
- Pure PHP, opcache-friendly
- No compilation step needed
- Blocks with defaults
- Reusable partials
- Clean separation of layout and content
Layout File
<?php // Example: More complex layout ?> <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title><?= h($title) ?></title> </head> <body> <?= $content ?> </body> </html>
Subdirectories: You can organize templates: render('admin/dashboard.php')
Testing
Tests go in tests/ directory. Mini uses a simple test helper pattern:
<?php // tests/MyFeature.php require_once __DIR__ . '/../vendor/autoload.php'; function test(string $description, callable $test): void { try { $test(); echo "✓ {$description}\n"; } catch (\Exception $e) { echo "✗ {$description}\n"; echo " Error: {$e->getMessage()}\n"; } } function assertEqual($expected, $actual, string $message = ''): void { if ($expected !== $actual) { throw new \Exception($message ?: "Expected: " . var_export($expected, true) . ", Got: " . var_export($actual, true)); } } // Tests test('Basic functionality works', function() { $result = 2 + 2; assertEqual(4, $result); }); test('Database query returns results', function() { mini\bootstrap(); $users = db()->query('SELECT * FROM users')->fetchAll(); assertEqual(true, is_array($users)); });
Run tests:
php tests/MyFeature.php
Authentication
Mini provides auth helpers, but you implement the authentication logic:
Setting Up Auth
// _config/bootstrap.php use mini\Auth\AuthInterface; class MyAuth implements AuthInterface { public function isAuthenticated(): bool { session_start(); return isset($_SESSION['user_id']); } public function getUserId(): mixed { return $_SESSION['user_id'] ?? null; } public function hasRole(string $role): bool { if (!$this->isAuthenticated()) { return false; } $userRole = db()->queryField( 'SELECT role FROM users WHERE id = ?', [$this->getUserId()] ); return $userRole === $role; } } // Register auth implementation setupAuth(fn() => new MyAuth());
Using Auth
// _routes/admin/dashboard.php require_login(); // Throws 401 if not logged in require_role('admin'); // Throws 403 if not admin echo render('admin/dashboard.php', [ 'title' => t('Admin Dashboard') ]);
Core Functions Reference
// Translation t(string $text, array $vars = []): Translatable // HTML escaping h(string $str): string // Template rendering render(string $template, array $vars = []): string // URL generation url(string $path = '', array $query = []): string // Redirects redirect(string $url, int $statusCode = 302): void // Current URL current_url(): string // Flash messages flash_set(string $type, string $message): void flash_get(): array // Session session(): bool // Database db(): DatabaseInterface // Cache cache(?string $namespace = null): CacheInterface // Logger log(): LoggerInterface // Tables (query builder) table(string $name): Repository // Translator (language management) translator(): Translator // Formatting fmt(): Fmt // Auth auth(): ?AuthInterface is_logged_in(): bool require_login(): void require_role(string $role): void // Framework bootstrap(): void router(): void
Philosophy: Why Native PHP?
Other frameworks wrap PHP in layers of abstraction. We don't.
$_POST and $_GET Are Not Dangerous
// Mini - direct and clear $username = $_POST['username'] ?? ''; $email = $_POST['email'] ?? ''; // Framework wrappers add overhead for no real benefit $username = $request->input('username', ''); // Laravel $email = $request->getParsedBody()['email'] ?? ''; // PSR-7
Our view: $_POST and $_GET are request-scoped. PHP manages them correctly. Wrapping them in objects adds indirection without solving any actual problem.
Locale Management Is Built-In
// Mini - use PHP's native locale handling \Locale::setDefault('nb_NO'); $formatter = new \NumberFormatter(\Locale::getDefault(), \NumberFormatter::CURRENCY); // We don't wrap what PHP does well
Direct SQL When You Need It
// Mini - direct SQL is fine $users = db()->query('SELECT * FROM users WHERE active = 1')->fetchAll(); // Query builder when convenient $users = table('users')->eq('active', 1)->all(); // We give you both - use what fits
Performance
File-based routing and lazy initialization mean Mini stays fast as your app grows:
- O(1) route lookup - Constant time routing, even with tens of thousands of routes
- No route compilation - Routes are discovered on-demand via filesystem
- No container compilation - Services initialize when used
- No middleware overhead - Direct execution path
- Filesystem scalability only - Performance limited by your filesystem, not the framework
Extending Mini
Use Any PSR Package
composer require symfony/mailer composer require league/flysystem composer require respect/validation
Mini is PSR-compatible, so Symfony, League, and other packages work directly.
Override Framework Services
See PATTERNS.md for detailed examples of overriding framework defaults (logger, cache, database, etc.).
When to Use Mini
Mini is ideal for:
- Long-term maintainability over rapid team scaling
- API backends and internal services
- Systems expected to run for decades
- Small expert teams who value architectural control
- Performance-sensitive applications
Choose Laravel/Symfony if:
- You need batteries-included features (queues, events, ORM)
- Large rotating teams need strong conventions
- Rapid prototyping with extensive ecosystem matters
- You prefer convention over explicit control
Philosophy: Mini isn't anti-framework — it's pro-clarity. It assumes you trust yourself more than your dependencies.
📖 See docs/WHEN-TO-USE-MINI.md for a detailed comparison and decision guide.
License
MIT - Build whatever you want, wherever you want.