A lightweight, modern PHP framework with built-in CLI tools for database migrations, similar to Laravel's Artisan.
- 🎯 Simple yet powerful routing system
- 🔐 Built-in authentication & CSRF protection
- 🗄️ ORM with Model support
- 📝 Database migrations (Artisan-style)
- 🎨 Template engine (Plates)
- 📦 Dependency injection container (Pimple)
- 🛠️ Console commands framework
- 📊 Request logging & timing utilities
scripting/
├── bin/
│ └── artisan # CLI entry point
├── config/
│ └── db.php # Database configuration
├── database/
│ └── migrations/ # Migration files
├── src/
│ ├── Application.php # Main app class
│ ├── Console/ # CLI framework
│ │ ├── Command.php
│ │ ├── Kernel.php
│ │ └── Commands/ # Built-in commands
│ ├── Controllers/ # HTTP controllers
│ ├── Models/ # Database models
│ ├── Providers/ # Service providers
│ ├── Middleware/ # HTTP middleware
│ ├── Exceptions/ # Exception handling
│ ├── Helpers/ # Helper functions
│ └── Util/ # Utility classes
├── views/ # Template files
├── public/ # Web root
└── vendor/ # Dependencies
-
Clone/setup the project
cd scripting composer install -
Configure database in
config/db.php:return [ 'schema' => 'mysql', 'host' => 'localhost', 'dbname' => 'scripting', 'username' => 'admin', 'password' => 'admin1' ];
-
Run migrations
php bin/artisan migrate
The framework provides a command-line interface similar to Laravel's Artisan. All commands start with php bin/artisan.
Display all available commands:
php bin/artisan listCreate a new migration file:
php bin/artisan make:migration create_users_table
php bin/artisan make:migration add_email_to_usersCreates a file in database/migrations/ with timestamp and class:
<?php
class CreateUsersTable
{
public function up()
{
// Write your migration code here
// $pdo->exec("CREATE TABLE users...");
}
public function down()
{
// Write rollback code here
// $pdo->exec("DROP TABLE users");
}
}Run all pending migrations:
php bin/artisan migrate- Checks for migrations not yet executed
- Creates
migrationstable automatically - Tracks which migrations have run
- Reports success/failure for each migration
Rollback the last batch of migrations:
php bin/artisan migrate:rollback- Runs the
down()method of the last batch - Removes migration records from the database
- Useful for undoing recent changes
Reset and re-run all migrations (full reset):
php bin/artisan migrate:refresh- Warning: This clears your database!
- Rolls back all migrations in reverse order
- Re-runs all migrations from scratch
- Useful for development/testing
Migrations are PHP files that modify your database schema. Each migration has two methods:
<?php
class CreateUsersTable
{
public function up()
{
// Get the PDO connection
$pdo = (new \Src\Database())->getConnection();
$pdo->exec("
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
");
}
public function down()
{
$pdo = (new \Src\Database())->getConnection();
$pdo->exec("DROP TABLE users");
}
}-
One logical change per migration
- Create tables in separate migrations from adding columns
-
Always provide a
down()method- Make migrations reversible for development
-
Use descriptive names
create_users_table✅add_email_to_users✅fix_column❌
-
Test rollbacks
- Run
migrate, thenmigrate:rollbackto verify
- Run
Create a new command by extending Src\Console\Command:
<?php
namespace Src\Console\Commands;
use Src\Console\Command;
class MyCustomCommand extends Command
{
protected string $name = 'my:command';
protected string $description = 'My custom command description';
public function handle(array $args): int
{
$this->info('Command executing...');
// Do something
$this->success('Command completed!');
return 0; // 0 = success, 1 = failure
}
}Then register it in src/Console/Kernel.php:
protected function registerCommands(): void
{
// ... existing commands
$this->register(new MyCustomCommand());
}$this->info('message')- Information message$this->success('message')- Success message (green)$this->error('message')- Error message (red)$this->warn('message')- Warning message (yellow)$this->line('message')- Plain text$this->table($headers, $rows)- Format tabular data
Migrations are tracked in the migrations table:
id | migration | batch | executed_at
---|-----------|-------|-------------
1 | 2024_01_15_100000_create_users_table.php | 1 | 2024-01-15 10:00:00
2 | 2024_01_15_100100_add_email_to_users.php | 1 | 2024-01-15 10:01:00
Edit config/db.php:
return [
'schema' => 'mysql', // 'mysql' or 'couchdb'
'host' => 'localhost',
'dbname' => 'scripting',
'username' => 'admin',
'password' => 'admin1'
];Get a PDO connection:
$db = new \Src\Database();
$pdo = $db->getConnection();
$pdo->exec("SELECT * FROM users");Define models in src/Models/:
<?php
namespace Src\Models;
class User extends Model
{
protected string $table = 'users';
public function isAdmin(): bool
{
return $this->is_admin ?? false;
}
}Define controllers in src/Controllers/:
<?php
namespace Src\Controllers;
class UserController extends Controller
{
public function index()
{
$users = \Src\Models\User::all();
return view('users.index', ['users' => $users]);
}
}Authentication is guard-aware, so client apps can keep separate sessions for users,
admins, vendors, staff, or any other auth area. The default guard is user, so
old code using #[Auth], #[Guest], auth(), and user() keeps working.
// Log in a normal user on the default "user" guard
auth()->login($user);
// Log in an admin on a separate "admin" guard
auth('admin')->login($admin);
// Read the authenticated account
$user = user(); // same as auth('user')->user()
$admin = user('admin'); // same as auth('admin')->user()
// Check and logout
auth('admin')->check();
auth('admin')->logout();
\Src\Auth::logoutAll();The object passed to login() must have an id property because the guard stores
that ID in the session and caches the full object.
Route attributes accept a guard name and optional redirect path:
use Src\Attributes\Auth;
use Src\Attributes\Guest;
use Src\Attributes\Route;
#[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()
{
// ...
}#[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);
flash('success', 'Admin logged in');
redirect('/admin/dashboard');
}
#[Auth('admin', '/admin/login')]
#[Route('GET', '/admin/logout')]
public function adminLogout()
{
auth('admin')->logout();
redirect('/admin/login');
}The framework includes a guarded admin panel using the admin auth guard.
GET /admin/login- admin login formPOST /admin/login- admin login submitGET /admin- admin dashboard, protected by#[Auth('admin', '/admin/login')]GET /admin/logout- logout only theadminguardGET /staffs- user/staff management, protected by theadminguard
Run migrations to create the admins table and seed the default admin:
php bin/artisan migrateDefault development credentials:
Email: admin@example.com
Password: admin123
Unpoly is integrated as the framework's first-class progressive enhancement layer. Views are still plain server-rendered PHP, but navigation and forms feel like a single-page app without a build step.
layouts/main.phpandlayouts/admin.phpload local Unpoly files frompublic/vendor/unpoly.- Both layouts expose
<div id="page" up-main>as the default swappable shell. - Page content still lives in
<main id="app">for smaller manual targets. - Same-origin links are followed by Unpoly automatically.
- Forms are submitted by Unpoly automatically.
- Flash messages live inside
#page, so redirects and validation responses update them. - Failed form submissions should re-render with HTTP
422. - Redirects still use normal
Locationheaders and also sendX-Up-Locationfor Unpoly.
up()->isRequest(); // true for Unpoly fragment requests
up()->target(); // X-Up-Target request header
up()->failTarget(); // X-Up-Fail-Target request header
up()->mode(); // root, modal, drawer, popup, ...
up()->isLayer(); // true when rendering inside an overlay
up()->setTarget('#page'); // send X-Up-Target response header
up()->setTitle('Dashboard'); // send X-Up-Title response header
up()->acceptLayer(['id' => 1]); // close/accept current overlay
up()->dismissLayer(); // dismiss current overlay
up()->emit('user:saved'); // emit a browser event after rendering
up_attrs(['up-target' => '#page']);
up_link_attrs(); // up-follow + up-target="#page"
up_form_attrs(); // up-submit + success/fail target "#page"
up_modal_attrs(); // up-layer="new" + target "#app"Use the default app fragment for full-page interactions:
<a href="<?= url('/staffs') ?>" <?= up_link_attrs() ?>>Users</a>
<form method="POST" action="<?= url('/contact') ?>" <?= up_form_attrs() ?>>
<input type="hidden" name="csrf_token" value="<?= generateCsrfToken() ?>">
...
</form>Open any server-rendered route in a modal:
<a href="<?= url('/users/create') ?>" <?= up_modal_attrs() ?>>Create user</a>Opt out when a link or form must do a full browser load:
<a href="/download" data-no-up>Download</a>
<form method="POST" action="/export" data-no-up>...</form>Successful POST handlers may keep using redirect():
flash('success', 'Saved');
redirect('/staffs');Validation failures should re-render the same view with 422:
if ($errors) {
flash('error', $this->renderErrors());
return $this->renderUnprocessable('contact', [
'pageTitle' => 'Contact',
]);
}The renderUnprocessable() helper makes Unpoly treat the response as a failed
submission and replace the form's up-fail-target.
Client-side code uses native ES modules and needs no build step. Keep
public/js/unpoly-app.js as the entrypoint and place feature code under
public/js/app/.
public/js/
├── unpoly-app.js # imports and initializes modules
└── app/
├── dom.js # DOM helpers and delegated events
├── bootstrap-modals.js # Bootstrap modal wrapper
├── unpoly-config.js # global Unpoly selectors/events
├── flashes.js # flash auto-dismiss behavior
└── staff.js # users/staff page interactions
New page behavior should use delegated events or Unpoly compilers so it keeps
working after #page is swapped. Avoid inline page scripts for behavior that
must survive SPA navigation.
Shared layout concerns live in htmls/views/partials/ so public and admin
layouts stay small:
htmls/views/partials/
├── assets.php # CSS and head scripts
├── scripts.php # footer scripts
├── flashes.php # flash message rendering
├── navbar.php # public navigation
├── adminNavbar.php # admin navigation
├── confirmationModal.php # shared confirm modal
When adding a new layout, include partials/assets, wrap the page in
#page up-main, render partials/flashes inside #app, and include
partials/scripts before any page-specific scripts.
Routes are defined using attributes in controllers:
<?php
namespace Src\Controllers;
use Src\Attributes\Route;
class UserController extends Controller
{
#[Route('/users', 'GET')]
public function index()
{
// ...
}
#[Route('/users/{id}', 'GET')]
public function show($id)
{
// ...
}
}Middleware processes requests before they reach controllers. Built-in middleware:
CsrfMiddleware- CSRF token validation- Custom middleware in
src/Middleware/
Templates use Plates engine, stored in views/:
<!-- views/users/index.php -->
<?php $this->layout('layouts/app') ?>
<h1>Users</h1>
<?php foreach ($users as $user): ?>
<p><?= $user->name ?></p>
<?php endforeach; ?>Exception handling is configured in src/Exceptions/ErrorHandler.php. Custom exceptions:
<?php
namespace Src\Exceptions;
class AppException extends Exception
{
// Custom app exception
}Uses Monolog for logging:
use Monolog\Logger;
use Monolog\Handlers\StreamHandler;
$logger = new Logger('app');
$logger->pushHandler(new StreamHandler('logs/app.log'));
$logger->info('User login', ['user_id' => 123]);Quick test workflow:
# Create migration
php bin/artisan make:migration create_products_table
# Edit database/migrations/TIMESTAMP_create_products_table.php
# Test running the migration
php bin/artisan migrate
# Test rollback
php bin/artisan migrate:rollback
# Test refresh
php bin/artisan migrate:refresh
# Final run
php bin/artisan migrate# Make sure you're in the project root
cd /path/to/scripting
# Try running help
php bin/artisan help- Check if migrations have already been run
- Check if migration files exist in
database/migrations/
- Verify
config/db.phphas correct credentials - Ensure database exists:
CREATE DATABASE scripting - Check MySQL is running
- Check the error message for SQL syntax errors
- Review the migration file logic
- Verify the
down()method for rollback
- Always create reversible migrations - write both
up()anddown() - Test migrations - run migrate, then rollback, then migrate again
- Use migrations for schema only - don't insert test data in migrations
- Keep migrations small - easier to debug if something fails
- Use descriptive naming - future you will appreciate it
This project is open source and available under the MIT License.
For issues or questions, check the code in src/ or review the example migrations in database/migrations/.