← Back to list

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.

Ann R. · 2025-09-22 19:51 · 58 claps · 9.5 min read paywalled
#php #mvc #psr #pdo #php-development
Open on Medium ↗
Wiki topics: STP · Startups & Venture

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

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

  1. Requirements: PHP 8.3+, SQLite extension enabled.
  2. 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 scriptscripts/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

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

Layoutapp/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>

Indexapp/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; ?>

Showapp/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>

Createapp/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>

Errorsapp/View/errors/404.php

<?php $title = 'Not Found'; $templatePath = __FILE__; ?>
<h2>404 — Not Found</h2>
<p>The requested resource was not found.</p>

Errorsapp/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

  1. List posts: open / and verify seeded posts.
  2. Create post: go to /posts/create, submit form; verify redirect to /posts/{id}.
  3. CSRF check: open form, remove hidden csrf value in dev tools, submit → expect error message.
  4. XSS check: try title <script>alert(1)</script> → page should show escaped text, not run JS.
  5. 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=0 in immutable deployments). This speeds up class loading and parsing.
  • File Cache: The Cache class 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 -o for production.

Troubleshooting / Common Pitfalls & How to Fix

“Class not found”

  • Cause: PSR-4 autoload path mismatch.
  • Fix: Check namespaces map in composer.json, run composer dump-autoload.

“could not find driver” (PDO)

  • Cause: SQLite extension not enabled.
  • Fix: Enable pdo_sqlite in php.ini or install php8.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.log and switch APP_ENV to dev locally.

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)


메타데이터
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