PHP Scripting Framework
A lightweight, modern PHP 8 micro-framework with routing, ORM, fluent Schema builder, migrations, template engine, DI container, and an artisan-like CLI — zero bloat, full control.
Overview
This framework is built to give you the ergonomics of a full-stack framework (routing, ORM, CLI, validation, template inheritance) without the weight. Every component is replaceable and the codebase stays small enough to read in an afternoon.
| Component | Class / Path | What it does |
|---|---|---|
| Application | Src\Application | Bootstraps the container, providers, and routes |
| Router | Src\Router | PHP-attribute-based routing with dynamic parameters |
| Auth | Src\Auth | Session-backed multi-guard authentication for users, admins, staff, and custom guards |
| Database | Src\Database | Fluent query builder, singleton PDO connection |
| Schema | Src\Database\Schema | DDL facade — create / alter / drop tables fluently |
| Blueprint | Src\Database\Blueprint | Column builder used inside Schema callbacks |
| Model | Src\Models\Model | Active-record-style base model |
| Controller | Src\Controllers\Controller | Base controller with helpers for render, redirect, JSON |
| Console Kernel | Src\Console\Kernel | Artisan-like CLI command registry and dispatcher |
| Session | Src\Session | Flash messages, old form values |
| Cache | Src\Cache | Simple in-process file cache |
| Container | Pimple | PSR-11 dependency injection |
| Templates | Plates 3 | Native-PHP template engine with layouts & sections |
| Unpoly | Src\Unpoly | Progressive enhancement layer for SPA-like navigation, forms, overlays, redirects, and validation |
Installation
Requirements
| Requirement | Version |
|---|---|
| PHP | 8.1 or higher |
| Composer | 2.x |
| MySQL / MariaDB | 5.7 / 10.4+ |
| PDO extension | enabled |
# 1. Clone and install
git clone https://github.com/yourname/scripting.git
cd scripting && composer install
# 2. Create database
mysql -u root -p -e "CREATE DATABASE scripting CHARACTER SET utf8mb4"
# 3. Edit config/db.php, then run migrations
php bin/artisan migrate
Project Structure
├── bin/
│ └── artisan # CLI entry point
├── config/
│ ├── app.php # base_url, timezone, view_folder, app_mode
│ └── db.php # Database credentials
├── database/
│ └── migrations/ # Timestamped migration files
├── docs/
│ └── index.html # This file
├── htmls/views/ # Plates template files
│ ├── layouts/ # Layout templates
│ ├── auth/ # Login / register views
│ └── errors/ # 404 / 500 error pages
├── src/
│ ├── Application.php # Bootstrap
│ ├── Database.php # Query builder / PDO singleton
│ ├── Router.php # HTTP router
│ ├── Request.php / Response.php
│ ├── Session.php / Cache.php
│ ├── Attributes/ # Route, Auth, Guest PHP 8 attributes
│ ├── Console/ # Artisan CLI framework
│ │ ├── Command.php # Abstract base command
│ │ ├── Kernel.php # Command registry
│ │ └── Commands/ # Built-in commands
│ ├── Controllers/ # HTTP controllers
│ ├── Database/ # Schema builder layer
│ │ ├── Schema.php # DDL facade (create/table/drop)
│ │ ├── Blueprint.php # Column builder for migrations
│ │ ├── ColumnDefinition.php # Fluent column modifier chain
│ │ └── ForeignKeyDefinition.php
│ ├── Exceptions/ # AppException + ErrorHandler
│ ├── Helpers/helpers.php # Global helper functions
│ ├── Middleware/ # HTTP middleware
│ ├── Models/ # Eloquent-style models
│ ├── Providers/ # Service providers
│ └── Util/ # Timing, Validation
└── README.md
Configuration
Database — config/db.php
return [
'schema' => 'mysql',
'host' => 'localhost',
'dbname' => 'scripting',
'username' => 'admin',
'password' => 'secret',
];
App — config/app.php
return [
'app_name' => 'My App',
'base_url' => '/scripting/public',
'timezone' => 'Asia/Dhaka',
'app_mode' => 'development', // or 'production'
'view_folder' => __DIR__ . '/../htmls/views',
];
config/ is auto-loaded — access it via app()->getConfig('filename').
Routing
Routes are declared on controller methods using PHP 8 attributes — no separate route file needed.
use Src\Attributes\Route;
class PostController extends Controller
{
#[Route('GET', '/posts')]
public function index() { ... }
#[Route('GET', '/posts/{id}', 'post.show')]
public function show($id) { ... }
#[Route('POST', '/posts', 'post.store')]
public function store() { ... }
}
Named Routes
$url = router()->route('post.show', ['id' => 42]);
// → /scripting/public/posts/42
POST route automatically runs CsrfMiddleware. Include <input type="hidden" name="csrf_token" value="<?= generateCsrfToken() ?>"> in all forms.
Controllers
Controllers live in src/Controllers/, extend Controller, and are auto-discovered on boot.
namespace Src\Controllers;
class ArticleController extends Controller
{
#[Route('GET', '/articles')]
public function index()
{
$articles = $this->db()->from('articles')->latest()->paginate(10);
$this->render('articles/index', compact('articles'));
}
}
Base Controller Methods
$this->render(view, data)Render a Plates template$this->json(data, code=200)Output JSON and exit$this->db(): DatabaseGet the Database singleton$this->post(key, rules?)Read POST field with optional validation$this->get(key)Read query string value$this->validation(): arrayReturns validation errorsAccess Control
#[Auth] // default "user" guard, redirect to /login if not authenticated
#[Guest] // default "user" guard, redirect to / if already logged in
#[Route('GET', '/dashboard')]
public function dashboard() { ... }
For admin, staff, vendor, or other areas, use a named guard: #[Auth('admin', '/admin/login')].
Scaffold
php bin/artisan make:controller ArticleController
php bin/artisan make:controller ArticleController --resource
Authentication Guards
Authentication is guard-aware. Each guard stores its own session ID, so a client app can keep user, admin, staff, or any other login area separate.
user. Existing code using #[Auth], #[Guest], auth(), and user() continues to use that guard.
Guard API
// Default "user" guard
auth()->login($user);
$user = user(); // same as auth('user')->user()
auth()->logout();
// Named "admin" guard
auth('admin')->login($admin);
$admin = user('admin'); // same as auth('admin')->user()
auth('admin')->check();
auth('admin')->logout();
\Src\Auth::logoutAll(); // logout every active guard
| Method | Description |
|---|---|
auth($guard = 'user') | Return an auth guard instance |
login($account) | Store the account ID in session and cache the full account object |
user() | Return the cached account for the guard, or null |
id() | Return the authenticated account ID for the guard |
check() | True when the guard is authenticated |
guest() | True when the guard is not authenticated |
logout() | Clear one guard session and cache entry |
\Src\Auth::logoutAll() | Clear all active guard sessions |
Protected Routes
#[Auth('admin', '/admin/login')]
#[Route('GET', '/admin/dashboard')]
public function dashboard()
{
$admin = user('admin');
}
#[Guest('admin', '/admin/dashboard')]
#[Route('GET', '/admin/login')]
public function adminLogin() { ... }
Example Admin Login
#[Guest('admin', '/admin/dashboard')]
#[Route('POST', '/admin/login')]
public function postAdminLogin()
{
$email = $this->post('email');
$admin = $this->db()->from('admins')->where('email', '=', $email)->first();
if (!$admin) {
flash('error', 'Invalid credentials');
redirect('/admin/login');
}
auth('admin')->login($admin);
redirect('/admin/dashboard');
}
Middleware
php bin/artisan make:middleware RateLimitMiddleware
class RateLimitMiddleware
{
public function handle(): void
{
// inspect, abort or continue
}
}
Request & Response
$name = $this->post('name', 'required|min:3');
$page = $this->get('page');
$this->json(['ok' => true]);
redirect('/dashboard');
Query Builder
Src\Database is a fluent PDO query builder. Get the instance via Database::getInstance() or $this->db() in a controller.
// Read
$user = $db->from('users')
->where('email', '=', 'alice@example.com')
->first();
$result = $db->from('users')->whereLike('first_name', 'ali')->paginate(15);
// $result->data, ->total, ->pages, ->links
// Write
$db->insert('posts', ['title' => $t, 'body' => $b]);
$db->update('posts', ['title' => 'New'], ['id' => 1]);
$db->delete('posts', ['id' => 1]);
| Method | Description |
|---|---|
from(table) | Start a SELECT query |
where(col, op, val) | WHERE clause |
whereLike(col, val) | WHERE col LIKE %val% |
orderBy / latest / oldest | Ordering |
limit / offset / skip | Pagination helpers |
get / first / count | Execute queries |
paginate(perPage) | Paginated result object |
insert / update / delete | Mutation helpers |
getConnection() | Raw PDO instance |
Schema Builder
The Src\Database\Schema facade and Blueprint builder let you define your database structure in expressive PHP — no raw SQL required in migrations.
Every method returns a fluent ColumnDefinition so modifiers can be chained indefinitely.
Creating a Table
use Src\Database\Schema;
use Src\Database\Blueprint;
Schema::create('posts', function (Blueprint $table) {
$table->id(); // INT UNSIGNED NOT NULL AUTO_INCREMENT PK
$table->foreignId('user_id'); // BIGINT UNSIGNED NOT NULL
$table->string('title'); // VARCHAR(255) NOT NULL
$table->string('slug')->unique(); // + UNIQUE KEY
$table->text('body')->nullable(); // TEXT NULL
$table->boolean('published')->default(false); // TINYINT(1) DEFAULT 0
$table->tinyInteger('priority')->unsigned()->default(1);
$table->enum('status', ['draft', 'published', 'archived'])->default('draft');
$table->json('meta')->nullable();
$table->timestamps(); // created_at + updated_at
$table->softDeletes(); // deleted_at TIMESTAMP NULL
// Composite indexes
$table->index(['user_id', 'status']);
$table->unique(['user_id', 'slug']);
// Foreign key constraint
$table->foreign('user_id')
->references('id')->on('users')
->cascadeOnDelete();
});
Altering a Table
Schema::table('users', function (Blueprint $table) {
// Add columns
$table->string('avatar')->nullable()->after('email');
$table->boolean('verified')->default(false)->after('avatar');
// Modify existing column (append ->change())
$table->string('phone', 50)->nullable()->change();
// Drop / rename
$table->dropColumn('old_field');
$table->renameColumn('bio', 'about');
});
Drop & Introspection
Schema::drop('posts');
Schema::dropIfExists('posts');
Schema::rename('old_name', 'new_name');
Schema::hasTable('users'); // bool
Schema::hasColumn('users', 'email'); // bool
Schema::getColumns('users'); // string[]
Schema::getTables(); // string[]
Schema::disableForeignKeyChecks(); // before truncate / refresh
Schema::enableForeignKeyChecks();
Blueprint — Column Type Reference
| Method | MySQL Type | Notes |
|---|---|---|
id(name='id') | INT UNSIGNED | Auto-increment + PRIMARY KEY |
bigId(name='id') | BIGINT UNSIGNED | Auto-increment + PRIMARY KEY |
string(name, len=255) | VARCHAR | Configurable length |
char(name, len=1) | CHAR | Fixed-length |
text(name) | TEXT | Up to ~65 KB |
mediumText(name) | MEDIUMTEXT | Up to ~16 MB |
longText(name) | LONGTEXT | Up to ~4 GB |
integer(name) | INT | |
tinyInteger(name) | TINYINT | -128 to 127 |
smallInteger(name) | SMALLINT | |
mediumInteger(name) | MEDIUMINT | |
bigInteger(name) | BIGINT | |
foreignId(name) | BIGINT UNSIGNED | For FK columns |
float(name, p=8, s=2) | FLOAT | |
double(name, p=15, s=8) | DOUBLE | |
decimal(name, p=8, s=2) | DECIMAL | Exact precision |
boolean(name) | TINYINT(1) | true / false |
enum(name, values[]) | ENUM | Fixed value set |
set(name, values[]) | SET | Multi-value set |
json(name) | JSON | MySQL 5.7.8+ |
binary(name) | BLOB | |
date(name) | DATE | |
time(name) | TIME | |
dateTime(name) | DATETIME | |
timestamp(name) | TIMESTAMP | |
year(name) | YEAR | |
timestamps() | — | Adds created_at + updated_at |
softDeletes(col='deleted_at') | TIMESTAMP NULL | For soft-delete pattern |
ColumnDefinition — Modifier Chain
Every column type method returns a ColumnDefinition. Chain any modifiers in any order:
| Modifier | Effect |
|---|---|
->nullable() | Allows NULL values |
->unsigned() | UNSIGNED (integers only) |
->default(value) | DEFAULT value (string quoted, null → NULL) |
->useCurrent() | DEFAULT CURRENT_TIMESTAMP (timestamps) |
->useCurrentOnUpdate() | ON UPDATE CURRENT_TIMESTAMP |
->autoIncrement() | AUTO_INCREMENT |
->unique() | Adds a UNIQUE KEY constraint |
->primary() | Marks column as PRIMARY KEY |
->after(column) | Position AFTER column (ALTER TABLE) |
->first() | Position FIRST (ALTER TABLE) |
->comment(text) | Attach a column COMMENT |
->change() | Use MODIFY COLUMN instead of ADD COLUMN |
Foreign Keys
$table->foreignId('user_id'); // BIGINT UNSIGNED NOT NULL
$table->foreign('user_id')
->references('id')
->on('users')
->cascadeOnDelete(); // ON DELETE CASCADE ON UPDATE RESTRICT
// Aliases for onDelete:
->cascadeOnDelete() // CASCADE
->nullOnDelete() // SET NULL
->restrictOnDelete() // RESTRICT (default)
// Full control:
->onDelete('CASCADE')->onUpdate('SET NULL')
->name('fk_custom_name') // override auto-generated constraint name
Composite & Full-text Indexes
$table->primary(['order_id', 'product_id']); // composite PK
$table->unique(['user_id', 'slug']); // composite unique
$table->index(['status', 'created_at']); // composite index
$table->fulltext(['title', 'body']); // FULLTEXT index
Table Options
Schema::create('logs', function (Blueprint $table) {
$table->engine('MyISAM');
$table->charset('latin1');
$table->collation('latin1_swedish_ci');
$table->id();
$table->text('message');
});
CREATE TABLE, not inline — making the SQL easier to read, diff, and port.
Models
php bin/artisan make:model Post
php bin/artisan make:model Post --migration # also creates migration
class Post extends Model
{
protected $table = 'posts';
protected $fillable = ['title', 'body', 'user_id'];
protected $hidden = ['password'];
}
| Method | Description |
|---|---|
all() | Return all rows |
find(int $id) | Find by primary key |
create(array $data) | Insert (respects $fillable) |
update(int $id, array $data) | Update by PK |
delete(int $id) | Delete by PK |
limit(int $n)->get() | Fetch with limit |
Migrations
Migrations live in database/migrations/ as timestamped PHP classes tracked in the migrations table. All generated stubs use the Schema builder.
php bin/artisan make:migration create_posts_table
Generated file:
use Src\Database\Schema;
use Src\Database\Blueprint;
class CreatePostsTable
{
public function up(): void
{
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->text('body')->nullable();
$table->boolean('published')->default(false);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('posts');
}
}
Commands
php bin/artisan migrateRun all pending migrationsphp bin/artisan migrate:statusShow ran / pending status for every filephp bin/artisan migrate:rollbackRoll back the last batchphp bin/artisan migrate:refreshRoll back ALL then re-run — ⚠️ clears data
create_X_table, add_Y_to_X, or drop_Y_from_X — the table name is auto-inferred in the generated stub.
Views & Templating
Views use Plates (native PHP templates). View files live in the path set by config/app.php → view_folder.
<!-- layouts/main.php -->
<html><body>
<?= $this->section('content') ?>
<?= $this->section('scripts') ?>
</body></html>
<!-- posts/index.php -->
<?php $this->layout('layouts/main', ['title' => $pageTitle]) ?>
<?php foreach ($posts->data as $post): ?>
<h2><?= $post->title ?></h2>
<?php endforeach ?>
<?php $this->push('scripts'); ?>
<script> console.log('loaded'); </script>
<?php $this->end(); ?>
$this->render('posts/index', compact('posts', 'pageTitle'));
Unpoly Frontend
Unpoly is loaded from public/vendor/unpoly and enhances server-rendered PHP views into a smooth single-page experience without a build step.
Built-in Conventions
| Feature | Convention |
|---|---|
| Main fragment | <div id="page" up-main> is the default target |
| Links | Same-origin links are followed automatically unless they use data-no-up |
| Forms | Forms are submitted automatically and target #page by helper convention |
| Validation | Failed submissions re-render with HTTP 422 |
| Redirects | redirect() sends normal Location plus Unpoly location headers |
| Modals | Use up_modal_attrs() to open any server-rendered route in an overlay |
Helpers
up()->isRequest();
up()->target();
up()->setTarget('#page');
up()->acceptLayer(['id' => 1]);
up()->emit('user:saved');
up_link_attrs();
up_form_attrs();
up_modal_attrs();
Helper Functions
| Function | Description |
|---|---|
app() | Global Application instance |
router() | Router from container |
url(string) | Build URL with base_url prefix |
asset(string) | Build URL to a public asset |
redirect(string) | Send Location header and exit |
flash(key, msg) | One-time flash message |
old(key, default) | Previously submitted form value |
auth(guard) | Get a session-backed auth guard instance |
user(guard) | Authenticated account from the selected guard cache |
up() | Unpoly request/response helper |
up_link_attrs() | Attributes for Unpoly-enhanced links |
up_form_attrs() | Attributes for Unpoly-enhanced forms |
up_modal_attrs() | Attributes for opening routes in Unpoly overlays |
timing(time) | Timing utility for date formatting |
generateCsrfToken() | Get / create CSRF token |
Artisan Console
php bin/artisan <command> [arguments]
| Command | Description |
|---|---|
list | List all registered commands |
make:controller Name [--resource] | Scaffold basic or resource controller |
make:model Name [-m] | Scaffold model; -m also creates migration |
make:migration snake_name | Create a blank Schema-builder migration |
make:middleware Name | Scaffold middleware stub |
migrate | Run all pending migrations |
migrate:status | Show ran / pending status table |
migrate:rollback | Roll back last batch |
migrate:refresh | Reset and re-run all migrations |
Custom Commands
namespace Src\Console\Commands;
use Src\Console\Command;
class SendNewsletterCommand extends Command
{
protected string $name = 'newsletter:send';
protected string $description = 'Send the weekly newsletter';
public function handle(array $args): int
{
$dry = in_array('--dry-run', $args);
$users = $this->db->from('users')->get();
foreach ($users as $u) {
$this->line(" → {$u->email}");
if (!$dry) { /* mail($u->email, ...) */ }
}
$this->success(count($users) . ' emails dispatched');
return 0;
}
}
Register in src/Console/Kernel.php → registerCommands():
$this->register(new SendNewsletterCommand());
$this->info(string)ℹ️ Info line$this->success(string)✅ Success line$this->error(string)❌ Error line$this->warn(string)⚠️ Warning line$this->line(string)Plain output$this->table(h, rows)ASCII tableSession & Cache
// Flash messages
flash('success', 'Saved!');
flash('error', 'Something went wrong');
// Cache
use Src\Cache;
Cache::set("user_{$id}", $user);
$user = Cache::get("user_{$id}");
Cache::delete("user_{$id}");
Validation
$name = $this->post('name', 'required|min:3|max:100');
$email = $this->post('email', 'required|email');
$age = $this->post('age', 'required|numeric');
if ($this->validation()) {
flash('error', $this->renderErrors());
redirect('/form');
}
| Rule | Description |
|---|---|
required | Field must not be empty |
min:N | Minimum string length |
max:N | Maximum string length |
email | Valid email address |
numeric | Must be numeric |
Dependency Injection
Powered by Pimple. Bind services in Application::registerServices() or a Service Provider.
$this->container['mailer'] = fn() => new Mailer(app()->getConfig('mail'));
// Resolve anywhere
$mailer = app()->getContainer()['mailer'];
Logging
$log = app()->getContainer()['logger']; // Monolog instance
$log->info('User login', ['id' => $id]);
$log->error('Payment failed', ['order' => $orderId]);
Default handler writes to logs/request_time.log. Add extra handlers in Application::registerServices().
Error Handling
| Context | Behaviour |
|---|---|
| HTTP development | Renders views/errors/error.php with full message |
| HTTP production | Renders generic views/errors/500.php |
| HTTP 404 | Renders views/errors/404.php |
| CLI | Prints ❌ Error: … and exits with code 1 |
use Src\Exceptions\AppException;
throw new AppException('Not found', 404);
throw new AppException('Forbidden', 403);
Service Providers
Any class in src/Providers/ extending ServiceProvider with a register(Container $c) method is auto-loaded at boot.
class MailServiceProvider extends ServiceProvider
{
public function register(Container $c): void
{
$c['mailer'] = fn() => new Mailer(app()->getConfig('mail'));
}
}
PHP Attributes
| Attribute | Location | Effect |
|---|---|---|
#[Route(method, path, name?)] | Controller method | Registers as HTTP route |
#[Auth(guard?, redirectTo?)] | Controller method | Redirects guests for the selected guard |
#[Guest(guard?, redirectTo?)] | Controller method | Redirects authenticated accounts for the selected guard |
#[Auth]
#[Route('GET', '/settings')]
public function settings() { ... }
#[Auth('admin', '/admin/login')]
#[Route('GET', '/admin/settings')]
public function adminSettings() { ... }