Build a PHP MVC Architecture

Build a production PHP MVC framework with DI, middleware, and CRUD.

Introduction

30 Second Summary

Every PHP developer eventually hits a wall where framework magic stops helping and starts hiding problems. When a bug surfaces three layers deep in vendor code, the only real fix is understanding what the framework does for you.

In this project, you will build a production-grade MVC architecture from scratch using PHP 8.4, Apache, and MySQL. No framework dependencies, no hidden behavior.

What You'll Build

A fully functional MVC framework kernel with dependency injection, middleware dispatch, routing, and a working article management application proving the architecture handles real-world concerns.

By the end of this project, you'll have:

  • A DI container with autowiring via Reflection that resolves controller dependencies automatically.
  • A middleware pipeline composing session management, CSRF protection, and request processing in stack order.
  • A complete article CRUD application with input validation, secure sessions, and prepared-statement database access.
  • Secret Mission: A content negotiation middleware that returns JSON responses for API clients without duplicating controllers.

Are there any prerequisites?

This is a hard project. You should be comfortable with PHP OOP concepts like interfaces, abstract classes, and the Reflection API.

You also need a working understanding of the HTTP request/response lifecycle, basic MySQL, Composer proficiency, and Apache VirtualHost or htdocs access.

Set Up Project Structure, Apache Config, and Environment

Every production PHP application starts with a carefully organized file system and a locked-down web server configuration. The folder layout determines how classes are loaded, how code is separated from public assets, and how security boundaries are enforced.

In this step, you will scaffold the full directory tree, configure Apache to route all requests through a single front controller, and wire up environment-based configuration using Composer and phpdotenv.

In this step, get ready to:
  • Create the project directory tree and install Composer dependencies.
  • Configure Apache with .htaccess files that route requests and protect source files.
  • Set up environment variables and the front controller entry point.
Create the project directory tree and install dependencies

A production MVC separates publicly accessible files from application source code. Only the public/ folder should be reachable by a browser.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir production-mvc
cd production-mvc
  • Create the full directory tree by running this command:
mkdir -p public src/Core src/Http src/App/Controllers src/App/Models src/App/Middleware src/App/Views config routes database storage/logs storage/cache

What does this structure do?

  • public/ is the only web-accessible folder. Apache serves files from here.
  • src/Core/ holds the framework kernel (Container, Router, Kernel, Pipeline).
  • src/Http/ holds Request, Response, and Middleware contracts.
  • src/App/ holds your application-specific Controllers, Models, Middleware, and Views.
  • storage/ is for runtime data like logs and cache that should never be committed.
  • Confirm the directories exist by running:
ls

You should see config, database, public, routes, src, and storage in the output.

  • Open production-mvc in VS Code by running:
code .
  • Create a new file called composer.json in the project root (the production-mvc folder) with the following content:
{
    "name": "app/production-mvc",
    "description": "Production PHP MVC Architecture",
    "autoload": {
        "psr-4": {
            "App\\": "src/"
        },
        "files": ["src/helpers.php"]
    },
    "require": {
        "php": ">=8.4",
        "vlucas/phpdotenv": "^5.6"
    }
}

What does this configuration do?

  • The psr-4 key maps the App\ namespace to the src/ directory. Any class like App\Core\Container automatically resolves to src/Core/Container.php.
  • The files key tells Composer to load src/helpers.php on every request. This is where global helper functions will live.
  • The vlucas/phpdotenv package reads .env files into $_ENV so you never hardcode credentials.
  • Create the src/helpers.php placeholder file so Composer's autoloader can find it. Run:
echo '<?php

declare(strict_types=1);' > src/helpers.php
  • Install the project dependencies by running:
composer install

You should see Composer downloading vlucas/phpdotenv and generating the autoload files. A vendor/ directory now exists in your project.

Seeing a PHP version error?

The composer.json requires PHP >=8.4. If Composer reports an incompatible version, confirm your CLI is using PHP 8.4 by running php -v. If multiple PHP versions are installed, ensure your PATH resolves to 8.4.

Help me fix my PHP version issue

✔️ Awesome, I've got everything!

Great. Double check you have saved composer.json and that vendor/ exists in your project folder.

ⓧ I'd like to double check the full code

{
    "name": "app/production-mvc",
    "description": "Production PHP MVC Architecture",
    "autoload": {
        "psr-4": {
            "App\\": "src/"
        },
        "files": ["src/helpers.php"]
    },
    "require": {
        "php": ">=8.4",
        "vlucas/phpdotenv": "^5.6"
    }
}
Create the Apache routing configuration

A front controller pattern routes every HTTP request through a single index.php file. Apache's mod_rewrite makes this possible by rewriting URLs that do not match a real file or directory.

  • Create a new file called public/.htaccess with the following content:
<IfModule mod_rewrite.c>
    RewriteEngine On
    RewriteCond %{REQUEST_FILENAME} !-f
    RewriteCond %{REQUEST_FILENAME} !-d
    RewriteRule ^ index.php [L]
</IfModule>

# Disable directory listing
Options -Indexes

# Prevent access to hidden files
<FilesMatch "^\.">
    Require all denied
</FilesMatch>

What does this .htaccess do?

  • RewriteCond %{REQUEST_FILENAME} !-f skips real files (CSS, JS, images) so they are served directly.
  • RewriteCond %{REQUEST_FILENAME} !-d skips real directories.
  • RewriteRule ^ index.php [L] sends everything else to your front controller.
  • Options -Indexes prevents Apache from listing directory contents if no index file exists.
  • The FilesMatch block denies HTTP access to hidden files like .env if they accidentally end up in public/.
  • Save public/.htaccess.

Now you need a root .htaccess that prevents direct access to your application source code.

  • Create a new file called .htaccess in the project root (not inside public/) with the following content:
# Deny all direct access to project root
# Only public/ should be web-accessible via DocumentRoot or alias
<IfModule mod_rewrite.c>
    RewriteEngine On
    RewriteRule ^(.*)$ public/$1 [L]
</IfModule>

What does the root .htaccess do?

If someone navigates to your project root URL instead of the public/ subdirectory, this rule forwards the request into public/ automatically. This protects src/, config/, .env, and vendor/ from direct HTTP access.

  • Save .htaccess.

What about Apache's DocumentRoot?

For production, your Apache VirtualHost should set DocumentRoot to the public/ directory directly. Make sure AllowOverride All is enabled for the directory so .htaccess files are processed.

If you are using XAMPP or MAMP, you can place the project inside htdocs/ and the root .htaccess will handle routing for you.

Rewrite rules not working?

Make sure mod_rewrite is enabled in your Apache config. On Ubuntu/Debian, run sudo a2enmod rewrite and restart Apache. On XAMPP/MAMP, the module is usually enabled by default.

Help me enable mod_rewrite

✔️ Awesome, I've got everything!

Great. You should have both public/.htaccess and a root .htaccess saved.

ⓧ I'd like to double check the full code

Here is public/.htaccess:

<IfModule mod_rewrite.c>
    RewriteEngine On
    RewriteCond %{REQUEST_FILENAME} !-f
    RewriteCond %{REQUEST_FILENAME} !-d
    RewriteRule ^ index.php [L]
</IfModule>

# Disable directory listing
Options -Indexes

# Prevent access to hidden files
<FilesMatch "^\.">
    Require all denied
</FilesMatch>

Here is the root .htaccess:

# Deny all direct access to project root
# Only public/ should be web-accessible via DocumentRoot or alias
<IfModule mod_rewrite.c>
    RewriteEngine On
    RewriteRule ^(.*)$ public/$1 [L]
</IfModule>
Create the environment configuration and front controller

Hardcoded credentials are one of the most common security failures in PHP applications. Environment files keep secrets out of version control and let you change configuration per deployment without touching code.

  • Create a new file called .env.example in the project root with the following content:
APP_ENV=local
APP_DEBUG=true
APP_KEY=change-this-to-a-random-64-char-string

DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=mvc_app
DB_USERNAME=root
DB_PASSWORD=

What do these variables control?

  • APP_ENV and APP_DEBUG switch between detailed error pages (development) and generic messages (production).
  • APP_KEY is a secret used for cryptographic operations. You will set this to a random string.
  • The DB_* variables configure your MySQL connection. Adjust them to match your local setup.
  • Copy .env.example to .env by running:
cp .env.example .env
  • Open .env and update DB_USERNAME and DB_PASSWORD to match your local MySQL credentials if they differ from the defaults.
  • Create a .gitignore file in the project root with the following content:
.env
vendor/
storage/logs/*.log

Why exclude these files?

.env contains secrets that must never be committed. vendor/ is regenerated by composer install and should not bloat your repository. Log files are runtime artifacts.

  • Save .env.example and .gitignore.

Now for the most important file in the project. The front controller is the single entry point for every HTTP request.

  • Create a new file called public/index.php with the following content:
<?php

declare(strict_types=1);

require_once __DIR__ . '/../vendor/autoload.php';

use App\Core\Container;
use App\Core\ExceptionHandler;
use App\Core\Kernel;
use Dotenv\Dotenv;

$dotenv = Dotenv::createImmutable(dirname(__DIR__));
$dotenv->load();

$container = new Container();

$exceptionHandler = new ExceptionHandler(
    debug: $_ENV['APP_DEBUG'] === 'true',
    logPath: dirname(__DIR__) . '/storage/logs/app.log'
);
$exceptionHandler->register();

$container->singleton(Container::class, fn() => $container);

require dirname(__DIR__) . '/config/bindings.php';

$kernel = $container->get(Kernel::class);
$kernel->handle();

What does this front controller do?

  • Loads Composer's autoloader so all App\ classes resolve automatically via PSR-4.
  • Loads .env variables into $_ENV using phpdotenv.
  • Creates the DI Container and registers the ExceptionHandler (you will build both in the next step).
  • Registers the container as a singleton of itself so other services can request it.
  • Loads service bindings from config/bindings.php, resolves the Kernel, and calls handle() to dispatch the request.

This file will not run yet

The front controller references Container, ExceptionHandler, and Kernel classes that you will build in the next steps. You are writing the blueprint now. It will all come together once the kernel classes exist.

  • Save public/index.php.

File not saving in the right place?

Make sure index.php is inside the public/ folder, not the project root. In VS Code's Explorer sidebar, right-click the public folder and select New File to create the file in the correct location.

Help me organize my files correctly

✔️ Awesome, I've got everything!

Great. Double check you have saved public/index.php, .env.example, .env, and .gitignore.

ⓧ I'd like to double check the full code

Here is public/index.php:

<?php

declare(strict_types=1);

require_once __DIR__ . '/../vendor/autoload.php';

use App\Core\Container;
use App\Core\ExceptionHandler;
use App\Core\Kernel;
use Dotenv\Dotenv;

$dotenv = Dotenv::createImmutable(dirname(__DIR__));
$dotenv->load();

$container = new Container();

$exceptionHandler = new ExceptionHandler(
    debug: $_ENV['APP_DEBUG'] === 'true',
    logPath: dirname(__DIR__) . '/storage/logs/app.log'
);
$exceptionHandler->register();

$container->singleton(Container::class, fn() => $container);

require dirname(__DIR__) . '/config/bindings.php';

$kernel = $container->get(Kernel::class);
$kernel->handle();

Here is .env.example:

APP_ENV=local
APP_DEBUG=true
APP_KEY=change-this-to-a-random-64-char-string

DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=mvc_app
DB_USERNAME=root
DB_PASSWORD=

Your project skeleton is in place. Every file lives in a purposeful location, Apache knows how to route requests, and your environment configuration is ready. Next up, you will build the DI Container and Exception Handler that bring this front controller to life.

Build the DI Container and Exception Handler

Your project structure and Apache routing are in place. Now the framework needs a brain. Every time a controller needs a database connection or a session manager, something has to figure out how to create those objects and pass them in.

In this step, you will build the two foundational kernel services. The DI Container resolves class dependencies automatically using PHP's Reflection API. The Exception Handler catches every error and uncaught exception, displaying helpful stack traces in development and safe error pages in production.

In this step, get ready to:
  • Build a DI Container with constructor autowiring via ReflectionClass.
  • Build an environment-aware Exception Handler that logs errors and renders appropriate responses.
  • Wire up the service bindings that register your core database, session, and router services.
Build the DI Container with autowiring

A DI Container is a registry that knows how to create objects and their dependencies. Instead of manually calling new everywhere, you ask the container for a class and it figures out what constructor arguments are needed, resolves them recursively, and hands you a fully assembled object.

  • Create src/Core/Container.php and add the following code:
<?php

declare(strict_types=1);

namespace App\Core;

use Closure;
use ReflectionClass;
use ReflectionNamedType;
use RuntimeException;

class Container
{
    private array $bindings = [];
    private array $singletons = [];
    private array $instances = [];

    public function bind(string $abstract, Closure|string $concrete): void
    {
        $this->bindings[$abstract] = $concrete;
    }

    public function singleton(string $abstract, Closure|string $concrete): void
    {
        $this->singletons[$abstract] = $concrete;
    }

    public function get(string $abstract): object
    {
        // Return cached singleton instance if already resolved
        if (isset($this->instances[$abstract])) {
            return $this->instances[$abstract];
        }

        // Resolve and cache a singleton
        if (isset($this->singletons[$abstract])) {
            $concrete = $this->singletons[$abstract];
            $instance = $concrete instanceof Closure ? $concrete($this) : $this->build($concrete);
            $this->instances[$abstract] = $instance;
            return $instance;
        }

        // Resolve a regular binding (new instance each time)
        if (isset($this->bindings[$abstract])) {
            $concrete = $this->bindings[$abstract];
            return $concrete instanceof Closure ? $concrete($this) : $this->build($concrete);
        }

        // No explicit binding — attempt autowiring
        return $this->build($abstract);
    }

    public function build(string $class): object
    {
        if (!class_exists($class)) {
            throw new RuntimeException("Class {$class} does not exist.");
        }

        $reflector = new ReflectionClass($class);

        if (!$reflector->isInstantiable()) {
            throw new RuntimeException("Class {$class} is not instantiable.");
        }

        $constructor = $reflector->getConstructor();

        // No constructor means no dependencies — just instantiate
        if ($constructor === null) {
            return new $class();
        }

        $dependencies = [];

        foreach ($constructor->getParameters() as $param) {
            $type = $param->getType();

            if ($type instanceof ReflectionNamedType && !$type->isBuiltin()) {
                // Recursively resolve typed class dependencies
                $dependencies[] = $this->get($type->getName());
            } elseif ($param->isDefaultValueAvailable()) {
                $dependencies[] = $param->getDefaultValue();
            } else {
                throw new RuntimeException(
                    "Cannot resolve parameter \${$param->getName()} in {$class}."
                );
            }
        }

        return $reflector->newInstanceArgs($dependencies);
    }
}

What does this code do?

The Container has three resolution strategies, tried in order:

  • Cached singletons: if the class was already resolved as a singleton, return the same instance every time.
  • Explicit bindings: if you registered a closure or class name with bind() or singleton(), use that factory.
  • Autowiring: if no binding exists, use ReflectionClass to inspect the constructor, resolve each typed parameter recursively, and instantiate the class automatically.

This means controllers and services get their dependencies injected without you manually wiring every class.

  • Save src/Core/Container.php.

Seeing a namespace error?

Make sure the namespace App\Core; line exactly matches the PSR-4 mapping in your composer.json. The App\\ prefix maps to src/, so App\Core\Container must live at src/Core/Container.php.

If Composer cannot find the class later, regenerate the autoload map by running:

composer dump-autoload

Help me debug my autoloading issue

✔️ Awesome, I've got everything!

Great. Make sure src/Core/Container.php is saved before moving on.

ⓧ I'd like to double check the full code

<?php

declare(strict_types=1);

namespace App\Core;

use Closure;
use ReflectionClass;
use ReflectionNamedType;
use RuntimeException;

class Container
{
    private array $bindings = [];
    private array $singletons = [];
    private array $instances = [];

    public function bind(string $abstract, Closure|string $concrete): void
    {
        $this->bindings[$abstract] = $concrete;
    }

    public function singleton(string $abstract, Closure|string $concrete): void
    {
        $this->singletons[$abstract] = $concrete;
    }

    public function get(string $abstract): object
    {
        if (isset($this->instances[$abstract])) {
            return $this->instances[$abstract];
        }

        if (isset($this->singletons[$abstract])) {
            $concrete = $this->singletons[$abstract];
            $instance = $concrete instanceof Closure ? $concrete($this) : $this->build($concrete);
            $this->instances[$abstract] = $instance;
            return $instance;
        }

        if (isset($this->bindings[$abstract])) {
            $concrete = $this->bindings[$abstract];
            return $concrete instanceof Closure ? $concrete($this) : $this->build($concrete);
        }

        return $this->build($abstract);
    }

    public function build(string $class): object
    {
        if (!class_exists($class)) {
            throw new RuntimeException("Class {$class} does not exist.");
        }

        $reflector = new ReflectionClass($class);

        if (!$reflector->isInstantiable()) {
            throw new RuntimeException("Class {$class} is not instantiable.");
        }

        $constructor = $reflector->getConstructor();

        if ($constructor === null) {
            return new $class();
        }

        $dependencies = [];

        foreach ($constructor->getParameters() as $param) {
            $type = $param->getType();

            if ($type instanceof ReflectionNamedType && !$type->isBuiltin()) {
                $dependencies[] = $this->get($type->getName());
            } elseif ($param->isDefaultValueAvailable()) {
                $dependencies[] = $param->getDefaultValue();
            } else {
                throw new RuntimeException(
                    "Cannot resolve parameter \${$param->getName()} in {$class}."
                );
            }
        }

        return $reflector->newInstanceArgs($dependencies);
    }
}
Build the exception handler

Uncaught exceptions and PHP errors should never leak raw stack traces to users in production. The Exception Handler converts all PHP errors into exceptions, then decides what to show based on the environment.

  • Create src/Core/ExceptionHandler.php and add the following code:
<?php

declare(strict_types=1);

namespace App\Core;

use ErrorException;
use Throwable;

class ExceptionHandler
{
    public function __construct(
        private readonly bool $debug,
        private readonly string $logPath,
    ) {}

    public function register(): void
    {
        // Convert all PHP errors (warnings, notices) into exceptions
        set_error_handler($this->handleError(...));
        // Catch any uncaught exception
        set_exception_handler($this->handleException(...));
    }

    private function handleError(int $severity, string $message, string $file, int $line): never
    {
        throw new ErrorException($message, 0, $severity, $file, $line);
    }

    private function handleException(Throwable $e): void
    {
        $this->log($e);

        http_response_code(500);

        if ($this->debug) {
            // Dev mode: show full details for debugging
            echo '<h1>' . htmlspecialchars($e->getMessage()) . '</h1>';
            echo '<pre>' . htmlspecialchars($e->getTraceAsString()) . '</pre>';
            echo '<p>' . htmlspecialchars($e->getFile()) . ':' . $e->getLine() . '</p>';
        } else {
            // Prod mode: generic message, no internals exposed
            echo '<h1>500 Internal Server Error</h1>';
            echo '<p>Something went wrong. Please try again later.</p>';
        }
    }

    private function log(Throwable $e): void
    {
        // Create log directory if it doesn't exist
        $dir = dirname($this->logPath);
        if (!is_dir($dir)) {
            mkdir($dir, 0755, true);
        }

        $entry = sprintf(
            "[%s] %s: %s in %s:%d\n%s\n\n",
            date('Y-m-d H:i:s'),
            $e::class,
            $e->getMessage(),
            $e->getFile(),
            $e->getLine(),
            $e->getTraceAsString()
        );

        file_put_contents($this->logPath, $entry, FILE_APPEND | LOCK_EX);
    }
}

What does this code do?

  • set_error_handler converts PHP warnings and notices into ErrorException instances. This prevents silent failures from going unnoticed.
  • set_exception_handler catches any exception that bubbles up uncaught. In debug mode (APP_DEBUG=true), it renders a full stack trace. In production, it shows a generic 500 page.
  • Every exception is logged to storage/logs/app.log with a timestamp, class name, message, and trace. The LOCK_EX flag prevents concurrent writes from corrupting the log file.

The readonly keyword on the constructor properties is a PHP 8.2+ feature that prevents accidental reassignment after construction.

  • Save src/Core/ExceptionHandler.php.

Getting a 'cannot use readonly' error?

The readonly keyword requires PHP 8.2 or later. Confirm your PHP version by running:

php -v

If you see a version below 8.4, make sure Apache is using your PHP 8.4 installation and not an older system PHP.

Help me fix my PHP version issue

✔️ Awesome, I've got everything!

Great. Make sure src/Core/ExceptionHandler.php is saved before moving on.

ⓧ I'd like to double check the full code

<?php

declare(strict_types=1);

namespace App\Core;

use ErrorException;
use Throwable;

class ExceptionHandler
{
    public function __construct(
        private readonly bool $debug,
        private readonly string $logPath,
    ) {}

    public function register(): void
    {
        set_error_handler($this->handleError(...));
        set_exception_handler($this->handleException(...));
    }

    private function handleError(int $severity, string $message, string $file, int $line): never
    {
        throw new ErrorException($message, 0, $severity, $file, $line);
    }

    private function handleException(Throwable $e): void
    {
        $this->log($e);

        http_response_code(500);

        if ($this->debug) {
            echo '<h1>' . htmlspecialchars($e->getMessage()) . '</h1>';
            echo '<pre>' . htmlspecialchars($e->getTraceAsString()) . '</pre>';
            echo '<p>' . htmlspecialchars($e->getFile()) . ':' . $e->getLine() . '</p>';
        } else {
            echo '<h1>500 Internal Server Error</h1>';
            echo '<p>Something went wrong. Please try again later.</p>';
        }
    }

    private function log(Throwable $e): void
    {
        $dir = dirname($this->logPath);
        if (!is_dir($dir)) {
            mkdir($dir, 0755, true);
        }

        $entry = sprintf(
            "[%s] %s: %s in %s:%d\n%s\n\n",
            date('Y-m-d H:i:s'),
            $e::class,
            $e->getMessage(),
            $e->getFile(),
            $e->getLine(),
            $e->getTraceAsString()
        );

        file_put_contents($this->logPath, $entry, FILE_APPEND | LOCK_EX);
    }
}
Register core service bindings

The Container knows how to autowire classes, but some services need manual configuration. A PDO database connection requires credentials from your environment. A Router needs to load route definitions. The bindings file tells the container exactly how to build these special cases.

  • Create config/bindings.php and add the following code:
<?php

declare(strict_types=1);

use App\Core\Container;
use App\Core\Router;
use App\Core\Session;

/** @var Container $container */

// Database connection as a singleton (one connection shared across the app)
$container->singleton(PDO::class, function () {
    return new PDO(
        dsn: sprintf(
            'mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4',
            $_ENV['DB_HOST'],
            $_ENV['DB_PORT'],
            $_ENV['DB_DATABASE']
        ),
        username: $_ENV['DB_USERNAME'],
        password: $_ENV['DB_PASSWORD'],
        options: [
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            PDO::ATTR_EMULATE_PREPARES => false,
        ]
    );
});

// Session manager as a singleton
$container->singleton(Session::class, fn() => new Session());

// Router loads route definitions on first resolution
$container->singleton(Router::class, function () {
    $router = new Router();
    require dirname(__DIR__) . '/routes/web.php';
    return $router;
});

What does this code do?

  • The PDO singleton creates one database connection that the entire app shares. The ERRMODE_EXCEPTION setting makes database errors throw exceptions instead of failing silently. Setting EMULATE_PREPARES to false forces true server-side prepared statements for real SQL injection protection.
  • All three services are registered as singletons, meaning the container creates them once on first request and reuses the same instance afterward.
  • The Router closure loads routes/web.php lazily. Since singletons only resolve when first requested, this file does not need to exist until you actually define routes in a later step.
  • Save config/bindings.php.
  • Open your browser and visit your Apache URL (e.g. http://localhost or wherever your project is served).

You should see a styled error page showing a message like "Class App\Core\Kernel does not exist" along with a full stack trace. This confirms the Exception Handler is working correctly.

Why is this error a good sign?

The error means your Container, ExceptionHandler, and bindings are all wired correctly. The front controller in public/index.php loaded the environment, created the container, registered the exception handler, loaded bindings, and then tried to resolve Kernel::class.

Since the Kernel class does not exist yet, the container threw a RuntimeException. The exception handler caught it and rendered the debug page. Every layer of the request lifecycle is working.

Seeing a blank page or raw PHP error?

  • If you see a blank page, check that APP_DEBUG=true is set in your .env file. Also verify the storage/logs/ directory exists and is writable.
  • If you see a raw PHP fatal error instead of the styled page, the ExceptionHandler itself may have a syntax error. Check your Apache error log for details.
  • If you get a 404 or Apache's default page, confirm your public/.htaccess is in place and that AllowOverride All is enabled in your Apache configuration.

Help me debug why I'm not seeing the exception handler page

✔️ Awesome, I've got everything!

Great. Make sure all three files are saved and you see the Kernel error in your browser.

ⓧ I'd like to double check the full code

<?php

declare(strict_types=1);

use App\Core\Container;
use App\Core\Router;
use App\Core\Session;

/** @var Container $container */

$container->singleton(PDO::class, function () {
    return new PDO(
        dsn: sprintf(
            'mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4',
            $_ENV['DB_HOST'],
            $_ENV['DB_PORT'],
            $_ENV['DB_DATABASE']
        ),
        username: $_ENV['DB_USERNAME'],
        password: $_ENV['DB_PASSWORD'],
        options: [
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            PDO::ATTR_EMULATE_PREPARES => false,
        ]
    );
});

$container->singleton(Session::class, fn() => new Session());

$container->singleton(Router::class, function () {
    $router = new Router();
    require dirname(__DIR__) . '/routes/web.php';
    return $router;
});

Your framework kernel is taking shape. The Container resolves dependencies automatically, and the Exception Handler ensures nothing fails silently. Next up, you will build the Request, Response, and Middleware Pipeline that give your framework a proper HTTP lifecycle.

Build Request, Response, and Middleware Pipeline

Your framework's kernel can resolve dependencies and handle exceptions. But right now, there is no structured way for the application to read an incoming HTTP request or send back a response.

In production applications, controllers should never touch PHP superglobals like $_GET or $_POST directly. They should work with typed value objects that encapsulate HTTP data. In this step, you will build the Request and Response objects that wrap superglobals, then connect them with a middleware pipeline that processes every request through a composable stack of handlers.

In this step, get ready to:
  • Build a Request class that wraps PHP superglobals into typed accessor methods.
  • Build a Response class with static factory methods for HTML, JSON, and redirects.
  • Build a middleware pipeline that chains request processors using closures.
Build the Request class

Every HTTP request carries data across multiple PHP superglobals. The Request class consolidates all of that into a single object with a clean, typed API.

  • Create a new file at src/Http/Request.php and add the following code:
<?php

declare(strict_types=1);

namespace App\Http;

class Request
{
    private array $attributes = [];

    public function __construct(
        private readonly array $server,
        private readonly array $get,
        private readonly array $post,
        private readonly array $cookies,
        private readonly array $files,
        private readonly string $body,
    ) {}

    public static function capture(): static
    {
        return new static(
            server: $_SERVER,
            get: $_GET,
            post: $_POST,
            cookies: $_COOKIE,
            files: $_FILES,
            body: file_get_contents('php://input') ?: '',
        );
    }

    public function method(): string
    {
        return strtoupper($this->server['REQUEST_METHOD'] ?? 'GET');
    }

    public function path(): string
    {
        $uri = $this->server['REQUEST_URI'] ?? '/';
        $path = parse_url($uri, PHP_URL_PATH);
        return rtrim($path, '/') ?: '/';
    }

    public function query(string $key, mixed $default = null): mixed
    {
        return $this->get[$key] ?? $default;
    }

    public function input(string $key, mixed $default = null): mixed
    {
        return $this->post[$key] ?? $this->get[$key] ?? $default;
    }

    public function all(): array
    {
        return array_merge($this->get, $this->post);
    }

    public function header(string $name): ?string
    {
        $key = 'HTTP_' . strtoupper(str_replace('-', '_', $name));
        return $this->server[$key] ?? null;
    }

    public function isPost(): bool
    {
        return $this->method() === 'POST';
    }

    public function isAjax(): bool
    {
        return $this->header('X-Requested-With') === 'XMLHttpRequest';
    }

    public function withAttribute(string $key, mixed $value): static
    {
        $clone = clone $this;
        $clone->attributes[$key] = $value;
        return $clone;
    }

    public function attribute(string $key, mixed $default = null): mixed
    {
        return $this->attributes[$key] ?? $default;
    }
}

What does this code do?

The constructor accepts raw arrays that mirror PHP's superglobals. The static capture() factory reads the real superglobals once and produces an immutable Request instance.

  • method() and path() extract the HTTP verb and clean URI path from the server array.
  • query() reads GET parameters while input() checks POST first, then falls back to GET.
  • header() normalizes header names to match PHP's HTTP_ prefix convention in $_SERVER.
  • withAttribute() clones the request and attaches custom data. Middleware uses this to pass computed values (like a parsed user ID) downstream without mutating the original request.
  • Save src/Http/Request.php.

You will not see a visible change yet. The Request class is consumed by the Pipeline and Kernel you will build shortly.

Seeing a namespace error?

Make sure the file lives at exactly src/Http/Request.php (capital H in Http). PSR-4 autoloading maps App\Http\Request to that exact directory path.

If Composer cannot find the class later, run composer dump-autoload from the project root to regenerate the autoload map.

Help me debug a PSR-4 namespace mismatch in my Request class.

Build the Response class

Controllers in production frameworks never call echo directly. Instead, they return a Response object. The Kernel emits the response at the very end of the lifecycle, preventing header-already-sent errors and making controllers testable.

  • Create a new file at src/Http/Response.php and add the following code:
<?php

declare(strict_types=1);

namespace App\Http;

class Response
{
    public function __construct(
        private string $body = '',
        private int $statusCode = 200,
        private array $headers = [],
    ) {}

    public static function html(string $body, int $status = 200): static
    {
        return new static($body, $status, ['Content-Type' => 'text/html; charset=UTF-8']);
    }

    public static function json(array $data, int $status = 200): static
    {
        return new static(
            json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
            $status,
            ['Content-Type' => 'application/json; charset=UTF-8']
        );
    }

    public static function redirect(string $url, int $status = 302): static
    {
        return new static('', $status, ['Location' => $url]);
    }

    public function withHeader(string $name, string $value): static
    {
        $clone = clone $this;
        $clone->headers[$name] = $value;
        return $clone;
    }

    public function send(): void
    {
        http_response_code($this->statusCode);

        foreach ($this->headers as $name => $value) {
            header("{$name}: {$value}");
        }

        echo $this->body;
    }

    public function getStatusCode(): int
    {
        return $this->statusCode;
    }

    public function getBody(): string
    {
        return $this->body;
    }
}

What does this code do?

The Response class encapsulates the outgoing HTTP message. Static factories build common response types without requiring controllers to remember status codes or headers.

  • Response::html() sets the Content-Type to text/html automatically.
  • Response::json() encodes the data with JSON_THROW_ON_ERROR so encoding failures surface as exceptions instead of silent false returns.
  • send() is the only place in the entire application that calls echo. This single emission point prevents header conflicts.
  • withHeader() returns a clone so the original Response stays immutable. This matches the same clone pattern used by withAttribute() on Request.
  • Save src/Http/Response.php.

You will not see a visible change yet. The Response class will be used once the Kernel is wired up in the next step.

Syntax error on the json factory?

Make sure the json_encode call uses the bitwise OR operator (|) to combine the two flags, not a logical OR (||). The flags are bitmask constants that combine with |.

Help me fix a syntax error in my Response::json() factory method.

Build the middleware interface and pipeline

A middleware pipeline lets you stack reusable request processors (session handling, CSRF protection, content negotiation) without polluting controller logic. Each middleware processes the request, optionally modifies it, and passes it along. The pipeline builds these into a nested closure stack using functional composition.

  • Create the directory src/Http/Middleware/ inside your project.
  • Create a new file at src/Http/Middleware/MiddlewareInterface.php and add the following code:
<?php

declare(strict_types=1);

namespace App\Http\Middleware;

use App\Http\Request;
use App\Http\Response;
use Closure;

interface MiddlewareInterface
{
    public function handle(Request $request, Closure $next): Response;
}

What does this interface define?

Every middleware class implements one method: handle(). It receives the current Request and a $next closure representing the rest of the pipeline. The middleware can modify the request before calling $next, or short-circuit the pipeline entirely by returning a Response without calling $next.

  • Save src/Http/Middleware/MiddlewareInterface.php.
  • Now create the Pipeline class at src/Core/Pipeline.php with the following code:
<?php

declare(strict_types=1);

namespace App\Core;

use App\Http\Request;
use App\Http\Response;
use Closure;

class Pipeline
{
    private array $middlewares = [];

    public function __construct(
        private readonly Container $container,
    ) {}

    public function through(array $middlewares): static
    {
        $this->middlewares = $middlewares;
        return $this;
    }

    public function handle(Request $request, Closure $destination): Response
    {
        $pipeline = array_reduce(
            array_reverse($this->middlewares),
            fn(Closure $next, string $middleware) => function (Request $request) use ($next, $middleware): Response {
                $instance = $this->container->get($middleware);
                return $instance->handle($request, $next);
            },
            $destination
        );

        return $pipeline($request);
    }
}

How does the pipeline work?

The array_reduce call builds a nested set of closures. Each middleware wraps the next one, forming an "onion" structure. The $destination closure (the router dispatch) sits at the center.

  • The middleware array is reversed so that the first middleware listed runs first (outermost layer of the onion).
  • Each middleware is resolved from the DI container, so it gets its own dependencies (like Session) injected automatically.
  • through() returns static for method chaining. The Kernel will call $pipeline->through($middlewares)->handle($request, $destination) in a single fluent chain.

Why array_reduce instead of a loop?

A loop would require managing an index pointer and building the closure stack manually. array_reduce is a functional primitive that naturally expresses "wrap each layer around the previous one." This is the same fundamental pattern used by Slim, Mezzio, and Laravel's middleware pipeline.

  • Save src/Core/Pipeline.php.
  • Confirm your files exist by running:
ls src/Http/ src/Http/Middleware/ src/Core/Pipeline.php

You should see Request.php and Response.php under src/Http/, MiddlewareInterface.php under src/Http/Middleware/, and Pipeline.php confirmed.

Pipeline class not resolving?

Check that the namespace is App\Core (not App\Http). The Pipeline lives in src/Core/ because it is a framework kernel class, not an HTTP-layer class.

Also confirm that MiddlewareInterface.php uses the namespace App\Http\Middleware (matching its directory path exactly).

Help me debug why my Pipeline or MiddlewareInterface isn't being autoloaded.

✔️ Awesome, I've got everything!

Great. Make sure all four files are saved before moving on.

ⓧ I'd like to double check the full code

Here is each file in full for comparison.

<?php

declare(strict_types=1);

namespace App\Http;

class Request
{
    private array $attributes = [];

    public function __construct(
        private readonly array $server,
        private readonly array $get,
        private readonly array $post,
        private readonly array $cookies,
        private readonly array $files,
        private readonly string $body,
    ) {}

    public static function capture(): static
    {
        return new static(
            server: $_SERVER,
            get: $_GET,
            post: $_POST,
            cookies: $_COOKIE,
            files: $_FILES,
            body: file_get_contents('php://input') ?: '',
        );
    }

    public function method(): string
    {
        return strtoupper($this->server['REQUEST_METHOD'] ?? 'GET');
    }

    public function path(): string
    {
        $uri = $this->server['REQUEST_URI'] ?? '/';
        $path = parse_url($uri, PHP_URL_PATH);
        return rtrim($path, '/') ?: '/';
    }

    public function query(string $key, mixed $default = null): mixed
    {
        return $this->get[$key] ?? $default;
    }

    public function input(string $key, mixed $default = null): mixed
    {
        return $this->post[$key] ?? $this->get[$key] ?? $default;
    }

    public function all(): array
    {
        return array_merge($this->get, $this->post);
    }

    public function header(string $name): ?string
    {
        $key = 'HTTP_' . strtoupper(str_replace('-', '_', $name));
        return $this->server[$key] ?? null;
    }

    public function isPost(): bool
    {
        return $this->method() === 'POST';
    }

    public function isAjax(): bool
    {
        return $this->header('X-Requested-With') === 'XMLHttpRequest';
    }

    public function withAttribute(string $key, mixed $value): static
    {
        $clone = clone $this;
        $clone->attributes[$key] = $value;
        return $clone;
    }

    public function attribute(string $key, mixed $default = null): mixed
    {
        return $this->attributes[$key] ?? $default;
    }
}
<?php

declare(strict_types=1);

namespace App\Http;

class Response
{
    public function __construct(
        private string $body = '',
        private int $statusCode = 200,
        private array $headers = [],
    ) {}

    public static function html(string $body, int $status = 200): static
    {
        return new static($body, $status, ['Content-Type' => 'text/html; charset=UTF-8']);
    }

    public static function json(array $data, int $status = 200): static
    {
        return new static(
            json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
            $status,
            ['Content-Type' => 'application/json; charset=UTF-8']
        );
    }

    public static function redirect(string $url, int $status = 302): static
    {
        return new static('', $status, ['Location' => $url]);
    }

    public function withHeader(string $name, string $value): static
    {
        $clone = clone $this;
        $clone->headers[$name] = $value;
        return $clone;
    }

    public function send(): void
    {
        http_response_code($this->statusCode);

        foreach ($this->headers as $name => $value) {
            header("{$name}: {$value}");
        }

        echo $this->body;
    }

    public function getStatusCode(): int
    {
        return $this->statusCode;
    }

    public function getBody(): string
    {
        return $this->body;
    }
}
<?php

declare(strict_types=1);

namespace App\Http\Middleware;

use App\Http\Request;
use App\Http\Response;
use Closure;

interface MiddlewareInterface
{
    public function handle(Request $request, Closure $next): Response;
}
<?php

declare(strict_types=1);

namespace App\Core;

use App\Http\Request;
use App\Http\Response;
use Closure;

class Pipeline
{
    private array $middlewares = [];

    public function __construct(
        private readonly Container $container,
    ) {}

    public function through(array $middlewares): static
    {
        $this->middlewares = $middlewares;
        return $this;
    }

    public function handle(Request $request, Closure $destination): Response
    {
        $pipeline = array_reduce(
            array_reverse($this->middlewares),
            fn(Closure $next, string $middleware) => function (Request $request) use ($next, $middleware): Response {
                $instance = $this->container->get($middleware);
                return $instance->handle($request, $next);
            },
            $destination
        );

        return $pipeline($request);
    }
}

Your HTTP layer is complete. You have typed objects for reading requests and building responses, and a composable pipeline that will thread middleware through every request. Next up, you will build the Router and Kernel that wire these pieces into a working lifecycle.

Build the Router, Kernel, and Base Controller

Your framework has a DI container that can build objects, a middleware pipeline that can process requests, and Request/Response objects that wrap PHP superglobals.

What it cannot do yet is match a URL to a controller action. The Router maps incoming URIs to controller methods, the Kernel orchestrates the full request lifecycle, and the base Controller gives every action a clean way to return responses.

In this step, get ready to:
  • Build a regex-based Router with named parameter extraction and controller resolution through the container.
  • Build the Kernel that ties Request capture, middleware dispatch, and response emission together.
  • Create the abstract base Controller, define article routes, and verify the full boot cycle via Apache.
Build the Router with regex dispatch

The Router needs to accept route registrations like /articles/{id} and match them against incoming requests. When a match is found, it resolves the controller from the DI container and calls the appropriate action method.

  • Create a new file src/Core/Router.php and add the following code:
<?php

declare(strict_types=1);

namespace App\Core;

use App\Http\Request;
use App\Http\Response;

class Router
{
    private array $routes = [];

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

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

    public function put(string $path, array $handler): void
    {
        $this->addRoute('PUT', $path, $handler);
    }

    public function delete(string $path, array $handler): void
    {
        $this->addRoute('DELETE', $path, $handler);
    }

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

    public function dispatch(Request $request, Container $container): Response
    {
        $method = $request->method();
        $path = $request->path();

        foreach ($this->routes as $route) {
            if ($route['method'] !== $method) {
                continue;
            }

            // Convert {param} placeholders into named regex capture groups
            $pattern = preg_replace('#\{(\w+)\}#', '(?P<$1>[^/]+)', $route['path']);
            $pattern = '#^' . $pattern . '$#';

            if (preg_match($pattern, $path, $matches)) {
                // Extract only named captures (string keys), discard numeric keys
                $params = array_filter($matches, fn($key) => is_string($key), ARRAY_FILTER_USE_KEY);

                [$controllerClass, $action] = $route['handler'];

                // Resolve the controller through DI, enabling constructor injection
                $controller = $container->get($controllerClass);
                $controller->setRequest($request);

                return $controller->{$action}(...$params);
            }
        }

        return Response::html('<h1>404 Not Found</h1>', 404);
    }
}

What does this code do?

The public methods get(), post(), put(), and delete() register routes with their HTTP method, URI pattern, and handler (a controller class + action pair).

The dispatch() method does the heavy lifting. It converts {param} placeholders into regex named capture groups, matches the current path, then resolves the controller through the container so it gets constructor injection for free.

If no route matches, it returns a 404 Response. No exceptions thrown, no side effects. The router is a pure dispatcher.

  • Save src/Core/Router.php.
Build the Kernel and middleware config

The Kernel is the single entry point that orchestrates the entire HTTP lifecycle. It captures the request, loads middleware, pushes everything through the pipeline, and emits the final response.

  • Create a new file src/Core/Kernel.php and add the following code:
<?php

declare(strict_types=1);

namespace App\Core;

use App\Http\Request;
use App\Http\Response;

class Kernel
{
    public function __construct(
        private readonly Container $container,
        private readonly Router $router,
        private readonly Pipeline $pipeline,
    ) {}

    public function handle(): void
    {
        // Capture the current HTTP request from PHP superglobals
        $request = Request::capture();

        // Load the middleware stack from config
        $middlewares = require dirname(__DIR__, 2) . '/config/middleware.php';

        // Run through middleware pipeline with router dispatch as the innermost handler
        $response = $this->pipeline
            ->through($middlewares)
            ->handle($request, fn(Request $req): Response => $this->router->dispatch($req, $this->container));

        // Emit the final response to the client
        $response->send();
    }
}

What does this code do?

The Kernel receives its three dependencies (Container, Router, Pipeline) via constructor injection from the DI container. It never creates them itself.

The handle() method captures the Request, loads the middleware class list, then passes the request through the pipeline. The router dispatch is wrapped in a closure and passed as the final destination. After all middleware and the router run, the response is emitted.

  • Save src/Core/Kernel.php.

The Kernel loads middleware from a config file that does not exist yet. You need to create it now so the application can boot.

  • Create a new file config/middleware.php and add the following code:
<?php

declare(strict_types=1);

// Global middleware stack - processed on every request in order
return [
    App\App\Middleware\SessionMiddleware::class,
    App\App\Middleware\CsrfMiddleware::class,
];

What does this file do?

This file returns an array of middleware class names. The Kernel loads this array and passes it to the Pipeline. Each class will be resolved through the DI container and executed in order.

The actual SessionMiddleware and CsrfMiddleware classes do not exist yet. You will build them in a later step. For now, having them listed here will cause an error if the pipeline tries to resolve them. You will handle this during testing.

  • Save config/middleware.php.

Seeing a class not found error?

If you try to load the app now, the Pipeline will try to resolve SessionMiddleware and fail because that class does not exist yet. This is expected. You will either build the middleware classes before testing, or temporarily return an empty array from config/middleware.php to test the router in isolation.

help me debug a class not found error when booting my PHP MVC Kernel

Create the base Controller and define routes

Every controller in your app will extend an abstract base class. This base provides a view() method that renders templates into Response objects using output buffering, keeping controllers free of any echo calls.

  • Create the directory src/App/Controllers/ if it does not exist, then create src/App/Controllers/Controller.php with the following code:
<?php

declare(strict_types=1);

namespace App\App\Controllers;

use App\Http\Request;
use App\Http\Response;

abstract class Controller
{
    protected Request $request;

    public function setRequest(Request $request): void
    {
        $this->request = $request;
    }

    protected function view(string $template, array $data = [], int $status = 200): Response
    {
        // If the request wants JSON (set by content negotiation middleware), return JSON directly
        if ($this->request->attribute('wantsJson')) {
            return Response::json($data, $status);
        }

        // Extract data array into local variables for the template
        extract($data);

        // Render the specific template into $content
        ob_start();
        require dirname(__DIR__) . '/Views/' . $template . '.php';
        $content = ob_get_clean();

        // Wrap $content inside the layout
        ob_start();
        require dirname(__DIR__) . '/Views/layout.php';
        $html = ob_get_clean();

        return Response::html($html, $status);
    }

    protected function redirect(string $url): Response
    {
        return Response::redirect($url);
    }
}

What does this code do?

The setRequest() method is called by the Router after resolving the controller. This gives every action access to the current request without polluting the constructor signature.

The view() method uses PHP's output buffering (ob_start() / ob_get_clean()) to capture template output into a string. It renders the specific template first, then wraps it in a layout. The result is a Response object with properly structured HTML.

The wantsJson check is a hook for the Secret Mission content negotiation middleware. For now it always returns HTML.

  • Save src/App/Controllers/Controller.php.

Now define the route map so the Router knows which controllers handle which URLs.

  • Create the file routes/web.php and add the following code:
<?php

declare(strict_types=1);

use App\App\Controllers\ArticleController;

/** @var App\Core\Router $router */

// Article CRUD routes
$router->get('/', [ArticleController::class, 'index']);
$router->get('/articles', [ArticleController::class, 'index']);
$router->get('/articles/create', [ArticleController::class, 'create']);
$router->post('/articles', [ArticleController::class, 'store']);
$router->get('/articles/{id}', [ArticleController::class, 'show']);
$router->get('/articles/{id}/edit', [ArticleController::class, 'edit']);
$router->post('/articles/{id}', [ArticleController::class, 'update']);
$router->post('/articles/{id}/delete', [ArticleController::class, 'destroy']);

What does this file do?

This file is loaded inside the Router singleton closure in config/bindings.php. The $router variable is available because the closure creates it before requiring this file.

Each route maps an HTTP method + URI pattern to a controller class and action method. The {id} placeholder will be extracted by the regex and passed as a string argument to the action method.

Why POST for update and delete?

HTML forms only support GET and POST. While the Router supports PUT and DELETE registrations, browser forms cannot send those methods without JavaScript. Using POST /articles/{id} for update and POST /articles/{id}/delete for destroy keeps the app fully functional without client-side scripting.

  • Save routes/web.php.

To test the boot cycle without the middleware classes that do not exist yet, temporarily empty the middleware array.

  • In config/middleware.php, temporarily change the return statement to return an empty array:
return [];
  • Visit your Apache URL (e.g. http://localhost/ or your configured VirtualHost) in your browser.
  • You should see a detailed error page (since APP_DEBUG=true) mentioning that ArticleController does not exist. This confirms the full boot chain is working: front controller loaded the environment, resolved the Kernel, dispatched through the Pipeline, hit the Router, and the Router tried to resolve the controller.
  • Once confirmed, revert config/middleware.php back to the full middleware array:
<?php

declare(strict_types=1);

return [
    App\App\Middleware\SessionMiddleware::class,
    App\App\Middleware\CsrfMiddleware::class,
];
  • Save config/middleware.php.

Seeing a 500 error with no details?

Make sure your .env file has APP_DEBUG=true. Without it, the ExceptionHandler shows a generic 500 page instead of the stack trace.

If you see a blank page, check that AllowOverride All is set in your Apache config and that mod_rewrite is enabled. Without these, Apache cannot route requests to public/index.php.

help me debug my PHP MVC framework not loading in Apache

✔️ Awesome, I've got everything!

Great. Make sure you have saved all four files and reverted the middleware config to its full array.

ⓧ I'd like to double check the full code

Here are the four new files you created in this step:

<?php

declare(strict_types=1);

namespace App\Core;

use App\Http\Request;
use App\Http\Response;

class Router
{
    private array $routes = [];

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

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

    public function put(string $path, array $handler): void
    {
        $this->addRoute('PUT', $path, $handler);
    }

    public function delete(string $path, array $handler): void
    {
        $this->addRoute('DELETE', $path, $handler);
    }

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

    public function dispatch(Request $request, Container $container): Response
    {
        $method = $request->method();
        $path = $request->path();

        foreach ($this->routes as $route) {
            if ($route['method'] !== $method) {
                continue;
            }

            $pattern = preg_replace('#\{(\w+)\}#', '(?P<$1>[^/]+)', $route['path']);
            $pattern = '#^' . $pattern . '$#';

            if (preg_match($pattern, $path, $matches)) {
                $params = array_filter($matches, fn($key) => is_string($key), ARRAY_FILTER_USE_KEY);

                [$controllerClass, $action] = $route['handler'];

                $controller = $container->get($controllerClass);
                $controller->setRequest($request);

                return $controller->{$action}(...$params);
            }
        }

        return Response::html('<h1>404 Not Found</h1>', 404);
    }
}
<?php

declare(strict_types=1);

namespace App\Core;

use App\Http\Request;
use App\Http\Response;

class Kernel
{
    public function __construct(
        private readonly Container $container,
        private readonly Router $router,
        private readonly Pipeline $pipeline,
    ) {}

    public function handle(): void
    {
        $request = Request::capture();

        $middlewares = require dirname(__DIR__, 2) . '/config/middleware.php';

        $response = $this->pipeline
            ->through($middlewares)
            ->handle($request, fn(Request $req): Response => $this->router->dispatch($req, $this->container));

        $response->send();
    }
}
<?php

declare(strict_types=1);

namespace App\App\Controllers;

use App\Http\Request;
use App\Http\Response;

abstract class Controller
{
    protected Request $request;

    public function setRequest(Request $request): void
    {
        $this->request = $request;
    }

    protected function view(string $template, array $data = [], int $status = 200): Response
    {
        if ($this->request->attribute('wantsJson')) {
            return Response::json($data, $status);
        }

        extract($data);

        ob_start();
        require dirname(__DIR__) . '/Views/' . $template . '.php';
        $content = ob_get_clean();

        ob_start();
        require dirname(__DIR__) . '/Views/layout.php';
        $html = ob_get_clean();

        return Response::html($html, $status);
    }

    protected function redirect(string $url): Response
    {
        return Response::redirect($url);
    }
}
<?php

declare(strict_types=1);

use App\App\Controllers\ArticleController;

/** @var App\Core\Router $router */

$router->get('/', [ArticleController::class, 'index']);
$router->get('/articles', [ArticleController::class, 'index']);
$router->get('/articles/create', [ArticleController::class, 'create']);
$router->post('/articles', [ArticleController::class, 'store']);
$router->get('/articles/{id}', [ArticleController::class, 'show']);
$router->get('/articles/{id}/edit', [ArticleController::class, 'edit']);
$router->post('/articles/{id}', [ArticleController::class, 'update']);
$router->post('/articles/{id}/delete', [ArticleController::class, 'destroy']);
<?php

declare(strict_types=1);

return [
    App\App\Middleware\SessionMiddleware::class,
    App\App\Middleware\CsrfMiddleware::class,
];

Your framework can now boot, route requests, and resolve controllers through the DI container. Next up, you will build the database layer, secure sessions, and CSRF protection to make the middleware stack functional.

Build the Database Layer, Model, and Secure Sessions

Your framework kernel can now route requests through a middleware pipeline and resolve controllers via the DI container. The next challenge is persistence and security.

In this step, you will build the database abstraction layer, a secure session service, and the middleware that protects every state-changing request from CSRF attacks. You will also add an input validator and set up the MySQL database your app needs.

In this step, get ready to:
  • Build the abstract Model with PDO prepared statements and create the articles database.
  • Create a secure Session service and the SessionMiddleware that starts it on every request.
  • Build CSRF protection middleware, input validation, and view helper functions.
Build the abstract Model and create the database

Your config/bindings.php already registers a PDO singleton with strict settings: exception mode, associative fetch, and native prepared statements. Now you need an abstract Model class that every application model will extend.

  • Create the file src/Core/Model.php and add the following code:
<?php

declare(strict_types=1);

namespace App\Core;

use PDO;

abstract class Model
{
    public function __construct(
        protected readonly PDO $pdo,
    ) {}

    protected function query(string $sql, array $params = []): array
    {
        // Prepare and execute a SELECT query, returning all rows
        $stmt = $this->pdo->prepare($sql);
        $stmt->execute($params);
        return $stmt->fetchAll();
    }

    protected function execute(string $sql, array $params = []): bool
    {
        // Prepare and execute an INSERT/UPDATE/DELETE statement
        $stmt = $this->pdo->prepare($sql);
        return $stmt->execute($params);
    }

    protected function lastInsertId(): string
    {
        return $this->pdo->lastInsertId();
    }
}

What does this code do?

The Model receives a PDO connection via constructor injection. The container autowires this automatically because config/bindings.php already has PDO::class registered as a singleton.

Every query goes through prepare() then execute(). This means SQL injection is impossible by design. The protected readonly modifier prevents child classes from replacing the connection.

Next, create the database schema. Your app needs an articles table to prove the architecture works under real CRUD conditions.

  • Create the file database/schema.sql and add the following SQL:
CREATE DATABASE IF NOT EXISTS mvc_app CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

USE mvc_app;

CREATE TABLE articles (
    id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    title VARCHAR(200) NOT NULL,
    body TEXT NOT NULL,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    INDEX idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

What does this schema do?

The schema creates the mvc_app database with utf8mb4 encoding (full Unicode support including emoji). The articles table uses InnoDB for transaction support and adds an index on created_at for efficient date-ordered queries.

The INT UNSIGNED AUTO_INCREMENT primary key starts at 1 and increments automatically. No application code needs to generate IDs.

  • Import the schema into MySQL by running this command from your project root:
mysql -u root -p < database/schema.sql
  • Enter your MySQL root password when prompted.
  • Verify the table was created by running:
mysql -u root -p -e "SHOW TABLES FROM mvc_app;"
  • You should see articles listed in the output.

Access denied or database not created?

  • If you see an access denied error, confirm the MySQL root password is correct. If your MySQL install uses a non-root user, replace -u root with your username.
  • On Windows PowerShell, the < redirect is reserved. Use cmd.exe /c "mysql -u root -p < database/schema.sql" instead.
  • Make sure your .env file has DB_DATABASE=mvc_app matching the database name in the schema.

help me fix my MySQL connection issue

Build the Session service and SessionMiddleware

PHP sessions are the backbone of CSRF protection, flash messages, and user state. A raw session_start() call is insecure by default. Your Session service wraps it with hardened cookie parameters.

  • Create the file src/Core/Session.php and add the following code:
<?php

declare(strict_types=1);

namespace App\Core;

class Session
{
    private bool $started = false;

    public function start(): void
    {
        if ($this->started || session_status() === PHP_SESSION_ACTIVE) {
            $this->started = true;
            return;
        }

        // Configure secure cookie parameters before starting the session
        session_set_cookie_params([
            'lifetime' => 0,
            'path' => '/',
            'httponly' => true,
            'samesite' => 'Strict',
            'secure' => ($_ENV['APP_ENV'] ?? 'local') === 'production',
        ]);

        session_start();
        $this->started = true;
    }

    public function get(string $key, mixed $default = null): mixed
    {
        return $_SESSION[$key] ?? $default;
    }

    public function set(string $key, mixed $value): void
    {
        $_SESSION[$key] = $value;
    }

    public function flash(string $key, mixed $value): void
    {
        // Store data that persists only until the next request
        $_SESSION['_flash'][$key] = $value;
    }

    public function getFlash(string $key, mixed $default = null): mixed
    {
        $value = $_SESSION['_flash'][$key] ?? $default;
        unset($_SESSION['_flash'][$key]);
        return $value;
    }

    public function regenerate(): void
    {
        // Destroy old session file to prevent session fixation
        session_regenerate_id(true);
    }

    public function token(): string
    {
        // Generate a CSRF token if one doesn't exist
        if (!isset($_SESSION['_token'])) {
            $_SESSION['_token'] = bin2hex(random_bytes(32));
        }
        return $_SESSION['_token'];
    }

    public function verifyToken(string $token): bool
    {
        // Timing-safe comparison prevents timing attacks
        return hash_equals($this->token(), $token);
    }
}

Why these cookie settings matter

  • httponly: true prevents JavaScript from reading the session cookie. This blocks XSS attacks from stealing sessions.
  • samesite: 'Strict' ensures the browser never sends the cookie on cross-origin requests. This is a second layer of CSRF defense.
  • session_regenerate_id(true) creates a new session ID and deletes the old session file. Call this after privilege changes (login, password reset) to prevent session fixation.
  • bin2hex(random_bytes(32)) generates a cryptographically secure 64-character hex token for CSRF protection.

Now create the middleware that starts the session on every incoming request.

  • Create the file src/App/Middleware/SessionMiddleware.php and add the following code:
<?php

declare(strict_types=1);

namespace App\App\Middleware;

use App\Core\Session;
use App\Http\Middleware\MiddlewareInterface;
use App\Http\Request;
use App\Http\Response;
use Closure;

class SessionMiddleware implements MiddlewareInterface
{
    public function __construct(
        private readonly Session $session,
    ) {}

    public function handle(Request $request, Closure $next): Response
    {
        // Start the session before any controller runs
        $this->session->start();
        return $next($request);
    }
}

What does this middleware do?

The container autowires the Session dependency into this middleware automatically (it is registered as a singleton in config/bindings.php). Every request passes through SessionMiddleware before reaching the router, guaranteeing the session is active for all downstream code.

  • Save both src/Core/Session.php and src/App/Middleware/SessionMiddleware.php.

You will not see a visible change yet. The next substep builds the CSRF middleware that uses this session, and you will verify the full stack together.

Build CSRF protection, validation, and view helpers

CSRF attacks trick a user's browser into submitting a forged request to your app. Your middleware will reject any POST, PUT, or DELETE request that does not carry a valid token matching the session.

  • Create the file src/App/Middleware/CsrfMiddleware.php and add the following code:
<?php

declare(strict_types=1);

namespace App\App\Middleware;

use App\Core\Session;
use App\Http\Middleware\MiddlewareInterface;
use App\Http\Request;
use App\Http\Response;
use Closure;

class CsrfMiddleware implements MiddlewareInterface
{
    public function __construct(
        private readonly Session $session,
    ) {}

    public function handle(Request $request, Closure $next): Response
    {
        // Only validate tokens on state-changing methods
        if (in_array($request->method(), ['POST', 'PUT', 'DELETE'], true)) {
            $token = $request->input('_token', '');

            if (!is_string($token) || !$this->session->verifyToken($token)) {
                return Response::html('<h1>419 Page Expired</h1><p>CSRF token mismatch.</p>', 419);
            }
        }

        return $next($request);
    }
}

What does this code do?

On GET requests, the middleware does nothing and passes through. On POST, PUT, or DELETE, it reads the _token field from the request body and verifies it against the session token using hash_equals() (timing-safe comparison).

A mismatch returns a 419 status code immediately. The request never reaches the controller. This makes CSRF attacks impossible as long as every form includes the token.

Now build the Validator class that controllers will use to validate form input before persisting data.

  • Create the file src/Core/Validator.php and add the following code:
<?php

declare(strict_types=1);

namespace App\Core;

class Validator
{
    private array $errors = [];

    public static function make(array $data, array $rules): static
    {
        $validator = new static();

        foreach ($rules as $field => $ruleString) {
            $fieldRules = explode('|', $ruleString);
            $value = $data[$field] ?? null;

            foreach ($fieldRules as $rule) {
                $validator->applyRule($field, $value, $rule);
            }
        }

        return $validator;
    }

    private function applyRule(string $field, mixed $value, string $rule): void
    {
        $params = [];
        if (str_contains($rule, ':')) {
            [$rule, $paramStr] = explode(':', $rule, 2);
            $params = explode(',', $paramStr);
        }

        // PHP 8.4 match expression dispatches to the correct rule method
        match ($rule) {
            'required' => $this->validateRequired($field, $value),
            'min' => $this->validateMin($field, $value, (int) $params[0]),
            'max' => $this->validateMax($field, $value, (int) $params[0]),
            'email' => $this->validateEmail($field, $value),
            'numeric' => $this->validateNumeric($field, $value),
            default => null,
        };
    }

    private function validateRequired(string $field, mixed $value): void
    {
        if ($value === null || $value === '') {
            $this->errors[$field][] = "{$field} is required.";
        }
    }

    private function validateMin(string $field, mixed $value, int $min): void
    {
        if (is_string($value) && mb_strlen($value) < $min) {
            $this->errors[$field][] = "{$field} must be at least {$min} characters.";
        }
    }

    private function validateMax(string $field, mixed $value, int $max): void
    {
        if (is_string($value) && mb_strlen($value) > $max) {
            $this->errors[$field][] = "{$field} must not exceed {$max} characters.";
        }
    }

    private function validateEmail(string $field, mixed $value): void
    {
        if ($value && !filter_var($value, FILTER_VALIDATE_EMAIL)) {
            $this->errors[$field][] = "{$field} must be a valid email address.";
        }
    }

    private function validateNumeric(string $field, mixed $value): void
    {
        if ($value && !is_numeric($value)) {
            $this->errors[$field][] = "{$field} must be numeric.";
        }
    }

    public function fails(): bool
    {
        return !empty($this->errors);
    }

    public function errors(): array
    {
        return $this->errors;
    }
}

What does this code do?

The static make() factory accepts an associative array of data and a rules array using pipe-delimited syntax like 'required|min:3|max:200'. Each rule is parsed and dispatched via a match expression.

String-length validation uses mb_strlen() for proper multibyte support. The validator collects errors per field. Controllers call $validator->fails() to check the result and $validator->errors() to get the error messages for display.

Finally, create the helper functions that your view templates will use for CSRF fields and repopulating forms after validation errors.

  • Create the file src/helpers.php and add the following code:
<?php

declare(strict_types=1);

function csrf_token(): string
{
    return $_SESSION['_token'] ?? '';
}

function csrf_field(): string
{
    // Generate a hidden input with the CSRF token for use in forms
    return '<input type="hidden" name="_token" value="' . htmlspecialchars(csrf_token()) . '">';
}

function old(string $key, string $default = ''): string
{
    // Retrieve flashed old input for repopulating forms after validation failure
    return htmlspecialchars($_SESSION['_flash']['old'][$key] ?? $default);
}

What do these helpers do?

  • csrf_token() returns the raw token string from the session.
  • csrf_field() outputs a complete hidden input element. Views call <?= csrf_field() ?> inside every form.
  • old() retrieves previously submitted values from flash storage. If validation fails and the controller redirects back, old('title') repopulates the field so the user does not lose their input.

These are loaded globally via the files key in composer.json (already configured).

  • Save src/App/Middleware/CsrfMiddleware.php, src/Core/Validator.php, and src/helpers.php.
  • Regenerate the Composer autoloader so it picks up the helpers file by running:
composer dump-autoload
  • Verify everything is wired by starting the PHP development server:
php -S localhost:8000 -t public
  • Visit http://localhost:8000 in your browser. You should see an error page (because ArticleController does not exist yet). The key indicator is that the error is displayed by your ExceptionHandler, not a raw PHP fatal. This proves the middleware pipeline, session, and CSRF middleware are all loading correctly.

Seeing a raw PHP fatal instead of a styled error?

  • Run composer dump-autoload again. If a class is not being found, the autoloader map is stale.
  • Check that the namespace in SessionMiddleware.php is App\App\Middleware (matching the PSR-4 root App\ mapped to src/).
  • Confirm config/middleware.php returns an array with SessionMiddleware::class and CsrfMiddleware::class using their full namespaced names.

help me fix my middleware autoloading error

✔️ Awesome, I've got everything!

Great. Double check you have saved all files and that composer dump-autoload completed without errors.

ⓧ I'd like to double check the full code

Here are the complete files created in this step for comparison:

<?php

declare(strict_types=1);

namespace App\Core;

use PDO;

abstract class Model
{
    public function __construct(
        protected readonly PDO $pdo,
    ) {}

    protected function query(string $sql, array $params = []): array
    {
        $stmt = $this->pdo->prepare($sql);
        $stmt->execute($params);
        return $stmt->fetchAll();
    }

    protected function execute(string $sql, array $params = []): bool
    {
        $stmt = $this->pdo->prepare($sql);
        return $stmt->execute($params);
    }

    protected function lastInsertId(): string
    {
        return $this->pdo->lastInsertId();
    }
}
<?php

declare(strict_types=1);

namespace App\Core;

class Session
{
    private bool $started = false;

    public function start(): void
    {
        if ($this->started || session_status() === PHP_SESSION_ACTIVE) {
            $this->started = true;
            return;
        }

        session_set_cookie_params([
            'lifetime' => 0,
            'path' => '/',
            'httponly' => true,
            'samesite' => 'Strict',
            'secure' => ($_ENV['APP_ENV'] ?? 'local') === 'production',
        ]);

        session_start();
        $this->started = true;
    }

    public function get(string $key, mixed $default = null): mixed
    {
        return $_SESSION[$key] ?? $default;
    }

    public function set(string $key, mixed $value): void
    {
        $_SESSION[$key] = $value;
    }

    public function flash(string $key, mixed $value): void
    {
        $_SESSION['_flash'][$key] = $value;
    }

    public function getFlash(string $key, mixed $default = null): mixed
    {
        $value = $_SESSION['_flash'][$key] ?? $default;
        unset($_SESSION['_flash'][$key]);
        return $value;
    }

    public function regenerate(): void
    {
        session_regenerate_id(true);
    }

    public function token(): string
    {
        if (!isset($_SESSION['_token'])) {
            $_SESSION['_token'] = bin2hex(random_bytes(32));
        }
        return $_SESSION['_token'];
    }

    public function verifyToken(string $token): bool
    {
        return hash_equals($this->token(), $token);
    }
}
<?php

declare(strict_types=1);

namespace App\App\Middleware;

use App\Core\Session;
use App\Http\Middleware\MiddlewareInterface;
use App\Http\Request;
use App\Http\Response;
use Closure;

class SessionMiddleware implements MiddlewareInterface
{
    public function __construct(
        private readonly Session $session,
    ) {}

    public function handle(Request $request, Closure $next): Response
    {
        $this->session->start();
        return $next($request);
    }
}
<?php

declare(strict_types=1);

namespace App\App\Middleware;

use App\Core\Session;
use App\Http\Middleware\MiddlewareInterface;
use App\Http\Request;
use App\Http\Response;
use Closure;

class CsrfMiddleware implements MiddlewareInterface
{
    public function __construct(
        private readonly Session $session,
    ) {}

    public function handle(Request $request, Closure $next): Response
    {
        if (in_array($request->method(), ['POST', 'PUT', 'DELETE'], true)) {
            $token = $request->input('_token', '');

            if (!is_string($token) || !$this->session->verifyToken($token)) {
                return Response::html('<h1>419 Page Expired</h1><p>CSRF token mismatch.</p>', 419);
            }
        }

        return $next($request);
    }
}
<?php

declare(strict_types=1);

namespace App\Core;

class Validator
{
    private array $errors = [];

    public static function make(array $data, array $rules): static
    {
        $validator = new static();

        foreach ($rules as $field => $ruleString) {
            $fieldRules = explode('|', $ruleString);
            $value = $data[$field] ?? null;

            foreach ($fieldRules as $rule) {
                $validator->applyRule($field, $value, $rule);
            }
        }

        return $validator;
    }

    private function applyRule(string $field, mixed $value, string $rule): void
    {
        $params = [];
        if (str_contains($rule, ':')) {
            [$rule, $paramStr] = explode(':', $rule, 2);
            $params = explode(',', $paramStr);
        }

        match ($rule) {
            'required' => $this->validateRequired($field, $value),
            'min' => $this->validateMin($field, $value, (int) $params[0]),
            'max' => $this->validateMax($field, $value, (int) $params[0]),
            'email' => $this->validateEmail($field, $value),
            'numeric' => $this->validateNumeric($field, $value),
            default => null,
        };
    }

    private function validateRequired(string $field, mixed $value): void
    {
        if ($value === null || $value === '') {
            $this->errors[$field][] = "{$field} is required.";
        }
    }

    private function validateMin(string $field, mixed $value, int $min): void
    {
        if (is_string($value) && mb_strlen($value) < $min) {
            $this->errors[$field][] = "{$field} must be at least {$min} characters.";
        }
    }

    private function validateMax(string $field, mixed $value, int $max): void
    {
        if (is_string($value) && mb_strlen($value) > $max) {
            $this->errors[$field][] = "{$field} must not exceed {$max} characters.";
        }
    }

    private function validateEmail(string $field, mixed $value): void
    {
        if ($value && !filter_var($value, FILTER_VALIDATE_EMAIL)) {
            $this->errors[$field][] = "{$field} must be a valid email address.";
        }
    }

    private function validateNumeric(string $field, mixed $value): void
    {
        if ($value && !is_numeric($value)) {
            $this->errors[$field][] = "{$field} must be numeric.";
        }
    }

    public function fails(): bool
    {
        return !empty($this->errors);
    }

    public function errors(): array
    {
        return $this->errors;
    }
}
<?php

declare(strict_types=1);

function csrf_token(): string
{
    return $_SESSION['_token'] ?? '';
}

function csrf_field(): string
{
    return '<input type="hidden" name="_token" value="' . htmlspecialchars(csrf_token()) . '">';
}

function old(string $key, string $default = ''): string
{
    return htmlspecialchars($_SESSION['_flash']['old'][$key] ?? $default);
}
CREATE DATABASE IF NOT EXISTS mvc_app CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

USE mvc_app;

CREATE TABLE articles (
    id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    title VARCHAR(200) NOT NULL,
    body TEXT NOT NULL,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    INDEX idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

Your persistence layer, session security, CSRF protection, and input validation are all in place. Next up, you will build the ArticleController and views that tie everything together into a working CRUD application.

Build Article CRUD with Validation and Views

Your framework kernel is complete. The DI container resolves dependencies, the middleware pipeline processes requests, and the router dispatches to controllers. Now you need to prove the architecture works under real conditions.

In this step, you will build a full article CRUD application on top of the kernel. This means a model that talks to MySQL through prepared statements, a controller that validates input and flashes errors to the session, and view templates that render CSRF-protected forms.

In this step, get ready to:
  • Build the Article model with all CRUD methods using prepared statements.
  • Build the ArticleController with input validation and session-based error flashing.
  • Create view templates with CSRF protection and test the full CRUD flow.
Build the Article model

The Article model extends the base Model class you built earlier. It inherits the PDO connection and the query()/execute() helper methods that use prepared statements for every database call.

  • Create a new file at src/App/Models/Article.php and add the following code:
<?php

declare(strict_types=1);

namespace App\App\Models;

use App\Core\Model;

class Article extends Model
{
    public function all(): array
    {
        return $this->query('SELECT * FROM articles ORDER BY created_at DESC');
    }

    public function find(int $id): ?array
    {
        $results = $this->query('SELECT * FROM articles WHERE id = :id', ['id' => $id]);
        return $results[0] ?? null;
    }

    public function create(array $data): int
    {
        $this->execute(
            'INSERT INTO articles (title, body, created_at, updated_at) VALUES (:title, :body, NOW(), NOW())',
            ['title' => $data['title'], 'body' => $data['body']]
        );

        return (int) $this->lastInsertId();
    }

    public function update(int $id, array $data): bool
    {
        return $this->execute(
            'UPDATE articles SET title = :title, body = :body, updated_at = NOW() WHERE id = :id',
            ['title' => $data['title'], 'body' => $data['body'], 'id' => $id]
        );
    }

    public function delete(int $id): bool
    {
        return $this->execute('DELETE FROM articles WHERE id = :id', ['id' => $id]);
    }
}

What does this code do?

Every method uses named parameters (:id, :title, :body) in prepared statements. The database driver binds values separately from the SQL string, making SQL injection impossible by design.

The find() method returns ?array (nullable). If no article matches the ID, the controller gets null and can return a 404 response.

  • Save src/App/Models/Article.php.

Why no explicit binding in the container?

The DI container's autowiring handles this automatically. When the controller asks for an Article instance, the container inspects Article's constructor, sees it needs a PDO object, resolves the PDO singleton from config/bindings.php, and injects it. No manual wiring needed.

Build the ArticleController

The controller ties together the model, the validator, and the session. It receives dependencies via constructor injection, validates user input on store and update, flashes errors back to the form on failure, and returns Response objects for every action.

  • Create a new file at src/App/Controllers/ArticleController.php and add the following code:
<?php

declare(strict_types=1);

namespace App\App\Controllers;

use App\App\Models\Article;
use App\Core\Session;
use App\Core\Validator;
use App\Http\Response;

class ArticleController extends Controller
{
    public function __construct(
        private readonly Article $article,
        private readonly Session $session,
    ) {}

    public function index(): Response
    {
        $articles = $this->article->all();

        return $this->view('articles/index', [
            'title' => 'Articles',
            'articles' => $articles,
        ]);
    }

    public function show(string $id): Response
    {
        $article = $this->article->find((int) $id);

        if ($article === null) {
            return Response::html('<h1>404 Not Found</h1>', 404);
        }

        return $this->view('articles/show', [
            'title' => $article['title'],
            'article' => $article,
        ]);
    }

    public function create(): Response
    {
        return $this->view('articles/create', [
            'title' => 'New Article',
            'errors' => $this->session->getFlash('errors', []),
            'old' => $this->session->getFlash('old', []),
            'token' => $this->session->token(),
        ]);
    }

    public function store(): Response
    {
        $data = [
            'title' => trim($this->request->input('title', '')),
            'body' => trim($this->request->input('body', '')),
        ];

        $validator = Validator::make($data, [
            'title' => 'required|min:3|max:200',
            'body' => 'required|min:10',
        ]);

        if ($validator->fails()) {
            $this->session->flash('errors', $validator->errors());
            $this->session->flash('old', $data);
            return $this->redirect('/articles/create');
        }

        $this->article->create($data);
        $this->session->regenerate();

        return $this->redirect('/articles');
    }

    public function edit(string $id): Response
    {
        $article = $this->article->find((int) $id);

        if ($article === null) {
            return Response::html('<h1>404 Not Found</h1>', 404);
        }

        return $this->view('articles/edit', [
            'title' => 'Edit: ' . $article['title'],
            'article' => $article,
            'errors' => $this->session->getFlash('errors', []),
            'token' => $this->session->token(),
        ]);
    }

    public function update(string $id): Response
    {
        $data = [
            'title' => trim($this->request->input('title', '')),
            'body' => trim($this->request->input('body', '')),
        ];

        $validator = Validator::make($data, [
            'title' => 'required|min:3|max:200',
            'body' => 'required|min:10',
        ]);

        if ($validator->fails()) {
            $this->session->flash('errors', $validator->errors());
            return $this->redirect("/articles/{$id}/edit");
        }

        $this->article->update((int) $id, $data);

        return $this->redirect('/articles/' . $id);
    }

    public function destroy(string $id): Response
    {
        $this->article->delete((int) $id);
        return $this->redirect('/articles');
    }
}

What does this controller do?

The constructor declares Article and Session as dependencies. The DI container autowires both automatically when the router resolves this controller.

The store() method shows the full validation flow: trim input, run it through Validator::make() with declarative rules, flash errors and old input to the session on failure, and persist on success. After a successful create, session->regenerate() prevents session fixation attacks.

  • Save src/App/Controllers/ArticleController.php.

Seeing a container resolution error?

If you get an error about resolving parameters in ArticleController, check that your Article class namespace is exactly App\App\Models. The PSR-4 mapping expects the file at src/App/Models/Article.php.

help me debug a container resolution error in my ArticleController

Create view templates and test the CRUD flow

The base controller's view() method renders a template file into a $content variable, then wraps it in a shared layout. You need the layout file plus one template for each controller action that renders HTML.

  • Create the directories src/App/Views/ and src/App/Views/articles/ by running:
mkdir -p src/App/Views/articles
  • Create the shared layout file at src/App/Views/layout.php with the following code:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title><?= htmlspecialchars($title ?? 'MVC App') ?></title>
    <style>
        body { font-family: system-ui, sans-serif; max-width: 800px; margin: 2rem auto; padding: 0 1rem; }
        nav { margin-bottom: 2rem; padding-bottom: 1rem; border-bottom: 1px solid #ddd; }
        nav a { margin-right: 1rem; text-decoration: none; color: #0066cc; }
        .error { color: #cc0000; font-size: 0.9rem; }
        form label { display: block; margin-top: 1rem; font-weight: bold; }
        form input[type="text"], form textarea { width: 100%; padding: 0.5rem; margin-top: 0.25rem; }
        form textarea { height: 150px; }
        button { margin-top: 1rem; padding: 0.5rem 1rem; cursor: pointer; }
    </style>
</head>
<body>
    <nav>
        <a href="/articles">All Articles</a>
        <a href="/articles/create">New Article</a>
    </nav>
    <?= $content ?>
</body>
</html>

What does this layout do?

The layout is a shared HTML shell. The controller's view() method renders the page-specific template first (capturing its output into $content), then renders the layout which outputs $content inside the body. The $title variable comes from the data array passed by the controller.

  • Create the article list template at src/App/Views/articles/index.php:
<h1>Articles</h1>

<?php if (empty($articles)): ?>
    <p>No articles yet. <a href="/articles/create">Create one</a>.</p>
<?php else: ?>
    <?php foreach ($articles as $article): ?>
        <article style="margin-bottom: 1.5rem; padding-bottom: 1rem; border-bottom: 1px solid #eee;">
            <h2><a href="/articles/<?= $article['id'] ?>"><?= htmlspecialchars($article['title']) ?></a></h2>
            <p><?= htmlspecialchars(mb_substr($article['body'], 0, 150)) ?>...</p>
            <small>Created: <?= $article['created_at'] ?></small>
        </article>
    <?php endforeach; ?>
<?php endif; ?>
  • Create the single article template at src/App/Views/articles/show.php:
<article>
    <h1><?= htmlspecialchars($article['title']) ?></h1>
    <p><?= nl2br(htmlspecialchars($article['body'])) ?></p>
    <small>Created: <?= $article['created_at'] ?> | Updated: <?= $article['updated_at'] ?></small>

    <div style="margin-top: 1rem;">
        <a href="/articles/<?= $article['id'] ?>/edit">Edit</a>
        <form method="POST" action="/articles/<?= $article['id'] ?>/delete" style="display: inline;">
            <?= csrf_field() ?>
            <button type="submit" onclick="return confirm('Delete this article?')">Delete</button>
        </form>
    </div>
</article>
  • Create the new article form at src/App/Views/articles/create.php:
<h1>New Article</h1>

<?php if (!empty($errors)): ?>
    <div class="error">
        <?php foreach ($errors as $field => $messages): ?>
            <?php foreach ($messages as $message): ?>
                <p><?= htmlspecialchars($message) ?></p>
            <?php endforeach; ?>
        <?php endforeach; ?>
    </div>
<?php endif; ?>

<form method="POST" action="/articles">
    <?= csrf_field() ?>

    <label for="title">Title</label>
    <input type="text" id="title" name="title" value="<?= old('title') ?>" required>

    <label for="body">Body</label>
    <textarea id="body" name="body" required><?= old('body') ?></textarea>

    <button type="submit">Create Article</button>
</form>

How does CSRF protection work in these forms?

The csrf_field() helper from src/helpers.php outputs a hidden input containing the session's CSRF token. When the form submits, the CsrfMiddleware checks the submitted _token value against the session. A mismatch returns a 419 response, blocking the request.

  • Create the edit article form at src/App/Views/articles/edit.php:
<h1>Edit: <?= htmlspecialchars($article['title']) ?></h1>

<?php if (!empty($errors)): ?>
    <div class="error">
        <?php foreach ($errors as $field => $messages): ?>
            <?php foreach ($messages as $message): ?>
                <p><?= htmlspecialchars($message) ?></p>
            <?php endforeach; ?>
        <?php endforeach; ?>
    </div>
<?php endif; ?>

<form method="POST" action="/articles/<?= $article['id'] ?>">
    <?= csrf_field() ?>

    <label for="title">Title</label>
    <input type="text" id="title" name="title" value="<?= htmlspecialchars($article['title']) ?>" required>

    <label for="body">Body</label>
    <textarea id="body" name="body" required><?= htmlspecialchars($article['body']) ?></textarea>

    <button type="submit">Update Article</button>
</form>
  • Save all five view files.
  • Regenerate the Composer autoloader to pick up the new classes by running:
composer dump-autoload
  • Open your browser and navigate to your Apache URL (for example, http://localhost/articles or wherever your project is configured).
  • You should see the Articles index page with a "No articles yet" message and a link to create one.
  • Click **New Article** in the navigation.
  • Try submitting the form with a title shorter than 3 characters. You should see a validation error and get redirected back to the form with the old input preserved.
  • Fill in a valid title (3+ characters) and body (10+ characters), then click **Create Article**.
  • You should be redirected to the articles list where your new article now appears.

Seeing a 500 error or blank page?

Check storage/logs/app.log for the full stack trace. Common causes:

  • Database connection failed. Confirm your .env credentials match your MySQL setup and the mvc_app database exists.
  • Missing view file. The controller expects templates at src/App/Views/articles/. Check the file names match exactly.
  • Autoloader stale. Run composer dump-autoload again if you see a class-not-found error.

help me debug a 500 error when loading my article CRUD pages

✔️ Awesome, I've got everything!

Great. Double check you have saved both Article.php and ArticleController.php along with all five view templates.

ⓧ I'd like to double check the full code

Here is the complete Article.php model:

<?php

declare(strict_types=1);

namespace App\App\Models;

use App\Core\Model;

class Article extends Model
{
    public function all(): array
    {
        return $this->query('SELECT * FROM articles ORDER BY created_at DESC');
    }

    public function find(int $id): ?array
    {
        $results = $this->query('SELECT * FROM articles WHERE id = :id', ['id' => $id]);
        return $results[0] ?? null;
    }

    public function create(array $data): int
    {
        $this->execute(
            'INSERT INTO articles (title, body, created_at, updated_at) VALUES (:title, :body, NOW(), NOW())',
            ['title' => $data['title'], 'body' => $data['body']]
        );

        return (int) $this->lastInsertId();
    }

    public function update(int $id, array $data): bool
    {
        return $this->execute(
            'UPDATE articles SET title = :title, body = :body, updated_at = NOW() WHERE id = :id',
            ['title' => $data['title'], 'body' => $data['body'], 'id' => $id]
        );
    }

    public function delete(int $id): bool
    {
        return $this->execute('DELETE FROM articles WHERE id = :id', ['id' => $id]);
    }
}

Here is the complete ArticleController.php:

<?php

declare(strict_types=1);

namespace App\App\Controllers;

use App\App\Models\Article;
use App\Core\Session;
use App\Core\Validator;
use App\Http\Response;

class ArticleController extends Controller
{
    public function __construct(
        private readonly Article $article,
        private readonly Session $session,
    ) {}

    public function index(): Response
    {
        $articles = $this->article->all();

        return $this->view('articles/index', [
            'title' => 'Articles',
            'articles' => $articles,
        ]);
    }

    public function show(string $id): Response
    {
        $article = $this->article->find((int) $id);

        if ($article === null) {
            return Response::html('<h1>404 Not Found</h1>', 404);
        }

        return $this->view('articles/show', [
            'title' => $article['title'],
            'article' => $article,
        ]);
    }

    public function create(): Response
    {
        return $this->view('articles/create', [
            'title' => 'New Article',
            'errors' => $this->session->getFlash('errors', []),
            'old' => $this->session->getFlash('old', []),
            'token' => $this->session->token(),
        ]);
    }

    public function store(): Response
    {
        $data = [
            'title' => trim($this->request->input('title', '')),
            'body' => trim($this->request->input('body', '')),
        ];

        $validator = Validator::make($data, [
            'title' => 'required|min:3|max:200',
            'body' => 'required|min:10',
        ]);

        if ($validator->fails()) {
            $this->session->flash('errors', $validator->errors());
            $this->session->flash('old', $data);
            return $this->redirect('/articles/create');
        }

        $this->article->create($data);
        $this->session->regenerate();

        return $this->redirect('/articles');
    }

    public function edit(string $id): Response
    {
        $article = $this->article->find((int) $id);

        if ($article === null) {
            return Response::html('<h1>404 Not Found</h1>', 404);
        }

        return $this->view('articles/edit', [
            'title' => 'Edit: ' . $article['title'],
            'article' => $article,
            'errors' => $this->session->getFlash('errors', []),
            'token' => $this->session->token(),
        ]);
    }

    public function update(string $id): Response
    {
        $data = [
            'title' => trim($this->request->input('title', '')),
            'body' => trim($this->request->input('body', '')),
        ];

        $validator = Validator::make($data, [
            'title' => 'required|min:3|max:200',
            'body' => 'required|min:10',
        ]);

        if ($validator->fails()) {
            $this->session->flash('errors', $validator->errors());
            return $this->redirect("/articles/{$id}/edit");
        }

        $this->article->update((int) $id, $data);

        return $this->redirect('/articles/' . $id);
    }

    public function destroy(string $id): Response
    {
        $this->article->delete((int) $id);
        return $this->redirect('/articles');
    }
}

Your MVC architecture is now a working application. The Article model proves your PDO abstraction handles real queries. The controller proves your DI container, validator, session, and CSRF middleware all compose correctly under production conditions. Next, you will extend this into an API-ready architecture in the Secret Mission.

Secret mission

Content Negotiation Middleware for JSON API

Your MVC architecture serves HTML beautifully. But what if a mobile app or a JavaScript frontend needs the same data as JSON? In this mission, you'll add a content negotiation middleware that inspects the Accept header and automatically switches between HTML and JSON responses. No duplicate controllers required.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally on your machine with Apache and MySQL. There are no ongoing cloud costs.

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • The mvc_app MySQL database with the articles table.
  • Your local project directory containing all source code, Composer dependencies, and configuration files.

Keep everything running

No action needed. Choose this if you are still actively building on the architecture or want to keep developing the article CRUD app.

  • Your Apache server continues serving the project from the public/ directory.
  • The mvc_app database stays available for queries.
  • Your .env credentials remain configured and ready to use.

Pause - I'll come back to this later

Shut down running processes to free up memory. Your files and database stay intact for when you return.

  • Stop your Apache server if it is running as a standalone process. If Apache runs as a system service, it will restart on reboot automatically.
  • Stop the MySQL service to free memory. It will restart on your next boot or when you manually start it again.
  • Your project files on the Desktop remain untouched and ready to pick back up.

Delete - I don't want to use this again

Remove all project resources and start fresh. This permanently deletes your code and database.

  • Drop the MySQL database by opening your MySQL client and running:
  • Remove your local project directory by running the following in your terminal:

Drop the database first:

DROP DATABASE IF EXISTS mvc_app;

What does this command do?

This permanently deletes the mvc_app database and all its tables, including every article you created. The IF EXISTS clause prevents an error if the database was already removed.

Then remove your project files:

macOS

  • Delete the project folder by running:
cd ~/Desktop
rm -rf production-mvc

Windows

  • Delete the project folder by running:
cd ~/Desktop
Remove-Item -Recurse -Force production-mvc
  • Confirm the folder is gone by running ls on your Desktop. You should no longer see the project directory listed.

Nice Work!

Nice Work!

You just built a production-grade PHP MVC architecture from zero framework dependencies. Every piece of the kernel, from dependency injection to middleware dispatch to response emission, is code you wrote and fully understand.

You've learned how to:

  • Build a DI container with constructor autowiring via PHP's Reflection API, eliminating the service locator anti-pattern from your business code.
  • Compose a middleware pipeline that handles CSRF protection, secure sessions, and content negotiation as stackable, independent layers.
  • Wire a full article CRUD application with PDO prepared statements, declarative input validation, and Request/Response value objects that keep controllers free of superglobals.
  • Secret Mission: Add a content negotiation middleware that inspects the Accept header and returns JSON responses automatically, making the app API-ready without duplicating controllers.

Ready to quiz yourself?