mini PHP framework

Introduction

mini is a small, Laravel-inspired PHP framework built to learn how modern frameworks actually work under the hood: a service container, HTTP kernel with middleware pipeline, routing and controller resolution via reflection, a simple ActiveRecord ORM, query builder, and a tiny Blade-style templating engine.

The goal is not to compete with full-stack frameworks, but to give you a clean, hackable codebase you completely understand.

Philosophy

  • Small, readable code over magical behavior.
  • Feature set mirrors “early Eloquent / early Laravel”.
  • Every subsystem is something you can open and modify.

Major Pieces

  • Service container with dependency injection & service providers.
  • Router with parameter binding & implicit route model binding.
  • View engine with {{ }}, @if, @foreach syntax.
  • Models, relationships, query builder & collections.
  • SQLite migrations via a small console tool (php mini ...).
  • Dark-mode debug error page with stack trace & request context.

Getting Started

mini assumes a basic PHP 8+ environment with SQLite available. From your project root:

$ php mini serve
PHP built-in server started at http://127.0.0.1:9003

The front controller public/index.php boots the application:

// public/index.php

require __DIR__.'/../vendor/autoload.php';     // optional
$app = require __DIR__.'/../bootstrap/app.php';

$kernel  = $app->make(\Framework\Http\Kernel::class);
$request = \Framework\Http\Request::capture();

$response = $kernel->handle($request);
$response->send();

Routes live in routes/web.php and are automatically loaded by the RoutingServiceProvider.

// routes/web.php

use Framework\Routing\Router;
use Framework\View\View;

return function (Router $router): void {
    $router->get('/', function () {
        return View::make('home', ['name' => 'Elliot Anderson']);
    });
};

Directory Structure

A typical application using mini looks like:

.
├── app
│   ├── Http
│   │   ├── Controllers
│   │   └── Middleware
│   ├── Models
│   └── Providers
│       └── AppServiceProvider.php
├── bootstrap
│   ├── app.php
│   └── providers.php
├── public
│   ├── index.php
│   └── debug/              # saved debug html snapshots
├── resources
│   └── views               # Blade-ish templates
├── routes
│   └── web.php
├── src
│   ├── Application.php     # container
│   ├── Console             # mini CLI: serve, migrate, ...
│   ├── Database            # Connection, Model, Builder, Migration
│   ├── Exceptions          # ErrorPageRenderer
│   ├── Http                # Request, Response, Kernel
│   ├── Routing             # Router
│   ├── Support             # Collection
│   └── View                # View, TemplateEngine
└── storage
    └── views               # compiled templates

Service Container & Bootstrapping

The Framework\Application class is a lightweight IoC container. It is responsible for:

  • Registering bindings and singletons.
  • Resolving classes (including constructor injection via reflection).
  • Providing debug info about registered services.
  • Managing service provider registration and booting.
// bootstrap/app.php (using service providers)

use Framework\Application;

require_once __DIR__ . '/../vendor/autoload.php';

$app = new Application();

// Register service providers
$providersFile = __DIR__ . '/providers.php';
if (file_exists($providersFile)) {
    $providers = require $providersFile;
    $app->registerProviders($providers);
}

// Boot all registered providers
$app->boot();

return $app;

Service providers handle all service registration and configuration. See the Service Providers section for more details.

Service Providers

Service Providers are the central place to configure and bootstrap your application. They organize service registration into logical groups and provide a clean, modular way to set up your framework.

Creating a Service Provider

All service providers extend the Framework\Support\ServiceProvider base class:

// app/Providers/AppServiceProvider.php

namespace App\Providers;

use Framework\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        // Register bindings in the service container
        $this->app->singleton(SomeService::class, function () {
            return new SomeService();
        });
    }

    public function boot(): void
    {
        // Boot services after all providers are registered
    }
}

Registering Service Providers

Service providers are registered in bootstrap/providers.php:

// bootstrap/providers.php

return [
    // Framework core providers
    \Framework\Providers\DatabaseServiceProvider::class,
    \Framework\Providers\ViewServiceProvider::class,
    \Framework\Providers\RoutingServiceProvider::class,
    \Framework\Providers\HttpServiceProvider::class,
    
    // Application providers
    \App\Providers\AppServiceProvider::class,
];

Built-in Service Providers

The framework includes several service providers:

  • DatabaseServiceProvider – Registers database connection and model setup
  • ViewServiceProvider – Configures view paths and caching
  • RoutingServiceProvider – Registers router and loads route files
  • HttpServiceProvider – Registers HTTP kernel
  • ConfigServiceProvider – Loads configuration files (available)
  • LoggingServiceProvider – Sets up logging system (available)
tip

Service providers are registered in order, and all register() methods are called before any boot() methods. This ensures dependencies are available when booting.

HTTP Kernel & Middleware

The HTTP layer consists of:

  • Framework\Http\Request – an immutable request object.
  • Framework\Http\Response – simple response wrapper.
  • Framework\Http\Kernel – builds a middleware pipeline and delegates to the router.
// Kernel::handle()

public function handle(Request $request): Response
{
    try {
        $pipeline = $this->buildMiddlewarePipeline();
        return $pipeline($request);
    } catch (\Throwable $e) {
        if (getenv('APP_DEBUG') === 'true') {
            $html = ErrorPageRenderer::render($e, $request, $this->app);
            return new Response($html, 500);
        }
        return new Response('Internal Server Error', 500);
    }
}

Middleware are simple classes with a handle(Request $request, Closure $next) method.

// app/Http/Middleware/LogRequests.php

class LogRequests
{
    public function handle(Request $request, \Closure $next): Response
    {
        $response = $next($request);

        error_log(sprintf(
            '%s %s -> %d',
            $request->getMethod(),
            $request->getPath(),
            $response->getStatusCode()
        ));

        return $response;
    }
}

Routing & Controllers

Basic Routes

// routes/web.php

return function (Router $router): void {
    $router->get('/', function () {
        return View::make('home');
    });

    $router->get('/hello/{name}', function (string $name) {
        return new Response("Hello, {$name}");
    });
};

Controller Actions

Routes can point to controllers using "Controller@method" or array syntax:

$router->get('/dashboard', 'HomeController@index');

// or

$router->get('/dashboard', [\App\Http\Controllers\HomeController::class, 'index']);

The router uses reflection to resolve the controller from the container and inject method dependencies:

class HomeController
{
    public function index(\Framework\Http\Request $request): Response
    {
        // Request is injected automatically
    }
}

Route Parameters & Implicit Model Binding

Named parameters in routes are converted into regexes and collected into $routeParams. These are used both as raw values and for implicit route model binding.

$router->get('/users/{user}', 'UserController@show');

// In UserController

public function show(\App\Models\User $user): Response
{
    // The {user} parameter is resolved into a User model:
    // SELECT * FROM users WHERE id = :id LIMIT 1
    return View::make('users.show', compact('user'));
}

Under the hood, callWithDependencies() checks if a parameter type is a subclass of Framework\Database\Model and the parameter name matches a route parameter; if so, it calls User::find($id) and injects the model.

Views & Templating

Views live in resources/views and are compiled to PHP into storage/views by Framework\View\TemplateEngine. The View facade compiles and returns a Response.

// bootstrap/app.php

View::setBasePath(__DIR__ . '/../resources/views');
View::setCachePath(__DIR__ . '/../storage/views');

Template Syntax

  • {{ $var }} – escaped echo (htmlspecialchars).
  • {!! $html !!} – raw echo.
  • @if, @elseif, @else, @endif.
  • @foreach, @endforeach.
<!-- resources/views/home.php -->

<!doctype html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Mini • Home</title>
</head>
<body>
    <h1>Welcome, {{ $user->name }}</h1>

    @if ($user->posts->isEmpty())
        <p>No posts yet.</p>
    @else
        <ul>
            @foreach ($user->posts as $post)
                <li>{{ $post->title }}</li>
            @endforeach
        </ul>
    @endif
</body>
</html>

The first time a view is requested, it is compiled and cached; subsequent requests use the cached file until the source template timestamp changes.

Models & ActiveRecord

Models extend Framework\Database\Model and map to database tables. The base model provides:

  • Static connection via Model::setConnection().
  • ActiveRecord operations: save(), delete(), all(), find().
  • Magic attributes via __get/__set and accessor/mutator methods.
  • Naive timestamps (created_at, updated_at).
  • Relationship helpers: hasMany, belongsTo.
// app/Models/User.php

namespace App\Models;

use Framework\Database\Model;

class User extends Model
{
    protected static string $table = 'users';

    public function posts()
    {
        return $this->hasMany(Post::class, 'user_id');
    }

    // Accessor example:
    public function getDisplayNameAttribute(): string
    {
        return $this->name . ' <' . $this->email . '>';
    }

    // Mutator example:
    public function setEmailAttribute(string $value): void
    {
        $this->attributes['email'] = strtolower($value);
    }
}

Creating & Updating

// create
$user = new User([
    'name'  => 'Elliot Anderson',
    'email' => 'elliot@example.com',
]);

$user->save();        // INSERT, sets $user->id

// update
$user->name = 'New Name';
$user->save();        // UPDATE users SET ... WHERE id = :id

Attributes are available directly as properties thanks to magic attribute handling and relationship resolution:

$user = User::find(1);

echo $user->display_name;   // calls getDisplayNameAttribute()
echo $user->posts->count(); // posts() relationship, wrapped in a Collection

Query Builder & Collection

The Framework\Database\Builder class provides a small fluent query builder for SELECT queries. Results are wrapped in Framework\Support\Collection, an iterable helper with map, filter, first, pluck, and more.

// Fluent builder usage

$users = User::query()
    ->where('age', '>', 18)
    ->where('status', 'active')
    ->orderBy('name')
    ->limit(10)
    ->get();           // returns Collection<User>

$names = $users
    ->map(fn (User $u) => $u->name)
    ->all();

Models provide shortcuts that delegate to the builder:

User::all();                        // Collection<User>
User::find(1);                      // ?User
User::where('status', 'active');    // Collection<User>
note

The builder currently targets SQLite and focuses on SELECT queries. It is intentionally minimal so you can read and extend it as needed (joins, aggregates, pagination, etc.).

Migrations & Console Commands

mini ships with a small console kernel that powers the php mini CLI. Commands are registered in Framework\Console\Application.

$ php mini
Available commands:
  serve
  make:controller
  make:migration
  migrate
  migrate:rollback

Creating & Running Migrations

// generate a migration class under database/migrations
$ php mini make:migration create_users_table

The generated file returns an anonymous class extending Framework\Database\Migration with up() and down() methods. Inside, use the underlying PDO connection directly:

// database/migrations/20241204120000_create_users_table.php

use Framework\Database\Migration;
use Framework\Database\Connection;

return new class extends Migration {
    public function up(Connection $connection): void
    {
        $connection->pdo()->exec(<<
// run outstanding migrations
$ php mini migrate

// rollback most recent batch
$ php mini migrate:rollback

Error Pages & Debug Experience

In debug mode (APP_DEBUG=true), unhandled exceptions are rendered using Framework\Exceptions\ErrorPageRenderer – a Whoops-style dark error page that shows:

  • Exception class, message, file and line.
  • Code excerpt with the failing line highlighted.
  • Stack trace with “App” vs “Framework” frames and a toggle.
  • Request context: method, URI, client IP and superglobals (GET, POST, cookies, headers).
  • Container bindings: registered bindings, singletons and instances.
  • A saved HTML snapshot link under public/debug/<id>.html.
// public/index.php

putenv('APP_DEBUG=true');      // for development

// Kernel::handle() already wraps exceptions and delegates to ErrorPageRenderer
production

In production, set APP_DEBUG=false. The kernel will return a generic 500 Internal Server Error response without exposing stack traces or request data.

Next Steps & Ideas

mini is intentionally incomplete so you can grow it as you learn. Natural next features include:

  • View layouts and sections (@extends, @section, @yield).
  • Soft deletes and global scopes on models.
  • More query builder features (joins, aggregates, pagination helpers).
  • HTTP testing helpers and an in-memory test kernel.
  • Enhanced configuration system with environment variable support.
  • Routing groups, middleware per route, named routes and URL generation.
  • Session management, CSRF protection, and authentication.

The real power of this framework is that you own every line. Treat this documentation as a jumping-off point: open the classes mentioned here, trace through the flow, and don’t be afraid to break things and rebuild them.