alexius-byte / rider-php
Mini PHP Framework
Requires
- php: ^8.1
- ext-curl: *
- ext-fileinfo: *
- ext-iconv: *
- ext-mbstring: *
- ext-openssl: *
- ext-pdo: *
README
PHP 8.1+ mini framework. Zero external dependencies.
Dotenv
Dotenv::load(__DIR__ . '/.env'); $_ENV['APP_ENV']; // string $_ENV['APP_DEBUG']; // bool $_ENV['DB_PORT']; // int
Existing $_ENV keys are never overwritten.
Router
Apache — .htaccess
Options -Indexes RewriteEngine On RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-d RewriteRule ^ index.php [L,QSA]
Nginx
server { root /var/www/public; index index.php; location / { try_files $uri $uri/ /index.php?$query_string; } location ~ \.php$ { fastcgi_pass unix:/var/run/php/php8.1-fpm.sock; fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; include fastcgi_params; } }
Basic routes
$router = new Router(); $router->get('/users', [UserController::class, 'index']); $router->post('/users', [UserController::class, 'store']); $router->put('/users/{id}', [UserController::class, 'update']); $router->delete('/users/{id}', [UserController::class, 'destroy']); // Closure $router->get('/', function (array $params) { echo 'Hello'; }); $router->dispatch(); if ($error = $router->hasError()) { http_response_code($error); // 404 or 405 }
Route parameters arrive as $params['id'] inside the handler.
Groups
$router->group('/admin'); $router->get('/dashboard', [AdminController::class, 'dashboard']); // → /admin/dashboard $router->get('/users', [AdminController::class, 'users']); // → /admin/users $router->resetGroup(); $router->get('/', [HomeController::class, 'index']); // → /
Middleware
class AuthMiddleware extends AbstractMiddleware { public function run(): bool { if (!isset($_SESSION['user'])) { return $this->json(['error' => 'Unauthorized'], 401); } return true; } }
$router->middleware(AuthMiddleware::class, AnotherMiddleware::class); $router->get('/profile', [UserController::class, 'profile']); $router->resetMiddleware();
Returning false from run() stops the chain — the route handler is not executed.
AbstractMiddleware provides json(array, status) and redirect(url, status) helpers.
Route classes
class SiteRoutes extends AbstractRoute { public function run(): void { $this->get('/', [HomeController::class, 'index']); $this->post('/contact', [HomeController::class, 'contact']); } } $router->addRouters(new SiteRoutes());
Groups and middleware are available inside AbstractRoute via $this->group(), $this->middleware(), $this->resetGroup(), $this->resetMiddleware().
Tracker
Auto-registers all public methods of a controller as routes.
$router->tracker(HttpMethod::GET, 'posts', PostController::class); // GET /posts/index → PostController::index() // GET /posts/show → PostController::show() // GET /posts/create → PostController::create()
Magic methods and inherited methods are ignored.
ORM
Model setup
class Product extends Model { protected string $table = 'products'; protected string $primaryKey = 'id'; protected bool $timestamps = true; protected array $fillable = ['code', 'name', 'price']; }
timestamps adds created_at / updated_at automatically on create() and update().
fillable is a whitelist — columns outside it are discarded on create() and update().
Read
$db = new Product(); $db->findById(1); $db->findAll(); $db->findBy('name', 'Notebook'); $db->first('email', 'a@a.com'); $db->random(); $db->count();
Sort
In-memory sort by a key extracted from each item. The callback receives one item and returns any comparable value — lower values come first.
$users = $db->sortBy($users, fn($u) => $u->name); $users = $db->sortBy($users, fn($u) => match($u->status) { 'active' => 0, 'pending' => 1, default => 2, });
Each key is computed once (O(n)), not once per comparison.
Write
$db->create(['code' => 'ABC', 'name' => 'Notebook', 'price' => 3500]); $product = $db->findById(1); $product->name = 'Updated'; $db->update($product); $db->delete(1); $db->truncate(); $db->deleteOlderThan(60, 'created_at'); // returns int (rows deleted) $db->upsert(['id' => 1, 'name' => 'Notebook', 'price' => 3500]); // insert or update
upsert() uses INSERT ... ON DUPLICATE KEY UPDATE. The primary key must be present in the array. On conflict, all fields are updated except the primary key and created_at. Requires a PRIMARY KEY or UNIQUE constraint on the target column.
Raw
$db->query('SELECT * FROM products WHERE price > ?', 100); $db->query('SELECT * FROM products WHERE active = ? AND role = ?', [1, 'admin']); $db->execute('UPDATE products SET active = 0 WHERE id = ?', [5]);
Transaction
$db->transaction(function (Product $db) { $db->create(['code' => 'A', 'name' => 'A', 'price' => 10]); $db->delete(5); });
Rolls back automatically on any exception.
Export
$export = new ExportDatabase(__DIR__ . '/backup.sql'); $export->setExclude(['logs', 'sessions']); $export->export();
Truncate multiple tables
$truncate = new TruncateTables(); $truncate->truncateAll(['users', 'migrations']); $truncate->addTable('logs'); $truncate->truncate();
Migrations
php migrate.php # run pending migrations php migrate.php rollback # undo last batch php migrate.php status # list ran / pending php migrate.php make create_users_table # generate migration file php migrate.php make:seeder UserSeeder # generate seeder file php migrate.php seed # run all seeders php migrate.php seed UserSeeder # run specific seeder
Migration
class CreateUsersTable extends AbstractMigration { public function up(): void { $this->execute(" CREATE TABLE users ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100) NOT NULL, email VARCHAR(150) NOT NULL UNIQUE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) "); } public function down(): void { $this->execute("DROP TABLE IF EXISTS users"); } }
Migration files live in database/migrations/ and are named with a timestamp prefix (2025_01_01_120000_create_users_table.php) for ordering. The migrations table is created automatically on first run.
Each php migrate.php call groups all pending migrations into a batch. rollback undoes the entire last batch at once.
Seeder
class UserSeeder extends AbstractSeeder { public function run(): void { $this->insert('users', [ ['name' => 'Admin', 'email' => 'admin@site.com'], ['name' => 'Editor', 'email' => 'editor@site.com'], ]); } }
Seeder files live in database/seeders/. insert() uses prepared statements — all rows in a single query.
Upload
$uploader = new UploadFiles('/var/app/storage/uploads'); $path = $uploader->upload($_FILES['photo'], AllowedType::Image);
Custom size limit (default 5 MB):
$uploader = new UploadFiles('/var/app/storage/uploads', 10); // 10 MB
Allowed types
| Case | Accepted MIMEs | Extensions |
|---|---|---|
AllowedType::Image |
image/jpeg, image/png, image/gif, image/webp | jpg, png, gif, webp |
AllowedType::Zip |
application/zip, application/x-zip-compressed | zip |
AllowedType::Pdf |
application/pdf | |
AllowedType::Video |
video/mp4, video/mpeg, video/quicktime, video/x-msvideo | mp4, mpeg, mov, avi |
AllowedType::Document |
application/msword, application/vnd.openxmlformats-officedocument.wordprocessingml.document | doc, docx |
MIME is detected via finfo — $_FILES['type'] is never trusted. The stored filename is a random 32-char hex string; the original name is discarded. The storage directory must be outside public/.
CORS
// Allow all origins Cors::allow('*'); // Allow specific origins only Cors::allow(['https://app.example.com', 'https://example.com']); // With credentials (cookies / Authorization header) Cors::allow( origins: ['https://app.example.com'], credentials: true ); // Custom methods, headers, and cache duration Cors::allow( origins: '*', methods: ['GET', 'POST'], headers: ['Content-Type', 'Authorization'], maxAge: 3600 );
Call before any output or routing logic — OPTIONS preflight requests are answered with 204 and execution stops immediately.
credentials: true is silently ignored when origins is '*' — browsers block that combination regardless.
Parameters
| Parameter | Type | Default |
|---|---|---|
origins |
string|array |
'*' |
methods |
array |
['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'] |
headers |
array |
['Content-Type', 'Authorization', 'X-Requested-With'] |
credentials |
bool |
false |
maxAge |
int |
86400 |
When origins is an array, the request Origin is matched against the list. If no match is found, no CORS headers are emitted. A Vary: Origin header is added automatically for specific-origin responses so proxies cache them correctly.
SSL
Symmetric encryption (AES-256-GCM)
$encrypted = SSL::encode('sensitive data', $key); $plain = SSL::decode($encrypted, $key);
$key is hashed with SHA-256 internally — any string length is accepted. Output is base64-encoded and carries the IV and authentication tag.
decode() throws SslException if the data is tampered or the key is wrong.
RSA key pair generation
SSL::generateKeyPair('/var/app/storage/keys', 'public', 'secret'); // → /var/app/storage/keys/public.pem // → /var/app/storage/keys/secret.pem (chmod 0600)
SSL::generateKeyPair('/var/app/storage/keys', 'public', 'secret', 4096); // custom bits
The private key is saved with 0600 permissions. The directory must exist before calling.
Cache
Drivers
$cache = new ArrayCache(); // in-memory, request lifetime $cache = new FileCache('/var/app/storage/cache'); // persistent on disk
Both implement CacheInterface (PSR-16 compatible) and are interchangeable.
Basic operations
$cache->set('key', $value); // no expiry $cache->set('key', $value, 300); // expires in 300 seconds $cache->set('key', $value, new DateInterval('PT5M')); // DateInterval $cache->get('key'); // returns null if missing or expired $cache->get('key', 'default'); // custom default $cache->has('key'); // bool — also evicts expired entries $cache->delete('key'); $cache->clear();
Multiple keys
$cache->setMultiple(['a' => 1, 'b' => 2], 60); foreach ($cache->getMultiple(['a', 'b'], 0) as $key => $value) { // $key => $value } $cache->deleteMultiple(['a', 'b']);
getMultiple and setMultiple return/accept generators — no intermediate array allocation.
TTL
| Value | Behavior |
|---|---|
null |
No expiry |
int |
Seconds from now |
DateInterval |
Resolved via DateTime::add() |
FileCache stores entries as SHA-256-named files; expired entries are evicted lazily on read.
Validation
$v = Validator::make($_POST) ->field('name')->required()->string()->max(100) ->field('email')->required()->email() ->field('age')->required()->int()->min(18)->max(120) ->field('website')->nullable()->url() ->field('role')->required()->in(['admin', 'editor', 'user']) ->field('slug')->required()->regex('/^[a-z0-9-]+$/'); if (!$v->passes()) { $errors = $v->errors(); // ['field' => 'message', ...] } // Or throw on failure $v->validate(); // throws ValidationException — message never carries user data
Custom messages
$v = Validator::make($_POST) ->field('email') ->required()->message('Email is required') ->email()->message('Enter a valid email') ->field('age') ->required()->message('Age is required') ->int()->message('Age must be a number') ->min(18)->message('You must be at least 18 years old');
message() only applies to the rule immediately before it. If that rule passed, message() is a no-op.
Rules
| Rule | Accepts | Behavior |
|---|---|---|
required() |
— | Fails if null or '' |
string() |
— | Fails if not a string |
int() |
— | Fails if not a valid integer representation |
float() |
— | Fails if not a valid float representation |
email() |
— | Validates via filter_var |
url() |
— | Validates via filter_var |
min(int|float) |
limit | Numeric value ≥ limit · String length ≥ limit (mb-safe) |
max(int|float) |
limit | Numeric value ≤ limit · String length ≤ limit (mb-safe) |
in(array) |
options | Strict type comparison |
regex(string) |
pattern | preg_match — pattern must include delimiters |
nullable() |
— | Skips all other rules if value is null or '' |
cpf() |
— | Validates Brazilian CPF — accepts 000.000.000-00 or 00000000000 |
The first error per field wins — subsequent failing rules on the same field are silently ignored.
Schema
Declarative shorthand validator. All fields are required by default. Returns only the declared fields. Throws SchemaException on failure.
$data = Schema::object($_POST, [ 'name' => 'string|min:2|max:100', 'email' => 'email|max:30', 'age' => 'int|min:18|max:120', 'website' => 'optional|url', 'role' => 'in:admin,editor,user', 'slug' => 'regex:/^[a-z0-9-]+$/', 'document' => 'cpf', ]); $data['name']; $data['email'];
try { $data = Schema::object($_POST, ['email' => 'email']); } catch (SchemaException $e) { $e->errors(); // ['email' => 'The email field must be a valid email address'] }
Rules are pipe-separated (|); parameters use a colon (min:6, in:a,b,c).
The first failing rule per field wins.
Fields not declared in the schema are discarded from the return value.
Schema rules
| Rule | Parameter | Behavior |
|---|---|---|
optional |
— | Field is not required — skips all other rules if value is null or '' |
string |
— | Fails if not a string |
int |
— | Fails if not a valid integer representation |
float |
— | Fails if not a valid float representation |
email |
— | Validates via filter_var |
url |
— | Validates via filter_var |
min |
limit | Numeric value ≥ limit · String length ≥ limit (mb-safe) |
max |
limit | Numeric value ≤ limit · String length ≤ limit (mb-safe) |
in |
values | Comma-separated list — strict string comparison |
regex |
pattern | preg_match — pattern must include delimiters |
cpf |
— | Validates Brazilian CPF — accepts 000.000.000-00 or 00000000000 |