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.

PHP 8.1+ MySQL / PDO Composer PSR-4 Schema Builder Plates Templates Pimple DI

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.

ComponentClass / PathWhat it does
ApplicationSrc\ApplicationBootstraps the container, providers, and routes
RouterSrc\RouterPHP-attribute-based routing with dynamic parameters
AuthSrc\AuthSession-backed multi-guard authentication for users, admins, staff, and custom guards
DatabaseSrc\DatabaseFluent query builder, singleton PDO connection
SchemaSrc\Database\SchemaDDL facade — create / alter / drop tables fluently
BlueprintSrc\Database\BlueprintColumn builder used inside Schema callbacks
ModelSrc\Models\ModelActive-record-style base model
ControllerSrc\Controllers\ControllerBase controller with helpers for render, redirect, JSON
Console KernelSrc\Console\KernelArtisan-like CLI command registry and dispatcher
SessionSrc\SessionFlash messages, old form values
CacheSrc\CacheSimple in-process file cache
ContainerPimplePSR-11 dependency injection
TemplatesPlates 3Native-PHP template engine with layouts & sections
UnpolySrc\UnpolyProgressive enhancement layer for SPA-like navigation, forms, overlays, redirects, and validation

Installation

Requirements

RequirementVersion
PHP8.1 or higher
Composer2.x
MySQL / MariaDB5.7 / 10.4+
PDO extensionenabled
# 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

scripting/
├── 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',
];
Note Any file in 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
CSRF Protection Every 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 errors

Access 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.

Default guard The default guard is 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
MethodDescription
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]);
MethodDescription
from(table)Start a SELECT query
where(col, op, val)WHERE clause
whereLike(col, val)WHERE col LIKE %val%
orderBy / latest / oldestOrdering
limit / offset / skipPagination helpers
get / first / countExecute queries
paginate(perPage)Paginated result object
insert / update / deleteMutation 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

MethodMySQL TypeNotes
id(name='id')INT UNSIGNEDAuto-increment + PRIMARY KEY
bigId(name='id')BIGINT UNSIGNEDAuto-increment + PRIMARY KEY
string(name, len=255)VARCHARConfigurable length
char(name, len=1)CHARFixed-length
text(name)TEXTUp to ~65 KB
mediumText(name)MEDIUMTEXTUp to ~16 MB
longText(name)LONGTEXTUp to ~4 GB
integer(name)INT
tinyInteger(name)TINYINT-128 to 127
smallInteger(name)SMALLINT
mediumInteger(name)MEDIUMINT
bigInteger(name)BIGINT
foreignId(name)BIGINT UNSIGNEDFor FK columns
float(name, p=8, s=2)FLOAT
double(name, p=15, s=8)DOUBLE
decimal(name, p=8, s=2)DECIMALExact precision
boolean(name)TINYINT(1)true / false
enum(name, values[])ENUMFixed value set
set(name, values[])SETMulti-value set
json(name)JSONMySQL 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 NULLFor soft-delete pattern

ColumnDefinition — Modifier Chain

Every column type method returns a ColumnDefinition. Chain any modifiers in any order:

$table->string('email') ->unique() → UNIQUE KEY email_unique
$table->integer('views') ->unsigned() ->default(0) → INT UNSIGNED NOT NULL DEFAULT 0
$table->string('bio') ->nullable() ->after('email') → VARCHAR(255) NULL AFTER `email`
$table->timestamp('verified_at') ->nullable() ->comment('Email verified') → TIMESTAMP NULL COMMENT '…'
$table->string('phone', 30) ->nullable() ->change() → MODIFY COLUMN (ALTER TABLE)
ModifierEffect
->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');
});
Generated SQL is clean Composite indexes and foreign keys are always written as separate constraint lines inside 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'];
}
MethodDescription
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

Migration naming convention Use 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

FeatureConvention
Main fragment<div id="page" up-main> is the default target
LinksSame-origin links are followed automatically unless they use data-no-up
FormsForms are submitted automatically and target #page by helper convention
ValidationFailed submissions re-render with HTTP 422
Redirectsredirect() sends normal Location plus Unpoly location headers
ModalsUse 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

FunctionDescription
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]
CommandDescription
listList 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_nameCreate a blank Schema-builder migration
make:middleware NameScaffold middleware stub
migrateRun all pending migrations
migrate:statusShow ran / pending status table
migrate:rollbackRoll back last batch
migrate:refreshReset 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 table

Session & 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');
}
RuleDescription
requiredField must not be empty
min:NMinimum string length
max:NMaximum string length
emailValid email address
numericMust 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

ContextBehaviour
HTTP developmentRenders views/errors/error.php with full message
HTTP productionRenders generic views/errors/500.php
HTTP 404Renders views/errors/404.php
CLIPrints ❌ 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

AttributeLocationEffect
#[Route(method, path, name?)]Controller methodRegisters as HTTP route
#[Auth(guard?, redirectTo?)]Controller methodRedirects guests for the selected guard
#[Guest(guard?, redirectTo?)]Controller methodRedirects authenticated accounts for the selected guard
#[Auth]
#[Route('GET', '/settings')]
public function settings() { ... }
#[Auth('admin', '/admin/login')]
#[Route('GET', '/admin/settings')]
public function adminSettings() { ... }
Multiple attributes Stack any number of attributes above a method — they are evaluated in declaration order.