Project

General

Profile

PHP Lightweight MVC » History » Version 1

Khun Josh, 12/30/2025 08:54 AM

1 1 Khun Josh
# PHP Lightweight MVC
2
3
4
# Table of Contents
5
6
1.  [Front controller](#org9c7b4c8)
7
2.  [Bootstrap and autoloading](#orgd97911a)
8
3.  [Core primitives](#orgbc694b2)
9
    1.  [Router](#org85fe152)
10
    2.  [Controller base class](#org02d2927)
11
    3.  [View renderer](#org7067757)
12
    4.  [Database helper](#org0ac8e7d)
13
4.  [App configuration](#orgc8e69ca)
14
5.  [Domain model: Inventory](#org65b2d22)
15
    1.  [InventoryItem](#orgde15914)
16
    2.  [Repository interface](#org5216bbd)
17
    3.  [Session-backed repository (design mode)](#org52f1f8c)
18
    4.  [DB-backed repository (production mode)](#org2e5e208)
19
    5.  [Repository factory and legacy alias](#orge48a85c)
20
6.  [Inventory controller and views](#org7820e2f)
21
    1.  [Controller](#orgb815a5a)
22
    2.  [Views](#org38818cd)
23
7.  [Switching from design to DB mode](#org2b26eb8)
24
25
This document describes the tiny MVC framework used in this directory.
26
It is written as a **literate program**: prose explains the design, and
27
source blocks tangle into the PHP files under `core/`, `app/`, and
28
`public/`. The goal is to keep development easy and make the transition
29
from in-memory prototypes to a real database very clear.
30
31
32
<a id="org9c7b4c8"></a>
33
34
# Front controller
35
36
All web requests enter through a single front controller:
37
38
    <?php
39
    declare(strict_types=1);
40
    
41
    require __DIR__ . '/../core/bootstrap.php';
42
    
43
    use Core\Router;
44
    use App\Config;
45
    
46
    $router = new Router(Config::BASE_URL);
47
    
48
    $router->get('/', 'InventoryController@index');
49
    $router->get('/inventory', 'InventoryController@index');
50
    $router->get('/inventory/show/{id}', 'InventoryController@show');
51
    $router->post('/inventory', 'InventoryController@store');
52
    
53
    $router->dispatch($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI']);
54
55
56
<a id="orgd97911a"></a>
57
58
# Bootstrap and autoloading
59
60
The bootstrap file sets error reporting, wires up PSR-4-style autoloading
61
for `Core\\` and `App\\` namespaces, starts a PHP session for
62
in-memory development, and conditionally bootstraps the database layer
63
when we switch to DB-backed storage.
64
65
    <?php
66
    declare(strict_types=1);
67
    
68
    error_reporting(E_ALL);
69
    ini_set('display_errors', '1');
70
    
71
    // Simple PSR-4-style autoloader for Core and App namespaces.
72
    spl_autoload_register(function (string $class): void {
73
        $prefixes = [
74
            'Core\\' => __DIR__ . '/',
75
            'App\\'  => __DIR__ . '/../app/',
76
        ];
77
    
78
        foreach ($prefixes as $prefix => $baseDir) {
79
            $len = strlen($prefix);
80
            if (strncmp($class, $prefix, $len) !== 0) {
81
                continue;
82
            }
83
    
84
            $relative = substr($class, $len);
85
            $file = $baseDir . str_replace('\\', '/', $relative) . '.php';
86
    
87
            if (is_file($file)) {
88
                require $file;
89
                return;
90
            }
91
        }
92
    });
93
    
94
    // Simple session-based state so in-memory models can persist
95
    // across requests during the design phase.
96
    if (session_status() !== PHP_SESSION_ACTIVE) {
97
        session_start();
98
    }
99
    
100
    // If inventory storage is configured as 'db', bootstrap the database
101
    // connection once for the request. When using 'session' storage, the
102
    // DB layer is completely skipped.
103
    use App\Config;
104
    use Core\Database;
105
    
106
    $storageMode = getenv('INVENTORY_STORAGE') ?: Config::INVENTORY_STORAGE;
107
    if ($storageMode === 'db') {
108
        Database::init(Config::dbDsn(), Config::DB_USER, Config::DB_PASSWORD);
109
    }
110
111
112
<a id="orgbc694b2"></a>
113
114
# Core primitives
115
116
117
<a id="org85fe152"></a>
118
119
## Router
120
121
The `Router` maps HTTP methods and paths to controller actions.
122
123
    <?php
124
    namespace Core;
125
    
126
    class Router
127
    {
128
        private string $baseUrl;
129
        private array $routes = [
130
            'GET'  => [],
131
            'POST' => [],
132
        ];
133
    
134
        public function __construct(string $baseUrl = '')
135
        {
136
            $this->baseUrl = rtrim($baseUrl, '/');
137
        }
138
    
139
        public function get(string $path, string $handler): void
140
        {
141
            $this->addRoute('GET', $path, $handler);
142
        }
143
    
144
        public function post(string $path, string $handler): void
145
        {
146
            $this->addRoute('POST', $path, $handler);
147
        }
148
    
149
        private function addRoute(string $method, string $path, string $handler): void
150
        {
151
            $this->routes[$method][] = [
152
                'pattern' => $this->compilePath($path),
153
                'handler' => $handler,
154
                'raw'     => $path,
155
            ];
156
        }
157
    
158
        private function compilePath(string $path): string
159
        {
160
            $regex = preg_replace('#\\{([a-zA-Z_][a-zA-Z0-9_]*)\\}#', '(?P<$1>[^/]+)', $path);
161
            return '#^' . rtrim($regex, '/') . '/?$#';
162
        }
163
    
164
        public function dispatch(string $method, string $uri): void
165
        {
166
            $path = parse_url($uri, PHP_URL_PATH) ?? '/';
167
            $path = preg_replace('#^' . preg_quote($this->baseUrl, '#') . '#', '', $path) ?: '/';
168
    
169
            foreach ($this->routes[$method] ?? [] as $route) {
170
                if (preg_match($route['pattern'], $path, $matches)) {
171
                    [$controllerName, $action] = explode('@', $route['handler']);
172
    
173
                    $controllerClass = 'App\\Controllers\\' . $controllerName;
174
                    if (!class_exists($controllerClass)) {
175
                        http_response_code(500);
176
                        echo "Controller {$controllerClass} not found.";
177
                        return;
178
                    }
179
    
180
                    $controller = new $controllerClass();
181
                    if (!method_exists($controller, $action)) {
182
                        http_response_code(500);
183
                        echo "Action {$action} not found on controller {$controllerClass}.";
184
                        return;
185
                    }
186
    
187
                    $params = array_filter(
188
                        $matches,
189
                        fn($key) => !is_int($key),
190
                        ARRAY_FILTER_USE_KEY
191
                    );
192
    
193
                    call_user_func_array([$controller, $action], $params);
194
                    return;
195
                }
196
            }
197
    
198
            http_response_code(404);
199
            echo "404 Not Found";
200
        }
201
    }
202
203
204
<a id="org02d2927"></a>
205
206
## Controller base class
207
208
Controllers use a thin base class that knows how to render views and
209
redirect.
210
211
    <?php
212
    namespace Core;
213
    
214
    class Controller
215
    {
216
        protected function render(string $view, array $data = []): void
217
        {
218
            View::render($view, $data);
219
        }
220
    
221
        protected function redirect(string $url): void
222
        {
223
            header("Location: {$url}");
224
            exit;
225
        }
226
    }
227
228
229
<a id="org7067757"></a>
230
231
## View renderer
232
233
Views are plain PHP templates under `app/Views`. The view renderer just
234
finds the file and exposes the data array as local variables.
235
236
    <?php
237
    namespace Core;
238
    
239
    class View
240
    {
241
        public static string $basePath = __DIR__ . '/../app/Views/';
242
    
243
        public static function render(string $view, array $data = []): void
244
        {
245
            $file = self::$basePath . $view . '.php';
246
    
247
            if (!is_file($file)) {
248
                http_response_code(500);
249
                echo "View {$view} not found.";
250
                return;
251
            }
252
    
253
            extract($data, EXTR_SKIP);
254
            require $file;
255
        }
256
    }
257
258
259
<a id="org0ac8e7d"></a>
260
261
## Database helper
262
263
The DB helper is a light wrapper around PDO. It is only initialized when
264
we choose DB-backed storage.
265
266
    <?php
267
    namespace Core;
268
    
269
    use PDO;
270
    use PDOException;
271
    
272
    class Database
273
    {
274
        private static ?PDO $pdo = null;
275
    
276
        public static function init(string $dsn, string $user, string $password): void
277
        {
278
            if (self::$pdo !== null) {
279
                return;
280
            }
281
    
282
            try {
283
                self::$pdo = new PDO($dsn, $user, $password, [
284
                    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
285
                    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
286
                ]);
287
            } catch (PDOException $e) {
288
                http_response_code(500);
289
                echo 'DB connection error: ' . htmlspecialchars($e->getMessage());
290
                exit;
291
            }
292
        }
293
    
294
        public static function pdo(): PDO
295
        {
296
            if (self::$pdo === null) {
297
                throw new \RuntimeException('Database::init() must be called first.');
298
            }
299
            return self::$pdo;
300
        }
301
    }
302
303
304
<a id="orgc8e69ca"></a>
305
306
# App configuration
307
308
The `Config` class centralizes app configuration, including how
309
inventory data is stored: purely in-memory/session for design, or in a
310
real database.
311
312
    <?php
313
    namespace App;
314
    
315
    /**
316
     * App-level configuration.
317
     */
318
    final class Config
319
    {
320
        /**
321
         * Base URL of the app (e.g. when deployed under a subdirectory).
322
         */
323
        public const BASE_URL = '';
324
    
325
        /**
326
         * Where inventory data is stored.
327
         * - 'session'  => in PHP session (design/dev mode, no DB required)
328
         * - 'db'       => MySQL via Core\\Database
329
         */
330
        public const INVENTORY_STORAGE = 'session';
331
    
332
        // --- Database configuration (used only when INVENTORY_STORAGE = 'db') ---
333
    
334
        public const DB_HOST = '127.0.0.1';
335
        public const DB_NAME = 'local';
336
        public const DB_USER = 'mysql';
337
        public const DB_PASSWORD = 'mysql';
338
    
339
        public static function dbDsn(): string
340
        {
341
            $host = getenv('DB_HOST') ?: self::DB_HOST;
342
            $db   = getenv('DB_NAME') ?: self::DB_NAME;
343
            return "mysql:host={$host};dbname={$db};charset=utf8mb4";
344
        }
345
    }
346
347
348
<a id="org65b2d22"></a>
349
350
# Domain model: Inventory
351
352
353
<a id="orgde15914"></a>
354
355
## InventoryItem
356
357
An `InventoryItem` is the core domain object.
358
359
    <?php
360
    namespace App\Models;
361
    
362
    class InventoryItem
363
    {
364
        public ?int $id;
365
        public string $name;
366
    
367
        public function __construct(?int $id, string $name)
368
        {
369
            $this->id   = $id;
370
            $this->name = $name;
371
        }
372
    }
373
374
375
<a id="org5216bbd"></a>
376
377
## Repository interface
378
379
We access inventory data through an `InventoryRepository` interface. The
380
rest of the app does not care whether data lives in memory or in a DB.
381
382
    <?php
383
    namespace App\Models;
384
    
385
    interface InventoryRepository
386
    {
387
        /** @return InventoryItem[] */
388
        public function all(): array;
389
    
390
        public function find(int $id): ?InventoryItem;
391
    
392
        public function create(string $name): InventoryItem;
393
    }
394
395
396
<a id="org52f1f8c"></a>
397
398
## Session-backed repository (design mode)
399
400
During the design phase we keep everything in PHP session state. This
401
requires no schema and is very forgiving.
402
403
    <?php
404
    namespace App\Models;
405
    
406
    /**
407
     * Inventory repository backed by PHP session data.
408
     *
409
     * This keeps development fluid: no real DB is required during
410
     * the design phase, but items persist across HTTP requests for
411
     * a single browser session. Later we can swap this for a
412
     * DB-backed implementation without touching controllers/views.
413
     */
414
    class SessionInventoryRepository implements InventoryRepository
415
    {
416
        private const SESSION_ITEMS_KEY = 'inventory_items';
417
        private const SESSION_NEXT_ID_KEY = 'inventory_next_id';
418
    
419
        public function __construct()
420
        {
421
            // Seed with a sample item to make first-run UX nicer.
422
            $items = $this->getStore();
423
            if (empty($items)) {
424
                $this->create('Sample item');
425
            }
426
        }
427
    
428
        /**
429
         * @return InventoryItem[]
430
         */
431
        public function all(): array
432
        {
433
            return array_values($this->getStore());
434
        }
435
    
436
        public function find(int $id): ?InventoryItem
437
        {
438
            $items = $this->getStore();
439
            return $items[$id] ?? null;
440
        }
441
    
442
        public function create(string $name): InventoryItem
443
        {
444
            $items =& $this->getStore();
445
            $nextId = $this->getNextId();
446
    
447
            $item = new InventoryItem($nextId, $name);
448
            $items[$nextId] = $item;
449
    
450
            $this->setNextId($nextId + 1);
451
    
452
            return $item;
453
        }
454
    
455
        /** @return array<int, InventoryItem> */
456
        private function &getStore(): array
457
        {
458
            if (!isset($_SESSION[self::SESSION_ITEMS_KEY]) || !is_array($_SESSION[self::SESSION_ITEMS_KEY])) {
459
                $_SESSION[self::SESSION_ITEMS_KEY] = [];
460
            }
461
    
462
            // Ensure everything in the store is a valid InventoryItem; if not,
463
            // drop it so views/controllers don't see incomplete objects.
464
            foreach ($_SESSION[self::SESSION_ITEMS_KEY] as $id => $value) {
465
                if (!$value instanceof InventoryItem) {
466
                    unset($_SESSION[self::SESSION_ITEMS_KEY][$id]);
467
                }
468
            }
469
    
470
            /** @var array<int, InventoryItem> $store */
471
            $store =& $_SESSION[self::SESSION_ITEMS_KEY];
472
            return $store;
473
        }
474
    
475
        private function getNextId(): int
476
        {
477
            if (!isset($_SESSION[self::SESSION_NEXT_ID_KEY])) {
478
                $_SESSION[self::SESSION_NEXT_ID_KEY] = 1;
479
            }
480
            return (int)$_SESSION[self::SESSION_NEXT_ID_KEY];
481
        }
482
    
483
        private function setNextId(int $nextId): void
484
        {
485
            $_SESSION[self::SESSION_NEXT_ID_KEY] = $nextId;
486
        }
487
    }
488
489
490
<a id="org2e5e208"></a>
491
492
## DB-backed repository (production mode)
493
494
When you are happy with the object design and ready to introduce a real
495
schema, you can switch to the DB-backed repository. It expects a table
496
like:
497
498
    CREATE TABLE inventory_items (
499
        id   INT AUTO_INCREMENT PRIMARY KEY,
500
        name VARCHAR(255) NOT NULL
501
    );
502
503
The implementation simply translates between DB rows and
504
`InventoryItem` objects.
505
506
    <?php
507
    namespace App\Models;
508
    
509
    use Core\Database;
510
    
511
    /**
512
     * Database-backed inventory repository.
513
     */
514
    class DbInventoryRepository implements InventoryRepository
515
    {
516
        /** @return InventoryItem[] */
517
        public function all(): array
518
        {
519
            $stmt = Database::pdo()->query('SELECT id, name FROM inventory_items ORDER BY id DESC');
520
            $rows = $stmt->fetchAll();
521
    
522
            return array_map(
523
                fn(array $r) => new InventoryItem((int)$r['id'], $r['name']),
524
                $rows
525
            );
526
        }
527
    
528
        public function find(int $id): ?InventoryItem
529
        {
530
            $stmt = Database::pdo()->prepare('SELECT id, name FROM inventory_items WHERE id = :id');
531
            $stmt->execute(['id' => $id]);
532
            $row = $stmt->fetch();
533
    
534
            if (!$row) {
535
                return null;
536
            }
537
    
538
            return new InventoryItem((int)$row['id'], $row['name']);
539
        }
540
    
541
        public function create(string $name): InventoryItem
542
        {
543
            $stmt = Database::pdo()->prepare('INSERT INTO inventory_items (name) VALUES (:name)');
544
            $stmt->execute(['name' => $name]);
545
    
546
            $id = (int)Database::pdo()->lastInsertId();
547
            return new InventoryItem($id, $name);
548
        }
549
    }
550
551
552
<a id="orge48a85c"></a>
553
554
## Repository factory and legacy alias
555
556
A tiny factory chooses the correct repository implementation based on
557
configuration. There is also a legacy `Inventory` class kept as a thin
558
alias for backwards compatibility.
559
560
    <?php
561
    namespace App\Models;
562
    
563
    use App\Config;
564
    
565
    final class InventoryRepositoryFactory
566
    {
567
        public static function make(): InventoryRepository
568
        {
569
            $mode = getenv('INVENTORY_STORAGE') ?: Config::INVENTORY_STORAGE;
570
    
571
            return $mode === 'db'
572
                ? new DbInventoryRepository()
573
                : new SessionInventoryRepository();
574
        }
575
    }
576
577
    <?php
578
    namespace App\Models;
579
    
580
    /**
581
     * Legacy alias kept for backwards compatibility.
582
     *
583
     * New code should type-hint against InventoryRepository and obtain
584
     * an instance via InventoryRepositoryFactory.
585
     */
586
    class Inventory extends SessionInventoryRepository
587
    {
588
    }
589
590
591
<a id="org7820e2f"></a>
592
593
# Inventory controller and views
594
595
596
<a id="orgb815a5a"></a>
597
598
## Controller
599
600
The controller depends only on the `InventoryRepository` interface and
601
obtains a concrete instance from the factory. It does not know whether
602
storage is session-based or database-backed.
603
604
    <?php
605
    namespace App\Controllers;
606
    
607
    use Core\Controller;
608
    use App\Models\InventoryRepository;
609
    use App\Models\InventoryRepositoryFactory;
610
    
611
    class InventoryController extends Controller
612
    {
613
        private InventoryRepository $inventory;
614
    
615
        public function __construct()
616
        {
617
            // Decide at runtime whether to use the session-backed or
618
            // DB-backed repository. Controllers and views don't care.
619
            $this->inventory = InventoryRepositoryFactory::make();
620
        }
621
    
622
        public function index(): void
623
        {
624
            $items = $this->inventory->all();
625
    
626
            $this->render('inventory/index', [
627
                'items' => $items,
628
            ]);
629
        }
630
    
631
        public function show(int $id): void
632
        {
633
            $item = $this->inventory->find($id);
634
            if ($item === null) {
635
                http_response_code(404);
636
                echo 'Item not found';
637
                return;
638
            }
639
    
640
            $this->render('inventory/show', [
641
                'item' => $item,
642
            ]);
643
        }
644
    
645
        public function store(): void
646
        {
647
            $name = trim($_POST['name'] ?? '');
648
            if ($name === '') {
649
                $this->render('inventory/index', [
650
                    'items' => $this->inventory->all(),
651
                    'error' => 'Name is required',
652
                ]);
653
                return;
654
            }
655
    
656
            $this->inventory->create($name);
657
            $this->redirect('/inventory');
658
        }
659
    }
660
661
662
<a id="org38818cd"></a>
663
664
## Views
665
666
Finally, two simple views display the inventory list and a single item.
667
They are deliberately dumb: just HTML + PHP echoing properties.
668
669
    <?php /** @var App\Models\InventoryItem[] $items */ ?>
670
    <!doctype html>
671
    <html>
672
    <head>
673
        <meta charset="utf-8">
674
        <title>Inventory</title>
675
    </head>
676
    <body>
677
    <h1>Inventory for teachingcode.org</h1>
678
    
679
    <?php if (!empty($error)): ?>
680
        <p style="color:red;"><?php echo htmlspecialchars($error, ENT_QUOTES, 'UTF-8'); ?></p>
681
    <?php endif; ?>
682
    
683
    <ul>
684
        <?php foreach ($items as $item): ?>
685
            <li>
686
                <?php echo htmlspecialchars($item->name, ENT_QUOTES, 'UTF-8'); ?>
687
                (<a href="/inventory/show/<?php echo (int)$item->id; ?>">details</a>)
688
            </li>
689
        <?php endforeach; ?>
690
    </ul>
691
    
692
    <form method="post" action="/inventory">
693
        <label>
694
            New item name:
695
            <input type="text" name="name">
696
        </label>
697
        <button type="submit">Add</button>
698
    </form>
699
    </body>
700
    </html>
701
702
    <?php /** @var App\Models\InventoryItem $item */ ?>
703
    <!doctype html>
704
    <html>
705
    <head>
706
        <meta charset="utf-8">
707
        <title>Item: <?php echo htmlspecialchars($item->name, ENT_QUOTES, 'UTF-8'); ?></title>
708
    </head>
709
    <body>
710
    <h1><?php echo htmlspecialchars($item->name, ENT_QUOTES, 'UTF-8'); ?></h1>
711
    
712
    <p>Item ID: <?php echo (int)$item->id; ?></p>
713
    
714
    <p><a href="/inventory">Back to list</a></p>
715
    </body>
716
    </html>
717
718
719
<a id="org2b26eb8"></a>
720
721
# Switching from design to DB mode
722
723
-   During design, you can rely entirely on the session-backed repository:
724
    -   `Config::INVENTORY_STORAGE` is `'session'`
725
    -   No schema or running database is required.
726
-   When you are ready to move to a real database:
727
    1.  Create the `inventory_items` table as shown above.
728
    2.  Set the environment variable `INVENTORY_STORAGE=db` (or change
729
        `Config::INVENTORY_STORAGE` to `'db'`).
730
    3.  Ensure MySQL credentials in `Config` match your `docker-compose`
731
        service.
732
733
Controllers and views do not need to change; only configuration and the
734
repository implementation determine where data actually lives.