Intro to MVC: Separating Concerns in Small PHP Projects
Learn MVC in PHP 8.3 with PDO, CSRF, XSS protection, caching, and tests. Build a secure, fast mini app step by step.
Intro to MVC: Separating Concerns in Small PHP Projects
Learn MVC in PHP 8.3 with PDO, CSRF, XSS protection, caching, and tests. Build a secure, fast mini app step by step.

Photo by Ben Griffiths on Unsplash
Model–View–Controller (MVC) helps you keep PHP code clean, testable, and secure — even in small projects. This hands-on guide builds a tiny MVC app on PHP 8.3 with PDO, CSRF protection, XSS-safe views, and file caching, following PSR-12 from start to finish.
Learning Outcomes
- Understand MVC roles and how they map to PHP files and classes
- Build a minimal MVC router and controller layer in PHP 8.3 (PSR-12)
- Use PDO with prepared statements, transactions, and exceptions
- Protect against XSS/CSRF/SQL injection and add robust error handling
- Add simple file-based caching and know where OPcache fits in
- Write and run unit tests (PHPUnit) and perform manual validation
Context & Why It Matters
Spaghetti code happens when “just one more feature” sneaks into your project. MVC breaks a PHP app into Models (data/logic), Views (HTML), and Controllers (request handling), so changes in one area don’t cascade into others. Benefits you’ll feel immediately:
- Security: Centralize escaping and CSRF checks.
- Testability: Models and controllers are unit-test friendly.
- Performance: Add caching in one spot; enable OPcache globally.
- Maintainability: Clear directory boundaries and PSR-12 autoloading.
Step-by-Step Tutorial / Case Study
We’ll build a tiny “Posts” app:
- List posts (cached)
- View a post
- Create a post (with CSRF + validation)
1) Project Layout (ASCII)
micro-mvc/
├─ composer.json
├─ public/
│ └─ index.php
├─ app/
│ ├─ Core/
│ │ ├─ Router.php
│ │ ├─ Controller.php
│ │ ├─ View.php
│ │ ├─ Database.php
│ │ ├─ Config.php
│ │ ├─ Csrf.php
│ │ ├─ Cache.php
│ │ ├─ ErrorHandler.php
│ │ └─ Helpers.php
│ ├─ Controller/
│ │ └─ PostController.php
│ ├─ Model/
│ │ ├─ Post.php
│ │ └─ PostRepository.php
│ └─ View/
│ ├─ layout.php
│ ├─ post/
│ │ ├─ index.php
│ │ ├─ show.php
│ │ └─ create.php
│ └─ errors/
│ ├─ 404.php
│ └─ 500.php
├─ app/data/
│ ├─ schema.sql
│ ├─ seed.sql
│ └─ app.db (created by setup)
├─ var/
│ ├─ cache/
│ └─ logs/
├─ scripts/
│ └─ setup.php
└─ tests/
└─ PostRepositoryTest.php
2) Install & Run
- Requirements: PHP 8.3+, SQLite extension enabled.
- Create project:
mkdir micro-mvc && cd micro-mvc
3. composer.json
{
"name": "acme/micro-mvc",
"type": "project",
"require": {
"php": "^8.3"
},
"autoload": {
"psr-4": {
"App\\": "app/"
},
"files": ["app/Core/Helpers.php"]
},
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"scripts": {
"serve": "php -S localhost:8000 -t public",
"setup": "php scripts/setup.php",
"test": "phpunit --colors=always"
}
}
Then run:
composer install
4. Create folders:
mkdir -p public app/Core app/Controller app/Model app/View/post app/View/errors app/data var/cache var/logs scripts tests
5. Database schema and seed:
app/data/schema.sql
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
body TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_posts_created_at ON posts(created_at);
app/data/seed.sql
BEGIN TRANSACTION;
INSERT INTO posts (title, body) VALUES
('Hello MVC', 'This is your first post.'),
('Security First', 'Always escape output and use CSRF tokens.'),
('PDO Everywhere', 'Prepared statements prevent SQL injection.');
COMMIT;
6. Setup script — scripts/setup.php
<?php
declare(strict_types=1);
$dbPath = __DIR__ . '/../app/data/app.db';
$schema = __DIR__ . '/../app/data/schema.sql';
$seed = __DIR__ . '/../app/data/seed.sql';
@mkdir(__DIR__ . '/../var/cache', 0777, true);
@mkdir(__DIR__ . '/../var/logs', 0777, true);
if (file_exists($dbPath)) {
unlink($dbPath);
}
$pdo = new PDO('sqlite:' . $dbPath);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec(file_get_contents($schema));
$pdo->exec(file_get_contents($seed));
echo "Database initialized at app/data/app.db\n";
- Run:
composer run setup
7. Serve:
composer run serve
- Visit
[http://localhost:8000.](http://localhost:8000.)
Code Walkthroughs (with explanations)
Core: Config, Helpers, Error Handling
**app/Core/Config.php**
<?php
declare(strict_types=1);
namespace App\Core;
final class Config
{
public const APP_ENV = 'dev'; // switch to 'prod' in deployment
public const DB_PATH = __DIR__ . '/../data/app.db';
public const CACHE_DIR = __DIR__ . '/../../var/cache';
public const LOG_FILE = __DIR__ . '/../../var/logs/app.log';
public const CSRF_KEY = 'csrf_token';
}
**app/Core/Helpers.php**
<?php
declare(strict_types=1);
use App\Core\Config;
function e(?string $value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
function redirect(string $location): never
{
header('Location: ' . $location, true, 302);
exit;
}
**app/Core/ErrorHandler.php**
<?php
declare(strict_types=1);
namespace App\Core;
use Throwable;
final class ErrorHandler
{
public static function register(): void
{
set_exception_handler([self::class, 'handle']);
}
public static function handle(Throwable $e): void
{
if (!is_dir(dirname(Config::LOG_FILE))) {
@mkdir(dirname(Config::LOG_FILE), 0777, true);
}
error_log(
sprintf("[%s] %s in %s:%d\n%s\n",
date('c'),
$e->getMessage(),
$e->getFile(),
$e->getLine(),
$e->getTraceAsString()
),
3,
Config::LOG_FILE
);
http_response_code(500);
if (Config::APP_ENV === 'dev') {
echo "<h1>Application Error</h1><pre>" . e($e->getMessage()) . "\n" . e($e->getTraceAsString()) . "</pre>";
} else {
require __DIR__ . '/../View/errors/500.php';
}
}
}
Core: Database, Cache, CSRF, View, Router, Controller
**app/Core/Database.php**
<?php
declare(strict_types=1);
namespace App\Core;
use PDO;
final class Database
{
private static ?PDO $pdo = null;
public static function pdo(): PDO
{
if (self::$pdo === null) {
$dsn = 'sqlite:' . Config::DB_PATH;
self::$pdo = new PDO($dsn, null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
}
return self::$pdo;
}
}
**app/Core/Cache.php**
<?php
declare(strict_types=1);
namespace App\Core;
final class Cache
{
public static function get(string $key, int $ttlSeconds, callable $producer): mixed
{
$file = Config::CACHE_DIR . '/' . sha1($key) . '.phpcache';
if (is_file($file) && (time() - filemtime($file)) < $ttlSeconds) {
return unserialize((string) file_get_contents($file));
}
$value = $producer();
@file_put_contents($file, serialize($value), LOCK_EX);
return $value;
}
public static function clear(): void
{
foreach (glob(Config::CACHE_DIR . '/*.phpcache') ?: [] as $f) {
@unlink($f);
}
}
}
**app/Core/Csrf.php**
<?php
declare(strict_types=1);
namespace App\Core;
final class Csrf
{
public static function token(): string
{
if (empty($_SESSION[Config::CSRF_KEY])) {
$_SESSION[Config::CSRF_KEY] = bin2hex(random_bytes(32));
}
return $_SESSION[Config::CSRF_KEY];
}
public static function validate(?string $token): bool
{
return hash_equals($_SESSION[Config::CSRF_KEY] ?? '', (string) $token);
}
}
**app/Core/View.php**
<?php
declare(strict_types=1);
namespace App\Core;
final class View
{
public static function render(string $template, array $data = []): void
{
extract($data, EXTR_OVERWRITE);
$templatePath = __DIR__ . '/../View/' . $template . '.php';
if (!is_file($templatePath)) {
http_response_code(404);
require __DIR__ . '/../View/errors/404.php';
return;
}
require __DIR__ . '/../View/layout.php';
}
}
**app/Core/Controller.php**
<?php
declare(strict_types=1);
namespace App\Core;
abstract class Controller
{
protected function view(string $template, array $data = []): void
{
View::render($template, $data);
}
}
**app/Core/Router.php**
<?php
declare(strict_types=1);
namespace App\Core;
final class Router
{
/** @var array<array{method:string,pattern:string,handler:callable}> */
private array $routes = [];
public function add(string $method, string $pattern, callable $handler): void
{
$this->routes[] = compact('method', 'pattern', 'handler');
}
public function dispatch(string $method, string $uri): void
{
$path = parse_url($uri, PHP_URL_PATH) ?: '/';
foreach ($this->routes as $r) {
if ($r['method'] !== $method) {
continue;
}
$regex = '@^' . preg_replace('@\{(\w+)\}@', '(?P<$1>[^/]+)', $r['pattern']) . '$@';
if (preg_match($regex, (string) $path, $matches)) {
$params = array_filter($matches, 'is_string', ARRAY_FILTER_USE_KEY);
($r['handler'])(...array_values($params));
return;
}
}
http_response_code(404);
require __DIR__ . '/../View/errors/404.php';
}
}
Model Layer
**app/Model/Post.php** (entity)
<?php
declare(strict_types=1);
namespace App\Model;
final class Post
{
public function __construct(
public ?int $id,
public string $title,
public string $body,
public string $createdAt,
) {}
}
**app/Model/PostRepository.php**
<?php
declare(strict_types=1);
namespace App.Model;
use App\Core\Database;
use PDO;
final class PostRepository
{
public function all(): array
{
$stmt = Database::pdo()->prepare('SELECT id, title, body, created_at FROM posts ORDER BY created_at DESC');
$stmt->execute();
$rows = $stmt->fetchAll();
return array_map(fn($r) => new Post((int)$r['id'], $r['title'], $r['body'], $r['created_at']), $rows);
}
public function find(int $id): ?Post
{
$stmt = Database::pdo()->prepare('SELECT id, title, body, created_at FROM posts WHERE id = :id');
$stmt->execute([':id' => $id]);
$r = $stmt->fetch();
return $r ? new Post((int)$r['id'], $r['title'], $r['body'], $r['created_at']) : null;
}
public function create(string $title, string $body): int
{
$pdo = Database::pdo();
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare('INSERT INTO posts (title, body) VALUES (:title, :body)');
$stmt->execute([':title' => $title, ':body' => $body]);
$id = (int) $pdo->lastInsertId();
$pdo->commit();
return $id;
} catch (\Throwable $e) {
$pdo->rollBack();
throw $e;
}
}
}
Controller Layer
**app/Controller/PostController.php**
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Core\Controller;
use App\Core\Cache;
use App\Core\Csrf;
use App\Model\PostRepository;
final class PostController extends Controller
{
public function __construct(private PostRepository $repo) {}
public function index(): void
{
$posts = Cache::get('posts_index', 10, fn() => $this->repo->all());
$this->view('post/index', ['posts' => $posts]);
}
public function show(string $id): void
{
$post = $this->repo->find((int)$id);
if (!$post) {
http_response_code(404);
$this->view('errors/404');
return;
}
$this->view('post/show', ['post' => $post]);
}
public function create(): void
{
$this->view('post/create', ['csrf' => Csrf::token(), 'errors' => []]);
}
public function store(): void
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
return;
}
$token = $_POST['csrf'] ?? null;
if (!Csrf::validate($token)) {
http_response_code(400);
$this->view('post/create', ['csrf' => Csrf::token(), 'errors' => ['CSRF token mismatch.']]);
return;
}
$title = trim((string)($_POST['title'] ?? ''));
$body = trim((string)($_POST['body'] ?? ''));
$errors = [];
if ($title === '' || mb_strlen($title) > 120) {
$errors[] = 'Title is required (max 120 chars).';
}
if ($body === '' || mb_strlen($body) > 5000) {
$errors[] = 'Body is required (max 5000 chars).';
}
if ($errors) {
$this->view('post/create', ['csrf' => Csrf::token(), 'errors' => $errors, 'title' => $title, 'body' => $body]);
return;
}
$id = $this->repo->create($title, $body);
Cache::clear(); // invalidate list cache
redirect('/posts/' . $id);
}
}
View Layer
Layout — app/View/layout.php
<?php /** @var string $templatePath */ ?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title><?= e($title ?? 'Mini MVC') ?></title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="X-Content-Type-Options" content="nosniff">
</head>
<body>
<header>
<h1><a href="/">Mini MVC</a></h1>
<nav><a href="/">Home</a> | <a href="/posts/create">New Post</a></nav>
<hr>
</header>
<main>
<?php require $templatePath; ?>
</main>
<footer><hr><small>PHP 8.3 MVC demo</small></footer>
</body>
</html>
Index — app/View/post/index.php
<?php
/** @var \App\Model\Post[] $posts */
$title = 'Posts';
$templatePath = __FILE__;
?>
<?php foreach ($posts as $p): ?>
<article>
<h2><a href="/posts/<?= (int)$p->id ?>"><?= e($p->title) ?></a></h2>
<p><?= nl2br(e(mb_strimwidth($p->body, 0, 180, '…'))) ?></p>
<small>Published: <?= e($p->createdAt) ?></small>
</article>
<hr>
<?php endforeach; ?>
Show — app/View/post/show.php
<?php
/** @var \App\Model\Post $post */
$title = e($post->title);
$templatePath = __FILE__;
?>
<article>
<h2><?= e($post->title) ?></h2>
<p><?= nl2br(e($post->body)) ?></p>
<small>Published: <?= e($post->createdAt) ?></small>
</article>
Create — app/View/post/create.php
<?php
$title = 'Create Post';
$templatePath = __FILE__;
$errors = $errors ?? [];
?>
<h2>Create Post</h2>
<?php if ($errors): ?>
<div role="alert">
<strong>Fix the following:</strong>
<ul><?php foreach ($errors as $err): ?><li><?= e($err) ?></li><?php endforeach; ?></ul>
</div>
<?php endif; ?>
<form method="post" action="/posts">
<input type="hidden" name="csrf" value="<?= e($csrf) ?>">
<div>
<label>Title
<input name="title" maxlength="120" required value="<?= e($title ?? '') ?>">
</label>
</div>
<div>
<label>Body
<textarea name="body" rows="8" maxlength="5000" required><?= e($body ?? '') ?></textarea>
</label>
</div>
<button type="submit">Save</button>
</form>
Errors — app/View/errors/404.php
<?php $title = 'Not Found'; $templatePath = __FILE__; ?>
<h2>404 — Not Found</h2>
<p>The requested resource was not found.</p>
Errors — app/View/errors/500.php
<?php $title = 'Server Error'; $templatePath = __FILE__; ?>
<h2>Something went wrong</h2>
<p>Please try again later.</p>
Public Front Controller (with routing)
**public/index.php**
<?php
declare(strict_types=1);
use App\Core\ErrorHandler;
use App\Core\Router;
use App\Controller\PostController;
use App\Model\PostRepository;
require __DIR__ . '/../vendor/autoload.php';
session_start();
ErrorHandler::register();
// [HTTP] -> Router -> Controller -> Repository/DB -> View -> [HTML]
$router = new Router();
$router->add('GET', '/', function () {
(new PostController(new PostRepository()))->index();
});
$router->add('GET', '/posts/{id}', function (string $id) {
(new PostController(new PostRepository()))->show($id);
});
$router->add('GET', '/posts/create', function () {
(new PostController(new PostRepository()))->create();
});
$router->add('POST', '/posts', function () {
(new PostController(new PostRepository()))->store();
});
$router->dispatch($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI']);
Validation & Testing Instructions (unit + manual)
Unit Tests (PHPUnit)
**tests/PostRepositoryTest.php**
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
use App\Model\PostRepository;
use App\Core\Database;
final class PostRepositoryTest extends TestCase
{
protected function setUp(): void
{
// Swap Database::pdo() by reflection to use an in-memory DB for tests
$ref = new ReflectionClass(Database::class);
$prop = $ref->getProperty('pdo');
$prop->setAccessible(true);
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec("CREATE TABLE posts (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, body TEXT, created_at TEXT DEFAULT (datetime('now')))");
$prop->setValue(null, $pdo);
}
public function testCreateAndFind(): void
{
$repo = new PostRepository();
$id = $repo->create('Test', 'Body');
$found = $repo->find($id);
$this->assertNotNull($found);
$this->assertSame('Test', $found->title);
}
public function testAllReturnsArray(): void
{
$repo = new PostRepository();
$repo->create('A', 'B');
$this->assertIsArray($repo->all());
}
}
Run tests:
composer run test
Manual Testing
- List posts: open
/and verify seeded posts. - Create post: go to
/posts/create, submit form; verify redirect to/posts/{id}. - CSRF check: open form, remove hidden
csrfvalue in dev tools, submit → expect error message. - XSS check: try title
<script>alert(1)</script>→ page should show escaped text, not run JS. - Caching: after creating a post, index may be cached for up to 10s. You can lower TTL or clear cache with a code call to
Cache::clear().
Security & Performance Notes
Security Checklist
- XSS: Always wrap dynamic output with
e()(htmlspecialchars). - CSRF: Generate per-session token; validate on POST.
- SQL Injection: Only use PDO prepared statements with bound parameters.
- Sessions: Use
session.cookie_httponly=1,session.cookie_secure=1(HTTPS). - Error disclosure: In production, show friendly error pages; log details to files.
- Headers: Consider
X-Frame-Options: DENY,Referrer-Policy: no-referrer-when-downgrade, and a basic CSP that allows your own scripts/styles. - Input validation: Validate length and type on all inputs (server-side).
Performance Notes
- OPcache: Enable PHP OPcache in production (
opcache.enable=1,opcache.validate_timestamps=0in immutable deployments). This speeds up class loading and parsing. - File Cache: The
Cacheclass caches list results (cheap and effective). In bigger apps, consider Redis or Symfony Cache. - DB: Use indexed columns (we indexed
created_at); avoid N+1 queries. - HTTP: Serve static assets via a web server/CDN with long
Cache-Control. - Autoload: Composer’s optimized autoloader:
composer dump-autoload -ofor production.
Troubleshooting / Common Pitfalls & How to Fix
“Class not found”
- Cause: PSR-4 autoload path mismatch.
- Fix: Check namespaces map in
composer.json, runcomposer dump-autoload.
“could not find driver” (PDO)
- Cause: SQLite extension not enabled.
- Fix: Enable
pdo_sqliteinphp.inior installphp8.3-sqlite3.
CSRF token mismatch
- Cause: Missing session or token.
- Fix: Ensure
session_start()is called before output; check hidden input is present.
XSS shows as HTML
- Cause: Missing
e()around output.- Fix: Wrap variables in
e()inside all views.
Cache shows stale posts
- Cause: TTL too long or cache not invalidated.
- Fix: Call
Cache::clear()after mutating operations or lower TTL.
500 error with no details
- Cause: Production mode hides exceptions.
- Fix: Check
var/logs/app.logand switchAPP_ENVtodevlocally.
Conclusion + Next Steps
You now have a clean, testable MVC skeleton tailored for small PHP 8.3 projects with security and performance built-in. Next steps:
- Add edit/delete actions (remember CSRF on DELETE/PUT forms).
- Introduce a request/response abstraction or PSR-7 layer.
- Swap SQLite for MySQL/PostgreSQL by changing the DSN in
Database.php. - Add a template engine and a DI container as your project grows.
TL;DR
MVC separates data (Model), UI (View), and request logic (Controller). This guide gave you a runnable PHP 8.3 mini-MVC with PDO, CSRF protection, XSS-safe views, exceptions, and caching. Use it as a base for small apps, tests included.
References (Official Docs)
- PHP: PDO — https://www.php.net/manual/en/book.pdo.php
- PHP: SQLite PDO — https://www.php.net/manual/en/ref.pdo-sqlite.php
- PHP: Error handling — https://www.php.net/manual/en/language.exceptions.php
- PHP OPcache — https://www.php.net/manual/en/book.opcache.php
- PSR-12 (PHP-FIG) — https://www.php-fig.org/psr/psr-12/
- OWASP Cheat Sheets (XSS/CSRF) — https://cheatsheetseries.owasp.org/
메타데이터
- post_id
- 1add2c7fa8d2
- slug
- intro-to-mvc-separating-concerns-in-small-php-projects-1add2c7fa8d2
- url
- https://medium.com/@annxsa/intro-to-mvc-separating-concerns-in-small-php-projects-1add2c7fa8d2
- canonical_url
- https://medium.com/@annxsa/intro-to-mvc-separating-concerns-in-small-php-projects-1add2c7fa8d2
- author_url
- https://medium.com/@annxsa
- status
- ok
- fetched_at
- 2026-06-21 07:44:09