Project

General

Profile

Actions

PHP Lightweight MVC

Table of Contents

  1. Front controller
  2. Bootstrap and autoloading
  3. Core primitives
    1. Router
    2. Controller base class
    3. View renderer
    4. Database helper
  4. App configuration
  5. Domain model: Inventory
    1. InventoryItem
    2. Repository interface
    3. Session-backed repository (design mode)
    4. DB-backed repository (production mode)
    5. Repository factory and legacy alias
  6. Inventory controller and views
    1. Controller
    2. Views
  7. Switching from design to DB mode

This document describes the tiny MVC framework used in this directory.
It is written as a literate program: prose explains the design, and
source blocks tangle into the PHP files under core/, app/, and
public/. The goal is to keep development easy and make the transition
from in-memory prototypes to a real database very clear.

Front controller

All web requests enter through a single front controller:

<?php
declare(strict_types=1);

require __DIR__ . '/../core/bootstrap.php';

use Core\Router;
use App\Config;

$router = new Router(Config::BASE_URL);

$router->get('/', 'InventoryController@index');
$router->get('/inventory', 'InventoryController@index');
$router->get('/inventory/show/{id}', 'InventoryController@show');
$router->post('/inventory', 'InventoryController@store');

$router->dispatch($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI']);

Bootstrap and autoloading

The bootstrap file sets error reporting, wires up PSR-4-style autoloading
for Core\\ and App\\ namespaces, starts a PHP session for
in-memory development, and conditionally bootstraps the database layer
when we switch to DB-backed storage.

<?php
declare(strict_types=1);

error_reporting(E_ALL);
ini_set('display_errors', '1');

// Simple PSR-4-style autoloader for Core and App namespaces.
spl_autoload_register(function (string $class): void {
    $prefixes = [
        'Core\\' => __DIR__ . '/',
        'App\\'  => __DIR__ . '/../app/',
    ];

    foreach ($prefixes as $prefix => $baseDir) {
        $len = strlen($prefix);
        if (strncmp($class, $prefix, $len) !== 0) {
            continue;
        }

        $relative = substr($class, $len);
        $file = $baseDir . str_replace('\\', '/', $relative) . '.php';

        if (is_file($file)) {
            require $file;
            return;
        }
    }
});

// Simple session-based state so in-memory models can persist
// across requests during the design phase.
if (session_status() !== PHP_SESSION_ACTIVE) {
    session_start();
}

// If inventory storage is configured as 'db', bootstrap the database
// connection once for the request. When using 'session' storage, the
// DB layer is completely skipped.
use App\Config;
use Core\Database;

$storageMode = getenv('INVENTORY_STORAGE') ?: Config::INVENTORY_STORAGE;
if ($storageMode === 'db') {
    Database::init(Config::dbDsn(), Config::DB_USER, Config::DB_PASSWORD);
}

Core primitives

Router

The Router maps HTTP methods and paths to controller actions.

<?php
namespace Core;

class Router
{
    private string $baseUrl;
    private array $routes = [
        'GET'  => [],
        'POST' => [],
    ];

    public function __construct(string $baseUrl = '')
    {
        $this->baseUrl = rtrim($baseUrl, '/');
    }

    public function get(string $path, string $handler): void
    {
        $this->addRoute('GET', $path, $handler);
    }

    public function post(string $path, string $handler): void
    {
        $this->addRoute('POST', $path, $handler);
    }

    private function addRoute(string $method, string $path, string $handler): void
    {
        $this->routes[$method][] = [
            'pattern' => $this->compilePath($path),
            'handler' => $handler,
            'raw'     => $path,
        ];
    }

    private function compilePath(string $path): string
    {
        $regex = preg_replace('#\\{([a-zA-Z_][a-zA-Z0-9_]*)\\}#', '(?P<$1>[^/]+)', $path);
        return '#^' . rtrim($regex, '/') . '/?$#';
    }

    public function dispatch(string $method, string $uri): void
    {
        $path = parse_url($uri, PHP_URL_PATH) ?? '/';
        $path = preg_replace('#^' . preg_quote($this->baseUrl, '#') . '#', '', $path) ?: '/';

        foreach ($this->routes[$method] ?? [] as $route) {
            if (preg_match($route['pattern'], $path, $matches)) {
                [$controllerName, $action] = explode('@', $route['handler']);

                $controllerClass = 'App\\Controllers\\' . $controllerName;
                if (!class_exists($controllerClass)) {
                    http_response_code(500);
                    echo "Controller {$controllerClass} not found.";
                    return;
                }

                $controller = new $controllerClass();
                if (!method_exists($controller, $action)) {
                    http_response_code(500);
                    echo "Action {$action} not found on controller {$controllerClass}.";
                    return;
                }

                $params = array_filter(
                    $matches,
                    fn($key) => !is_int($key),
                    ARRAY_FILTER_USE_KEY
                );

                call_user_func_array([$controller, $action], $params);
                return;
            }
        }

        http_response_code(404);
        echo "404 Not Found";
    }
}

Controller base class

Controllers use a thin base class that knows how to render views and
redirect.

<?php
namespace Core;

class Controller
{
    protected function render(string $view, array $data = []): void
    {
        View::render($view, $data);
    }

    protected function redirect(string $url): void
    {
        header("Location: {$url}");
        exit;
    }
}

View renderer

Views are plain PHP templates under app/Views. The view renderer just
finds the file and exposes the data array as local variables.

<?php
namespace Core;

class View
{
    public static string $basePath = __DIR__ . '/../app/Views/';

    public static function render(string $view, array $data = []): void
    {
        $file = self::$basePath . $view . '.php';

        if (!is_file($file)) {
            http_response_code(500);
            echo "View {$view} not found.";
            return;
        }

        extract($data, EXTR_SKIP);
        require $file;
    }
}

Database helper

The DB helper is a light wrapper around PDO. It is only initialized when
we choose DB-backed storage.

<?php
namespace Core;

use PDO;
use PDOException;

class Database
{
    private static ?PDO $pdo = null;

    public static function init(string $dsn, string $user, string $password): void
    {
        if (self::$pdo !== null) {
            return;
        }

        try {
            self::$pdo = new PDO($dsn, $user, $password, [
                PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
                PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            ]);
        } catch (PDOException $e) {
            http_response_code(500);
            echo 'DB connection error: ' . htmlspecialchars($e->getMessage());
            exit;
        }
    }

    public static function pdo(): PDO
    {
        if (self::$pdo === null) {
            throw new \RuntimeException('Database::init() must be called first.');
        }
        return self::$pdo;
    }
}

App configuration

The Config class centralizes app configuration, including how
inventory data is stored: purely in-memory/session for design, or in a
real database.

<?php
namespace App;

/**
 * App-level configuration.
 */
final class Config
{
    /**
     * Base URL of the app (e.g. when deployed under a subdirectory).
     */
    public const BASE_URL = '';

    /**
     * Where inventory data is stored.
     * - 'session'  => in PHP session (design/dev mode, no DB required)
     * - 'db'       => MySQL via Core\\Database
     */
    public const INVENTORY_STORAGE = 'session';

    // --- Database configuration (used only when INVENTORY_STORAGE = 'db') ---

    public const DB_HOST = '127.0.0.1';
    public const DB_NAME = 'local';
    public const DB_USER = 'mysql';
    public const DB_PASSWORD = 'mysql';

    public static function dbDsn(): string
    {
        $host = getenv('DB_HOST') ?: self::DB_HOST;
        $db   = getenv('DB_NAME') ?: self::DB_NAME;
        return "mysql:host={$host};dbname={$db};charset=utf8mb4";
    }
}

Domain model: Inventory

InventoryItem

An InventoryItem is the core domain object.

<?php
namespace App\Models;

class InventoryItem
{
    public ?int $id;
    public string $name;

    public function __construct(?int $id, string $name)
    {
        $this->id   = $id;
        $this->name = $name;
    }
}

Repository interface

We access inventory data through an InventoryRepository interface. The
rest of the app does not care whether data lives in memory or in a DB.

<?php
namespace App\Models;

interface InventoryRepository
{
    /** @return InventoryItem[] */
    public function all(): array;

    public function find(int $id): ?InventoryItem;

    public function create(string $name): InventoryItem;
}

Session-backed repository (design mode)

During the design phase we keep everything in PHP session state. This
requires no schema and is very forgiving.

<?php
namespace App\Models;

/**
 * Inventory repository backed by PHP session data.
 *
 * This keeps development fluid: no real DB is required during
 * the design phase, but items persist across HTTP requests for
 * a single browser session. Later we can swap this for a
 * DB-backed implementation without touching controllers/views.
 */
class SessionInventoryRepository implements InventoryRepository
{
    private const SESSION_ITEMS_KEY = 'inventory_items';
    private const SESSION_NEXT_ID_KEY = 'inventory_next_id';

    public function __construct()
    {
        // Seed with a sample item to make first-run UX nicer.
        $items = $this->getStore();
        if (empty($items)) {
            $this->create('Sample item');
        }
    }

    /**
     * @return InventoryItem[]
     */
    public function all(): array
    {
        return array_values($this->getStore());
    }

    public function find(int $id): ?InventoryItem
    {
        $items = $this->getStore();
        return $items[$id] ?? null;
    }

    public function create(string $name): InventoryItem
    {
        $items =& $this->getStore();
        $nextId = $this->getNextId();

        $item = new InventoryItem($nextId, $name);
        $items[$nextId] = $item;

        $this->setNextId($nextId + 1);

        return $item;
    }

    /** @return array<int, InventoryItem> */
    private function &getStore(): array
    {
        if (!isset($_SESSION[self::SESSION_ITEMS_KEY]) || !is_array($_SESSION[self::SESSION_ITEMS_KEY])) {
            $_SESSION[self::SESSION_ITEMS_KEY] = [];
        }

        // Ensure everything in the store is a valid InventoryItem; if not,
        // drop it so views/controllers don't see incomplete objects.
        foreach ($_SESSION[self::SESSION_ITEMS_KEY] as $id => $value) {
            if (!$value instanceof InventoryItem) {
                unset($_SESSION[self::SESSION_ITEMS_KEY][$id]);
            }
        }

        /** @var array<int, InventoryItem> $store */
        $store =& $_SESSION[self::SESSION_ITEMS_KEY];
        return $store;
    }

    private function getNextId(): int
    {
        if (!isset($_SESSION[self::SESSION_NEXT_ID_KEY])) {
            $_SESSION[self::SESSION_NEXT_ID_KEY] = 1;
        }
        return (int)$_SESSION[self::SESSION_NEXT_ID_KEY];
    }

    private function setNextId(int $nextId): void
    {
        $_SESSION[self::SESSION_NEXT_ID_KEY] = $nextId;
    }
}

DB-backed repository (production mode)

When you are happy with the object design and ready to introduce a real
schema, you can switch to the DB-backed repository. It expects a table
like:

CREATE TABLE inventory_items (
    id   INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(255) NOT NULL
);

The implementation simply translates between DB rows and
InventoryItem objects.

<?php
namespace App\Models;

use Core\Database;

/**
 * Database-backed inventory repository.
 */
class DbInventoryRepository implements InventoryRepository
{
    /** @return InventoryItem[] */
    public function all(): array
    {
        $stmt = Database::pdo()->query('SELECT id, name FROM inventory_items ORDER BY id DESC');
        $rows = $stmt->fetchAll();

        return array_map(
            fn(array $r) => new InventoryItem((int)$r['id'], $r['name']),
            $rows
        );
    }

    public function find(int $id): ?InventoryItem
    {
        $stmt = Database::pdo()->prepare('SELECT id, name FROM inventory_items WHERE id = :id');
        $stmt->execute(['id' => $id]);
        $row = $stmt->fetch();

        if (!$row) {
            return null;
        }

        return new InventoryItem((int)$row['id'], $row['name']);
    }

    public function create(string $name): InventoryItem
    {
        $stmt = Database::pdo()->prepare('INSERT INTO inventory_items (name) VALUES (:name)');
        $stmt->execute(['name' => $name]);

        $id = (int)Database::pdo()->lastInsertId();
        return new InventoryItem($id, $name);
    }
}

Repository factory and legacy alias

A tiny factory chooses the correct repository implementation based on
configuration. There is also a legacy Inventory class kept as a thin
alias for backwards compatibility.

<?php
namespace App\Models;

use App\Config;

final class InventoryRepositoryFactory
{
    public static function make(): InventoryRepository
    {
        $mode = getenv('INVENTORY_STORAGE') ?: Config::INVENTORY_STORAGE;

        return $mode === 'db'
            ? new DbInventoryRepository()
            : new SessionInventoryRepository();
    }
}

<?php
namespace App\Models;

/**
 * Legacy alias kept for backwards compatibility.
 *
 * New code should type-hint against InventoryRepository and obtain
 * an instance via InventoryRepositoryFactory.
 */
class Inventory extends SessionInventoryRepository
{
}

Inventory controller and views

Controller

The controller depends only on the InventoryRepository interface and
obtains a concrete instance from the factory. It does not know whether
storage is session-based or database-backed.

<?php
namespace App\Controllers;

use Core\Controller;
use App\Models\InventoryRepository;
use App\Models\InventoryRepositoryFactory;

class InventoryController extends Controller
{
    private InventoryRepository $inventory;

    public function __construct()
    {
        // Decide at runtime whether to use the session-backed or
        // DB-backed repository. Controllers and views don't care.
        $this->inventory = InventoryRepositoryFactory::make();
    }

    public function index(): void
    {
        $items = $this->inventory->all();

        $this->render('inventory/index', [
            'items' => $items,
        ]);
    }

    public function show(int $id): void
    {
        $item = $this->inventory->find($id);
        if ($item === null) {
            http_response_code(404);
            echo 'Item not found';
            return;
        }

        $this->render('inventory/show', [
            'item' => $item,
        ]);
    }

    public function store(): void
    {
        $name = trim($_POST['name'] ?? '');
        if ($name === '') {
            $this->render('inventory/index', [
                'items' => $this->inventory->all(),
                'error' => 'Name is required',
            ]);
            return;
        }

        $this->inventory->create($name);
        $this->redirect('/inventory');
    }
}

Views

Finally, two simple views display the inventory list and a single item.
They are deliberately dumb: just HTML + PHP echoing properties.

<?php /** @var App\Models\InventoryItem[] $items */ ?>
<!doctype html>
<html>
<head>
    <meta charset="utf-8">
    <title>Inventory</title>
</head>
<body>
<h1>Inventory for teachingcode.org</h1>

<?php if (!empty($error)): ?>
    <p style="color:red;"><?php echo htmlspecialchars($error, ENT_QUOTES, 'UTF-8'); ?></p>
<?php endif; ?>

<ul>
    <?php foreach ($items as $item): ?>
        <li>
            <?php echo htmlspecialchars($item->name, ENT_QUOTES, 'UTF-8'); ?>
            (<a href="/inventory/show/<?php echo (int)$item->id; ?>">details</a>)
        </li>
    <?php endforeach; ?>
</ul>

<form method="post" action="/inventory">
    <label>
        New item name:
        <input type="text" name="name">
    </label>
    <button type="submit">Add</button>
</form>
</body>
</html>

<?php /** @var App\Models\InventoryItem $item */ ?>
<!doctype html>
<html>
<head>
    <meta charset="utf-8">
    <title>Item: <?php echo htmlspecialchars($item->name, ENT_QUOTES, 'UTF-8'); ?></title>
</head>
<body>
<h1><?php echo htmlspecialchars($item->name, ENT_QUOTES, 'UTF-8'); ?></h1>

<p>Item ID: <?php echo (int)$item->id; ?></p>

<p><a href="/inventory">Back to list</a></p>
</body>
</html>

Switching from design to DB mode

  • During design, you can rely entirely on the session-backed repository:
    • Config::INVENTORY_STORAGE is 'session'
    • No schema or running database is required.
  • When you are ready to move to a real database:
    1. Create the inventory_items table as shown above.
    2. Set the environment variable INVENTORY_STORAGE=db (or change
      Config::INVENTORY_STORAGE to 'db').
    3. Ensure MySQL credentials in Config match your docker-compose
      service.

Controllers and views do not need to change; only configuration and the
repository implementation determine where data actually lives.

Updated by Khun Josh about 2 months ago · 1 revisions