PHP Lightweight MVC¶
Table of Contents¶
- Front controller
- Bootstrap and autoloading
- Core primitives
- App configuration
- Domain model: Inventory
- Inventory controller and views
- 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_STORAGEis'session' - No schema or running database is required.
-
- When you are ready to move to a real database:
- Create the
inventory_itemstable as shown above. - Set the environment variable
INVENTORY_STORAGE=db(or change
Config::INVENTORY_STORAGEto'db'). - Ensure MySQL credentials in
Configmatch yourdocker-compose
service.
- Create the
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