Build a Modular MVC System in PHP

Wire PSR-standard Composer libraries into a modular MVC app with no framework.

Introduction

30 Second Summary

Think about how a car is assembled from standardized parts made by different manufacturers. The engine, brakes, and transmission all come from specialists, but they fit together because they follow shared standards.

In this project, you will build a modular MVC web application in PHP by wiring together battle-tested Composer libraries. Instead of writing your own router, container, or template engine, you will assemble production-grade components that follow PSR standards into a scalable architecture.

What You'll Build

A modular MVC PHP application assembled from PSR-7 compliant Composer libraries including league/route, PHP-DI, Twig, and Laminas Diactoros.

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

  • A working front controller that dispatches PSR-7 requests through a dependency injection container with full autowiring.
  • Two independent modules (Home and Blog) each with their own routes, controllers, models, and Twig templates.
  • Production-ready environment-based configuration via vlucas/phpdotenv and secure error handling with zero hardcoded values.
  • Secret Mission: Add an Admin module with PSR-15 middleware authentication and a create-post form.

Are there any prerequisites?

This project assumes you already have PHP 8.2.12, Composer, Apache with mod_rewrite enabled, and MySQL installed and running on your machine. Step 1 will walk you through initializing the project, but it does not cover installing these tools.

Before We Start

Before We Start...

Initialize the Project and Install Dependencies

Every PHP application starts with a foundation. In a traditional framework like Laravel or Symfony, that foundation is pre-built for you. In this project, you are building it yourself by wiring together individual Composer libraries that each handle one specific job.

In this step, you will create the project directory, define all your dependencies in a composer.json file, install them, and set up the environment configuration and folder structure that the rest of the project relies on.

In this step, get ready to:
  • Initialize a Composer project with PSR-4 autoloading and install six library dependencies.
  • Create environment configuration files for database and app settings.
  • Build the directory structure for your modules, templates, and front controller.
Create the project directory and install dependencies

Your project needs a home on your file system and a set of third-party libraries. The composer.json file tells Composer which packages to download and how to autoload your own code using the PSR-4 standard.

  • Open the integrated terminal in VS Code by pressing Ctrl+` (backtick) on both macOS and Windows.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir modular-mvc
cd modular-mvc
  • Confirm the folder exists by running:
ls

You should see an empty output since the folder is brand new.

  • Open this folder in VS Code by clicking File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the modular-mvc folder, and click Open.

Why open the folder in VS Code?

Opening a folder (not just a file) makes VS Code treat it as your workspace. The Explorer sidebar shows all files, the integrated terminal starts in the right directory, and extensions like PHP Intelephense pick up your project structure automatically.

Now you will create the Composer configuration file that defines your project and its dependencies.

  • In the VS Code Explorer sidebar, click the New File icon (the page with a plus sign).
  • Name the file composer.json and press Enter.
  • Paste the following content into composer.json:
{
    "name": "app/modular-mvc",
    "description": "Modular MVC PHP application built from Composer libraries",
    "type": "project",
    "require": {
        "php": "^8.2",
        "league/route": "^6.2",
        "laminas/laminas-diactoros": "^3.8",
        "laminas/laminas-httphandlerrunner": "^2.10",
        "php-di/php-di": "^7.1",
        "twig/twig": "^3.27",
        "vlucas/phpdotenv": "^5.6"
    },
    "autoload": {
        "psr-4": {
            "App\\": "src/",
            "Modules\\": "modules/"
        }
    }
}

What does this file do?

The require section lists six libraries that replace code you would normally write yourself. Each handles one concern: routing, HTTP messages, response emitting, dependency injection, templating, and environment config.

The autoload section tells Composer how to map namespaces to directories. App\ maps to src/ and Modules\ maps to modules/. This means PHP can find your classes automatically without manual require statements.

  • Save composer.json.
  • Install all the dependencies by running this command in your terminal:
composer install

Composer resolves all package versions, downloads them, and generates an autoloader. You should see it downloading each package and a summary line showing the number of installs.

  • Confirm the vendor/ directory was created by running:
ls vendor/

You should see directories like league, php-di, twig, and laminas listed inside.

Seeing an error during install?

  • If you see a PHP version mismatch, confirm you are running PHP 8.2+ by running php -v in your terminal.
  • If Composer cannot resolve dependencies, make sure you copied the JSON exactly with no trailing commas or missing quotes.
  • If you see a network error, check your internet connection and try running composer install again.

Help me debug my Composer install error.

What are these six libraries?

  • league/route handles URL routing with PSR-15 middleware support and route groups.
  • laminas/laminas-diactoros provides PSR-7 HTTP request and response objects.
  • laminas/laminas-httphandlerrunner sends the PSR-7 response back to the browser.
  • php-di/php-di is a PSR-11 dependency injection container with autowiring.
  • twig/twig is the template engine for rendering HTML views.
  • vlucas/phpdotenv loads environment variables from a .env file.
Create the environment configuration files

Hardcoding database credentials or app settings directly in PHP files is a security risk. The .env file keeps sensitive values out of your codebase, and vlucas/phpdotenv loads them into PHP's $_ENV at runtime.

  • Create a new file called .env in your project root by clicking the New File icon in the Explorer sidebar.
  • Paste the following content into .env:
APP_ENV=production
APP_DEBUG=false
DB_HOST=127.0.0.1
DB_NAME=modular_mvc
DB_USER=root
DB_PASS=
DB_CHARSET=utf8mb4

What do these variables control?

  • APP_ENV and APP_DEBUG control error display. In production mode, users never see stack traces.
  • DB_HOST through DB_CHARSET configure the MySQL connection. Your front controller will read these when building the PDO instance.
  • DB_PASS is blank by default. If your local MySQL has a root password, enter it here.
  • Save .env.

You also need a .env.example file. This is a template that you commit to version control so other developers know which variables to set up, without exposing your actual values.

  • Create another new file called .env.example.
  • Paste the same content into .env.example:
APP_ENV=production
APP_DEBUG=false
DB_HOST=127.0.0.1
DB_NAME=modular_mvc
DB_USER=root
DB_PASS=
DB_CHARSET=utf8mb4
  • Save .env.example.

Never commit your .env file

If you add Git to this project later, add .env to your .gitignore. The .env.example file is safe to commit because it acts as a template without real credentials.

Create the directory structure

Your modular architecture needs a specific folder layout. Each directory has a clear purpose that keeps your code organized as the application grows.

  • Create all the project directories at once by running this command in your terminal:
mkdir -p public config modules/Home modules/Blog templates src

What is each directory for?

  • public/ is the web-accessible root. Apache points here and only this folder is exposed to the internet.
  • config/ holds container definitions and module registration.
  • modules/Home/ and modules/Blog/ are self-contained modules with their own controllers, models, routes, and templates.
  • templates/ holds shared Twig layouts used by all modules.
  • src/ is reserved for shared application-level classes under the App\ namespace.
  • Confirm the directories were created by running:
ls

You should see composer.json, composer.lock, vendor/, public/, config/, modules/, templates/, and src/ listed in the output.

Missing directories in the listing?

  • Make sure your terminal is inside the modular-mvc directory. Run pwd to check your current location.
  • If .env is not visible, it is a hidden file. Run ls -a to see hidden files.

Help me fix my directory structure.

✔️ Awesome, I've got everything!

Great. Double check you have saved both your .env files and that your Explorer sidebar shows all the folders.

ⓧ I'd like to double check the full code

Here is the complete composer.json you should have:

{
    "name": "app/modular-mvc",
    "description": "Modular MVC PHP application built from Composer libraries",
    "type": "project",
    "require": {
        "php": "^8.2",
        "league/route": "^6.2",
        "laminas/laminas-diactoros": "^3.8",
        "laminas/laminas-httphandlerrunner": "^2.10",
        "php-di/php-di": "^7.1",
        "twig/twig": "^3.27",
        "vlucas/phpdotenv": "^5.6"
    },
    "autoload": {
        "psr-4": {
            "App\\": "src/",
            "Modules\\": "modules/"
        }
    }
}

Here is your .env file (and .env.example should be identical):

APP_ENV=production
APP_DEBUG=false
DB_HOST=127.0.0.1
DB_NAME=modular_mvc
DB_USER=root
DB_PASS=
DB_CHARSET=utf8mb4

Your project skeleton is ready with all dependencies installed. Next up, you will build the front controller that ties everything together and routes incoming HTTP requests to the right module.

Build the Front Controller and Apache Routing

In the previous step, you set up the project skeleton with all the Composer libraries installed and environment config ready. Now it's time to create the piece that ties everything together.

Every HTTP request to your application needs a single entry point that loads libraries, builds dependencies, routes the request to the right controller, and sends back a response. This is called a front controller pattern. You'll build this entry point, configure Apache to funnel all traffic through it, and define the dependency injection container that supplies services to your controllers.

In this step, get ready to:
  • Create a front controller that boots the app and dispatches requests.
  • Configure Apache rewrite rules to route all traffic to the front controller.
  • Define container factories for PDO and Twig.
Create the front controller entry point

The front controller lives at public/index.php. It's the only PHP file that Apache will execute directly. Every other file is loaded through Composer's autoloader.

  • Start building public/index.php by adding the bootstrap section that loads the autoloader, reads environment variables, and builds the DI container:
<?php

declare(strict_types=1);

require_once dirname(__DIR__) . '/vendor/autoload.php';

use DI\ContainerBuilder;
use Dotenv\Dotenv;
use Laminas\Diactoros\ServerRequestFactory;
use Laminas\HttpHandlerRunner\Emitter\SapiEmitter;
use League\Route\Router;
use League\Route\Strategy\ApplicationStrategy;

// Load environment variables from .env
$dotenv = Dotenv::createImmutable(dirname(__DIR__));
$dotenv->load();

// Build the PHP-DI container from the definitions file
$containerBuilder = new ContainerBuilder();
$containerBuilder->addDefinitions(dirname(__DIR__) . '/config/container.php');
$container = $containerBuilder->build();

// Create the router with a container-aware strategy
$strategy = new ApplicationStrategy();
$strategy->setContainer($container);

$router = new Router();
$router->setStrategy($strategy);

What does this code do?

This bootstrap section initializes the three core services your app needs before it can handle a request.

  • Dotenv::createImmutable() reads your .env file and loads its values into $_ENV so you never hardcode database credentials.
  • ContainerBuilder creates the PHP-DI container from your definitions file. This container knows how to create any service your controllers need.
  • ApplicationStrategy tells league/route to resolve controllers from the container. This means controllers get their dependencies automatically via autowiring.

You won't see a change yet. Continue adding the second half of the file below.

  • Add the following code directly below the router setup in public/index.php to load module routes, dispatch the request, and handle errors:
// Load routes from each registered module
$modules = require dirname(__DIR__) . '/config/modules.php';

foreach ($modules as $module) {
    $routeFile = dirname(__DIR__) . '/modules/' . $module . '/routes.php';
    if (file_exists($routeFile)) {
        $registerRoutes = require $routeFile;
        $registerRoutes($router);
    }
}

// Create a PSR-7 request from PHP's superglobals
$request = ServerRequestFactory::fromGlobals($_SERVER, $_GET, $_POST, $_COOKIE, $_FILES);

// Dispatch the request and handle errors gracefully
try {
    $response = $router->dispatch($request);
} catch (\League\Route\Http\Exception\NotFoundException $e) {
    $response = new \Laminas\Diactoros\Response\HtmlResponse('<h1>404 Not Found</h1>', 404);
} catch (\Throwable $e) {
    $debug = $_ENV['APP_DEBUG'] === 'true';
    $body = $debug ? '<pre>' . htmlspecialchars($e->getMessage()) . '</pre>' : '<h1>500 Internal Server Error</h1>';
    $response = new \Laminas\Diactoros\Response\HtmlResponse($body, 500);
}

// Send the response back to the browser
(new SapiEmitter())->emit($response);

What does this code do?

This half of the front controller handles the full request lifecycle.

  • The foreach loop iterates through your module list, loads each module's routes.php file, and registers its routes on the router.
  • ServerRequestFactory::fromGlobals() converts PHP's $_SERVER, $_GET, etc. into a PSR-7 request object that the router can process.
  • The try/catch block catches route-not-found errors (returning a 404) and any other exceptions (returning a 500). When APP_DEBUG is true, you see the error message. In production, you see a generic error page.
  • SapiEmitter sends the final PSR-7 response back to the browser as actual HTTP headers and body content.
  • Save public/index.php.

You won't be able to test this file yet because config/modules.php doesn't exist. You'll create it in a later step.

Seeing a syntax error in your editor?

  • Make sure you pasted both sections into the same file. The second block goes directly below the first with no duplicate <?php tag.
  • Check that your use statements are all at the top of the file, after declare(strict_types=1).

Help me fix a syntax error in my public/index.php front controller file.

✔️ Awesome, I've got everything!

Great. Double check you've saved public/index.php before moving on.

ⓧ I'd like to double check the full code

<?php

declare(strict_types=1);

require_once dirname(__DIR__) . '/vendor/autoload.php';

use DI\ContainerBuilder;
use Dotenv\Dotenv;
use Laminas\Diactoros\ServerRequestFactory;
use Laminas\HttpHandlerRunner\Emitter\SapiEmitter;
use League\Route\Router;
use League\Route\Strategy\ApplicationStrategy;

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

$containerBuilder = new ContainerBuilder();
$containerBuilder->addDefinitions(dirname(__DIR__) . '/config/container.php');
$container = $containerBuilder->build();

$strategy = new ApplicationStrategy();
$strategy->setContainer($container);

$router = new Router();
$router->setStrategy($strategy);

$modules = require dirname(__DIR__) . '/config/modules.php';

foreach ($modules as $module) {
    $routeFile = dirname(__DIR__) . '/modules/' . $module . '/routes.php';
    if (file_exists($routeFile)) {
        $registerRoutes = require $routeFile;
        $registerRoutes($router);
    }
}

$request = ServerRequestFactory::fromGlobals($_SERVER, $_GET, $_POST, $_COOKIE, $_FILES);

try {
    $response = $router->dispatch($request);
} catch (\League\Route\Http\Exception\NotFoundException $e) {
    $response = new \Laminas\Diactoros\Response\HtmlResponse('<h1>404 Not Found</h1>', 404);
} catch (\Throwable $e) {
    $debug = $_ENV['APP_DEBUG'] === 'true';
    $body = $debug ? '<pre>' . htmlspecialchars($e->getMessage()) . '</pre>' : '<h1>500 Internal Server Error</h1>';
    $response = new \Laminas\Diactoros\Response\HtmlResponse($body, 500);
}

(new SapiEmitter())->emit($response);
Create the Apache rewrite rules

Apache needs to know that every URL (except actual files like images or CSS) should be handled by index.php. An .htaccess file in the public/ directory tells Apache's mod_rewrite module to do exactly this.

  • Create public/.htaccess with the following rewrite rules:
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [QSA,L]

What do these rules do?

  • RewriteEngine On enables Apache's URL rewriting.
  • RewriteCond %{REQUEST_FILENAME} !-f checks that the request is NOT for an existing file (like an image or stylesheet).
  • RewriteCond %{REQUEST_FILENAME} !-d checks that the request is NOT for an existing directory.
  • RewriteRule ^ index.php [QSA,L] sends everything else to index.php. The QSA flag preserves query strings, and L stops further rule processing.
  • Save public/.htaccess.
  • Confirm the file exists by running:
ls public/.htaccess

You should see public/.htaccess in the output.

File not showing up?

  • Files starting with a dot are hidden by default. Make sure you named it .htaccess (with the leading dot) inside the public/ folder.
  • If using VS Code, check that your file explorer shows hidden files. Go to File > Preferences > Settings and search for "files.exclude" to verify .htaccess isn't hidden.

Help me find my .htaccess file in the public directory.

✔️ Awesome, I've got everything!

Great. Your Apache rewrite rules are ready.

ⓧ I'd like to double check the full code

RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [QSA,L]
Define the container service definitions

The PHP-DI container needs to know how to create two key services: a PDO database connection and a Twig template engine. You define these as factory functions in a definitions file.

  • Create config/container.php with the following factory definitions:
<?php

declare(strict_types=1);

use Twig\Environment;
use Twig\Loader\FilesystemLoader;

return [
    // Factory for the PDO database connection
    PDO::class => function () {
        $dsn = sprintf(
            'mysql:host=%s;dbname=%s;charset=%s',
            $_ENV['DB_HOST'],
            $_ENV['DB_NAME'],
            $_ENV['DB_CHARSET']
        );
        $pdo = new PDO($dsn, $_ENV['DB_USER'], $_ENV['DB_PASS'], [
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            PDO::ATTR_EMULATE_PREPARES => false,
        ]);
        return $pdo;
    },

    // Factory for the Twig template engine
    Environment::class => function () {
        $loader = new FilesystemLoader([
            dirname(__DIR__) . '/templates',
            dirname(__DIR__) . '/modules/Home/templates',
            dirname(__DIR__) . '/modules/Blog/templates',
        ]);
        return new Environment($loader, [
            'cache' => $_ENV['APP_DEBUG'] === 'true' ? false : dirname(__DIR__) . '/var/cache/twig',
            'debug' => $_ENV['APP_DEBUG'] === 'true',
            'auto_reload' => true,
        ]);
    },
];

What do these definitions do?

Each entry maps a class name to a factory function. When any controller type-hints PDO or Twig\Environment in its constructor, PHP-DI calls the matching factory and injects the result automatically.

  • The PDO factory builds a MySQL connection string from your .env values. It sets ERRMODE_EXCEPTION so database errors throw exceptions instead of failing silently. EMULATE_PREPARES => false ensures real prepared statements for SQL injection protection.
  • The Twig factory registers template paths for the shared templates/ directory plus each module's own templates/ folder. Caching is disabled when APP_DEBUG is true so you see template changes immediately during development.
  • Save config/container.php.
  • Confirm the file exists by running:
ls config/container.php

You should see config/container.php in the output.

PDO connection failing later?

  • Double check that your .env file has the correct DB_HOST, DB_NAME, DB_USER, and DB_PASS values matching your local MySQL setup.
  • Make sure MySQL is running on your machine before testing database connections.

Help me debug my PDO connection in the container definitions file.

✔️ Awesome, I've got everything!

Great. Double check you've saved config/container.php before moving on.

ⓧ I'd like to double check the full code

<?php

declare(strict_types=1);

use Twig\Environment;
use Twig\Loader\FilesystemLoader;

return [
    PDO::class => function () {
        $dsn = sprintf(
            'mysql:host=%s;dbname=%s;charset=%s',
            $_ENV['DB_HOST'],
            $_ENV['DB_NAME'],
            $_ENV['DB_CHARSET']
        );
        $pdo = new PDO($dsn, $_ENV['DB_USER'], $_ENV['DB_PASS'], [
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            PDO::ATTR_EMULATE_PREPARES => false,
        ]);
        return $pdo;
    },

    Environment::class => function () {
        $loader = new FilesystemLoader([
            dirname(__DIR__) . '/templates',
            dirname(__DIR__) . '/modules/Home/templates',
            dirname(__DIR__) . '/modules/Blog/templates',
        ]);
        return new Environment($loader, [
            'cache' => $_ENV['APP_DEBUG'] === 'true' ? false : dirname(__DIR__) . '/var/cache/twig',
            'debug' => $_ENV['APP_DEBUG'] === 'true',
            'auto_reload' => true,
        ]);
    },
];

Your application's plumbing is in place. The front controller knows how to boot, route, and respond. Next up, you'll create the Home module to give the router its first actual route to handle.

Create the Home Module

Your front controller, container, and Apache routing are all in place. The application can boot, but it has nothing to show yet.

In this step, you'll build the first module: Home. A module is a self-contained directory with its own controller, routes, and templates. The Home module proves that your entire library stack works together end-to-end.

In this step, get ready to:
  • Create the HomeController with Twig template rendering.
  • Register the Home module's route to handle GET requests to /.
  • Build a shared layout template and the Home page template.
Create the HomeController with Twig rendering

Every module needs a controller to handle incoming requests. The HomeController will receive a PSR-7 request, render a Twig template, and return an HTML response.

  • Create a new file at modules/Home/Controllers/HomeController.php inside your modular-mvc project folder.
  • Add the following code to the file:
<?php

declare(strict_types=1);

namespace Modules\Home\Controllers;

use Laminas\Diactoros\Response\HtmlResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Twig\Environment;

class HomeController
{
    public function __construct(
        private Environment $twig
    ) {}

    public function index(ServerRequestInterface $request): ResponseInterface
    {
        // Render the Home module's Twig template
        $html = $this->twig->render('index.html.twig');
        return new HtmlResponse($html);
    }
}

What does this code do?

The constructor uses PHP 8's constructor promotion to inject Twig\Environment as a private property. PHP-DI autowires this automatically because the container definition for Environment::class already exists in config/container.php.

The index method receives a PSR-7 ServerRequestInterface and returns a ResponseInterface. It renders the Twig template and wraps the output in an HtmlResponse.

What is autowiring?

Autowiring means the container reads your constructor's type-hints and automatically resolves each dependency. You never have to manually wire HomeController to its Twig\Environment dependency. PHP-DI handles it for you.

  • Save HomeController.php.
  • You won't see a change in the browser yet. The controller needs a route and templates before it can render anything.

✔️ Awesome, I've got everything!

Great. Make sure the file is saved before moving on.

ⓧ I'd like to double check the full code

<?php

declare(strict_types=1);

namespace Modules\Home\Controllers;

use Laminas\Diactoros\Response\HtmlResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Twig\Environment;

class HomeController
{
    public function __construct(
        private Environment $twig
    ) {}

    public function index(ServerRequestInterface $request): ResponseInterface
    {
        $html = $this->twig->render('index.html.twig');
        return new HtmlResponse($html);
    }
}
Register the Home module's route

Each module declares its own routes in a routes.php file. This file returns a callable that accepts the router and maps URL patterns to controller methods.

  • Create a new file at modules/Home/routes.php and add the following code:
<?php

declare(strict_types=1);

use League\Route\Router;
use Modules\Home\Controllers\HomeController;

// Return a callable that registers this module's routes
return function (Router $router): void {
    $router->map('GET', '/', [HomeController::class, 'index']);
};

What does this code do?

This file returns an anonymous function. When public/index.php iterates your modules, it calls this function and passes the router in. The function then maps GET / to HomeController::index.

The [HomeController::class, 'index'] array tells league/route to resolve HomeController from the PSR-11 container (PHP-DI), which autowires its Twig dependency.

  • Save routes.php.
  • The route is registered but still can't render. The next substep adds the templates it needs.

✔️ Awesome, I've got everything!

Great. Make sure the file is saved before moving on.

ⓧ I'd like to double check the full code

<?php

declare(strict_types=1);

use League\Route\Router;
use Modules\Home\Controllers\HomeController;

return function (Router $router): void {
    $router->map('GET', '/', [HomeController::class, 'index']);
};
Build the Twig templates

Twig uses template inheritance. You create one shared layout with a placeholder block, then each page template extends the layout and fills in its own content.

  • Create the shared layout file at templates/layout.html.twig (in the project root's templates/ folder) and add this code:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{% block title %}Modular MVC{% endblock %}</title>
    <style>
        body { font-family: system-ui, sans-serif; max-width: 800px; margin: 0 auto; padding: 2rem; }
        nav { margin-bottom: 2rem; }
        nav a { margin-right: 1rem; }
    </style>
</head>
<body>
    <nav>
        <a href="/">Home</a>
        <a href="/blog">Blog</a>
    </nav>
    {% block content %}{% endblock %}
</body>
</html>

What does this template do?

This is the base layout that every page extends. It provides the HTML5 boilerplate, a navigation bar with links to Home and Blog, and a {% block content %} placeholder where child templates inject their page-specific content.

The {% block title %} provides a default page title of "Modular MVC" that child templates can override.

  • Save layout.html.twig.
  • Now create the Home page template at modules/Home/templates/index.html.twig and add this code:
{% extends "layout.html.twig" %}

{% block title %}Home - Modular MVC{% endblock %}

{% block content %}
<h1>Welcome to Modular MVC</h1>
<p>This application is built entirely from Composer libraries. No framework.</p>
<ul>
    <li><strong>Router:</strong> league/route (PSR-15)</li>
    <li><strong>Container:</strong> PHP-DI (PSR-11)</li>
    <li><strong>HTTP:</strong> Laminas Diactoros (PSR-7)</li>
    <li><strong>Templates:</strong> Twig</li>
</ul>
{% endblock %}

What does this template do?

The {% extends "layout.html.twig" %} line tells Twig to use the shared layout as the base. Then it overrides block title with a custom page title and fills block content with a welcome message and a list of the libraries powering the app.

  • Save index.html.twig.
  • Open your browser and visit http://localhost/ (or your Apache virtual host URL).
  • You should see the Home page with a "Welcome to Modular MVC" heading, a description paragraph, and a bulleted list of libraries (Router, Container, HTTP, Templates).

Seeing a 500 error or blank page?

  • Check that templates/layout.html.twig is in the project root's templates/ folder, not inside modules/Home/templates/. Twig loads templates from both paths, but the layout must be in the shared folder.
  • Verify your .env has APP_DEBUG=true temporarily so you can see the actual error message instead of a generic 500 page.
  • Make sure the namespace in HomeController.php is exactly Modules\Home\Controllers. The PSR-4 autoloader in composer.json maps Modules\\ to the modules/ directory.

Help me debug my Home module 500 error

✔️ Awesome, I've got everything!

Great. Double check you've saved all four files before moving on.

ⓧ I'd like to double check the full code

Here are all four files you created in this step:

<?php

declare(strict_types=1);

namespace Modules\Home\Controllers;

use Laminas\Diactoros\Response\HtmlResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Twig\Environment;

class HomeController
{
    public function __construct(
        private Environment $twig
    ) {}

    public function index(ServerRequestInterface $request): ResponseInterface
    {
        $html = $this->twig->render('index.html.twig');
        return new HtmlResponse($html);
    }
}
<?php

declare(strict_types=1);

use League\Route\Router;
use Modules\Home\Controllers\HomeController;

return function (Router $router): void {
    $router->map('GET', '/', [HomeController::class, 'index']);
};
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{% block title %}Modular MVC{% endblock %}</title>
    <style>
        body { font-family: system-ui, sans-serif; max-width: 800px; margin: 0 auto; padding: 2rem; }
        nav { margin-bottom: 2rem; }
        nav a { margin-right: 1rem; }
    </style>
</head>
<body>
    <nav>
        <a href="/">Home</a>
        <a href="/blog">Blog</a>
    </nav>
    {% block content %}{% endblock %}
</body>
</html>
{% extends "layout.html.twig" %}

{% block title %}Home - Modular MVC{% endblock %}

{% block content %}
<h1>Welcome to Modular MVC</h1>
<p>This application is built entirely from Composer libraries. No framework.</p>
<ul>
    <li><strong>Router:</strong> league/route (PSR-15)</li>
    <li><strong>Container:</strong> PHP-DI (PSR-11)</li>
    <li><strong>HTTP:</strong> Laminas Diactoros (PSR-7)</li>
    <li><strong>Templates:</strong> Twig</li>
</ul>
{% endblock %}

Your first module is live. The entire request lifecycle works: Apache routes to the front controller, league/route dispatches to HomeController, PHP-DI autowires Twig, and Twig renders the template with layout inheritance. Next up, you'll build a Blog module backed by a MySQL database.

Create the Blog Module with Database

Your Home module proves the architecture works. Now you need content that lives in a database.

The Blog module introduces a real data layer. It reads posts from a MySQL table, passes them through a repository class, and renders them with Twig templates. PHP-DI autowires every dependency so you never manually construct a controller.

In this step, get ready to:
  • Create the posts MySQL table and seed it with sample data.
  • Build a PostRepository that reads posts using PDO prepared statements.
  • Create the BlogController, routes, and Twig templates for listing and viewing posts.
Create the posts table and PostRepository

Every blog needs stored content. You will create a MySQL table to hold posts, then wrap database access in a repository class that uses prepared statements to prevent SQL injection.

  • Create a database/ directory inside your modular-mvc project folder by running this command:
mkdir database
  • Create database/schema.sql with the table definition and seed data by pasting this code:
CREATE TABLE IF NOT EXISTS posts (
    id INT AUTO_INCREMENT PRIMARY KEY,
    title VARCHAR(255) NOT NULL,
    slug VARCHAR(255) NOT NULL UNIQUE,
    content TEXT NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

INSERT INTO posts (title, slug, content) VALUES
('First Post', 'first-post', 'This is the content of the first blog post.'),
('Second Post', 'second-post', 'This is the content of the second blog post.');

What does this SQL do?

The CREATE TABLE statement defines five columns: an auto-incrementing id, a title, a URL-friendly slug (marked UNIQUE so no two posts share the same URL), the post content, and a created_at timestamp.

The two INSERT statements seed the table with sample posts so you have data to display immediately.

  • First, create the modular_mvc database if it does not exist yet by running:
mysql -u root -e "CREATE DATABASE IF NOT EXISTS modular_mvc"
  • Run the schema file against your MySQL database by running this command:
mysql -u root modular_mvc < database/schema.sql
  • Verify the table was created and seeded by running:
mysql -u root modular_mvc -e "SELECT * FROM posts"

You should see two rows: "First Post" and "Second Post" with their slugs and timestamps.

Not seeing the posts table?

  • Make sure MySQL is running. Check with mysql -u root -e "SELECT 1" to confirm you can connect.
  • If you set a MySQL root password, add -p to each command (e.g. mysql -u root -p modular_mvc < database/schema.sql). Update your .env file's DB_PASS to match.
  • Help me troubleshoot my MySQL connection

Now you need a PHP class that talks to this table. The repository pattern wraps all SQL queries behind clean method signatures, keeping database logic out of your controllers.

  • Create the modules/Blog/Models/ directory by running:
mkdir -p modules/Blog/Models
  • Create modules/Blog/Models/PostRepository.php and paste this code:
<?php

declare(strict_types=1);

namespace Modules\Blog\Models;

use PDO;

class PostRepository
{
    public function __construct(
        private PDO $pdo
    ) {}

    public function findAll(): array
    {
        // Select only the columns needed for the listing page
        $stmt = $this->pdo->query('SELECT id, title, slug, created_at FROM posts ORDER BY created_at DESC');
        return $stmt->fetchAll();
    }

    public function findBySlug(string $slug): ?array
    {
        // Use a prepared statement to prevent SQL injection
        $stmt = $this->pdo->prepare('SELECT * FROM posts WHERE slug = :slug LIMIT 1');
        $stmt->execute(['slug' => $slug]);
        $post = $stmt->fetch();
        return $post ?: null;
    }
}

What does this code do?

  • The constructor accepts a PDO instance. PHP-DI sees the type hint and automatically injects the PDO connection you defined in config/container.php.
  • The findAll() method fetches every post ordered by newest first. It only selects the columns needed for the listing page.
  • The findBySlug() method uses a prepared statement with a named parameter (:slug) to safely query a single post. This prevents SQL injection regardless of what value is passed in.
  • Save PostRepository.php.

You won't see a visible change yet. The next chunk creates the controller that uses this repository.

Seeing a syntax error?

  • Confirm the namespace matches the file path exactly: Modules\Blog\Models maps to modules/Blog/Models/.
  • Make sure you have the opening <?php tag at the very top of the file with no whitespace before it.
  • Help me fix my PostRepository syntax error

✔️ Awesome, I've got everything!

Great. Double check you have saved PostRepository.php before moving on.

ⓧ I'd like to double check the full code

<?php

declare(strict_types=1);

namespace Modules\Blog\Models;

use PDO;

class PostRepository
{
    public function __construct(
        private PDO $pdo
    ) {}

    public function findAll(): array
    {
        $stmt = $this->pdo->query('SELECT id, title, slug, created_at FROM posts ORDER BY created_at DESC');
        return $stmt->fetchAll();
    }

    public function findBySlug(string $slug): ?array
    {
        $stmt = $this->pdo->prepare('SELECT * FROM posts WHERE slug = :slug LIMIT 1');
        $stmt->execute(['slug' => $slug]);
        $post = $stmt->fetch();
        return $post ?: null;
    }
}
Build the BlogController

The controller connects your repository to your templates. It fetches data, then passes it to Twig for rendering. PHP-DI autowires both dependencies through constructor type hints.

  • Create the modules/Blog/Controllers/ directory by running:
mkdir -p modules/Blog/Controllers
  • Create modules/Blog/Controllers/BlogController.php and paste this code:
<?php

declare(strict_types=1);

namespace Modules\Blog\Controllers;

use Laminas\Diactoros\Response\HtmlResponse;
use League\Route\Http\Exception\NotFoundException;
use Modules\Blog\Models\PostRepository;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Twig\Environment;

class BlogController
{
    public function __construct(
        private PostRepository $postRepository,
        private Environment $twig
    ) {}

    public function list(ServerRequestInterface $request): ResponseInterface
    {
        // Fetch all posts from the repository
        $posts = $this->postRepository->findAll();
        $html = $this->twig->render('blog/list.html.twig', ['posts' => $posts]);
        return new HtmlResponse($html);
    }

    public function show(ServerRequestInterface $request, array $args): ResponseInterface
    {
        // Look up a single post by its URL slug
        $post = $this->postRepository->findBySlug($args['slug']);

        if ($post === null) {
            throw new NotFoundException();
        }

        $html = $this->twig->render('blog/show.html.twig', ['post' => $post]);
        return new HtmlResponse($html);
    }
}

What does this code do?

  • The constructor type-hints PostRepository and Twig\Environment. PHP-DI sees these type hints and automatically provides instances of both. No manual wiring needed.
  • The list() method fetches all posts and renders the listing template. It passes the posts array to Twig as a template variable.
  • The show() method receives route parameters via the $args array. If findBySlug() returns null, it throws NotFoundException which league/route catches and converts to a 404 response.
  • Save BlogController.php.

You won't see a visible change yet. The controller needs routes to map URLs to its methods.

Seeing a class not found error?

  • Make sure the namespace is Modules\Blog\Controllers and the file is at modules/Blog/Controllers/BlogController.php. PSR-4 autoloading requires this exact match.
  • Check that the use statements at the top match exactly. A typo in Modules\Blog\Models\PostRepository causes a fatal error.
  • Help me fix my class not found error

✔️ Awesome, I've got everything!

Great. Double check you have saved BlogController.php before moving on.

ⓧ I'd like to double check the full code

<?php

declare(strict_types=1);

namespace Modules\Blog\Controllers;

use Laminas\Diactoros\Response\HtmlResponse;
use League\Route\Http\Exception\NotFoundException;
use Modules\Blog\Models\PostRepository;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Twig\Environment;

class BlogController
{
    public function __construct(
        private PostRepository $postRepository,
        private Environment $twig
    ) {}

    public function list(ServerRequestInterface $request): ResponseInterface
    {
        $posts = $this->postRepository->findAll();
        $html = $this->twig->render('blog/list.html.twig', ['posts' => $posts]);
        return new HtmlResponse($html);
    }

    public function show(ServerRequestInterface $request, array $args): ResponseInterface
    {
        $post = $this->postRepository->findBySlug($args['slug']);

        if ($post === null) {
            throw new NotFoundException();
        }

        $html = $this->twig->render('blog/show.html.twig', ['post' => $post]);
        return new HtmlResponse($html);
    }
}
Register routes and create templates

The Blog module needs its own route file and Twig templates. The route file groups both endpoints under the /blog prefix using league/route's route group feature.

  • Create modules/Blog/routes.php and paste this code:
<?php

declare(strict_types=1);

use League\Route\Router;
use Modules\Blog\Controllers\BlogController;

// Return a callable that registers this module's routes
return function (Router $router): void {
    $router->group('/blog', function ($group): void {
        $group->map('GET', '/', [BlogController::class, 'list']);
        $group->map('GET', '/{slug}', [BlogController::class, 'show']);
    });
};

What does this code do?

  • The file returns a callable (a closure) that accepts the router instance. This is the same pattern the Home module uses.
  • The $router->group('/blog', ...) call prefixes all routes inside the callback with /blog. So / becomes /blog/ and /{slug} becomes /blog/{slug}.
  • The {slug} placeholder captures the URL segment and passes it to the controller in the $args array.
  • Save modules/Blog/routes.php.

Now create the Twig templates that the controller renders.

  • Create the template directory by running:
mkdir -p modules/Blog/templates/blog
  • Create modules/Blog/templates/blog/list.html.twig and paste this code:
{% extends "layout.html.twig" %}

{% block title %}Blog - Modular MVC{% endblock %}

{% block content %}
<h1>Blog Posts</h1>
{% if posts is empty %}
    <p>No posts yet.</p>
{% else %}
    {% for post in posts %}
    <article>
        <h2><a href="/blog/{{ post.slug }}">{{ post.title }}</a></h2>
        <time>{{ post.created_at }}</time>
    </article>
    {% endfor %}
{% endif %}
{% endblock %}

What does this template do?

It extends the shared layout.html.twig (which provides the HTML skeleton and navigation). The {% for post in posts %} loop renders each post as an article with a linked title. Twig auto-escapes all output by default, preventing XSS vulnerabilities.

  • Save list.html.twig.
  • Create modules/Blog/templates/blog/show.html.twig and paste this code:
{% extends "layout.html.twig" %}

{% block title %}{{ post.title }} - Blog{% endblock %}

{% block content %}
<article>
    <h1>{{ post.title }}</h1>
    <time>{{ post.created_at }}</time>
    <div>{{ post.content|raw }}</div>
</article>
<p><a href="/blog">&larr; Back to Blog</a></p>
{% endblock %}

What does this template do?

The show template renders a single post's full content. The |raw filter on post.content allows HTML in the content field to render unescaped. The back link returns the reader to the blog listing.

  • Save show.html.twig.
  • Confirm all Blog module files exist by running:
ls modules/Blog/

You should see Controllers, Models, routes.php, and templates listed.

Missing a directory or file?

  • Run ls modules/Blog/Controllers/ and ls modules/Blog/Models/ to confirm the PHP files are inside the correct subdirectories.
  • Check that your templates are at modules/Blog/templates/blog/ (note the nested blog/ subdirectory). Twig resolves the path blog/list.html.twig relative to the module's templates/ folder.
  • Help me verify my Blog module directory structure

✔️ Awesome, I've got everything!

Great. All Blog module files are in place.

ⓧ I'd like to double check the full code

<?php

declare(strict_types=1);

use League\Route\Router;
use Modules\Blog\Controllers\BlogController;

return function (Router $router): void {
    $router->group('/blog', function ($group): void {
        $group->map('GET', '/', [BlogController::class, 'list']);
        $group->map('GET', '/{slug}', [BlogController::class, 'show']);
    });
};
{% extends "layout.html.twig" %}

{% block title %}Blog - Modular MVC{% endblock %}

{% block content %}
<h1>Blog Posts</h1>
{% if posts is empty %}
    <p>No posts yet.</p>
{% else %}
    {% for post in posts %}
    <article>
        <h2><a href="/blog/{{ post.slug }}">{{ post.title }}</a></h2>
        <time>{{ post.created_at }}</time>
    </article>
    {% endfor %}
{% endif %}
{% endblock %}
{% extends "layout.html.twig" %}

{% block title %}{{ post.title }} - Blog{% endblock %}

{% block content %}
<article>
    <h1>{{ post.title }}</h1>
    <time>{{ post.created_at }}</time>
    <div>{{ post.content|raw }}</div>
</article>
<p><a href="/blog">&larr; Back to Blog</a></p>
{% endblock %}

Your Blog module is fully built. Next up, you will wire both modules together so the application actually serves these routes.

Wire Modules Together and Configure Production Defaults

You have two fully independent modules sitting in your project. Each has its own controllers, models, routes, and templates. The problem is that the front controller doesn't yet know which modules exist.

In this step, you'll create the module registry file that ties everything together. You'll also review the production-safe error handling and confirm the full application works end to end.

In this step, get ready to:
  • Create the module registry that activates your Home and Blog modules.
  • Review the production error handling and Twig template path configuration.
  • Test the full application across all routes.
Create the module registry file

The front controller (public/index.php) already contains a loop that reads from config/modules.php and loads each module's routes. That file doesn't exist yet, so the application would crash if you tried to load it right now.

  • Create a new file called modules.php inside your config/ directory, then paste this code:
<?php

declare(strict_types=1);

// Each entry maps to a directory under modules/
return [
    'Home',
    'Blog',
];

What does this file do?

This file returns a plain PHP array of module names. Each name corresponds to a directory under modules/.

The front controller iterates this array and loads each module's routes.php file. To add a new module in the future, you only need to create its folder and add its name to this array.

  • Save config/modules.php.

Now look at the code in public/index.php that consumes this registry:

$modules = require dirname(__DIR__) . '/config/modules.php';

foreach ($modules as $module) {
    $routeFile = dirname(__DIR__) . '/modules/' . $module . '/routes.php';
    if (file_exists($routeFile)) {
        $registerRoutes = require $routeFile;
        $registerRoutes($router);
    }
}

How does module loading work?

The loop builds a path to each module's routes.php using the module name from the array. The file_exists() check prevents a crash if a module is registered but hasn't created its routes file yet.

Each routes.php returns a callable that accepts the router. The front controller calls it immediately, registering that module's routes onto the shared router instance.

Scaling to new modules

Adding a third module (say, an API module) requires only two things: creating the modules/Api/ directory with a routes.php file, and adding 'Api' to the array in config/modules.php. No changes to the front controller needed.

Review the production error handling and template configuration

Before testing, let's confirm that the existing production defaults in your front controller and container will behave correctly.

  • In public/index.php, find the try/catch block near the bottom of the file:
try {
    $response = $router->dispatch($request);
} catch (\League\Route\Http\Exception\NotFoundException $e) {
    $response = new \Laminas\Diactoros\Response\HtmlResponse('<h1>404 Not Found</h1>', 404);
} catch (\Throwable $e) {
    $debug = $_ENV['APP_DEBUG'] === 'true';
    $body = $debug ? '<pre>' . htmlspecialchars($e->getMessage()) . '</pre>' : '<h1>500 Internal Server Error</h1>';
    $response = new \Laminas\Diactoros\Response\HtmlResponse($body, 500);
}

Why two catch blocks?

The first catch handles NotFoundException from league/route. When a user visits a URL that doesn't match any route, they get a clean 404 page instead of a stack trace.

The second catch handles any other exception. It checks APP_DEBUG from your .env file. When debug is false (production), users see a generic 500 error. When debug is true, you see the actual error message for troubleshooting.

  • Now open config/container.php and find the Environment::class definition:
Environment::class => function () {
    $loader = new FilesystemLoader([
        dirname(__DIR__) . '/templates',
        dirname(__DIR__) . '/modules/Home/templates',
        dirname(__DIR__) . '/modules/Blog/templates',
    ]);
    return new Environment($loader, [
        'cache' => $_ENV['APP_DEBUG'] === 'true' ? false : dirname(__DIR__) . '/var/cache/twig',
        'debug' => $_ENV['APP_DEBUG'] === 'true',
        'auto_reload' => true,
    ]);
},

What does the Twig configuration do?

The FilesystemLoader receives an array of directories to search for templates. This is how Twig finds both the shared layout.html.twig in templates/ and each module's templates in their own folders.

The cache option compiles templates to PHP files in production for faster rendering. In debug mode, caching is disabled so you see template changes immediately.

Why does PHP-DI not need manual controller registration?

PHP-DI uses autowiring. When league/route asks the container for BlogController, PHP-DI reads its constructor type hints (PostRepository and Environment), resolves those automatically, and injects them. No manual $container->set() calls needed for controllers or repositories.

Test the full application

Everything is wired together. Time to confirm all routes respond correctly.

  • Start the PHP development server by running this command from your project root (modular-mvc folder):
php -S localhost:8000 -t public

What does this command do?

The -S flag starts PHP's built-in development server on localhost:8000. The -t public flag sets the document root to your public/ directory, so all requests hit index.php through the front controller pattern.

  • Open your browser and visit http://localhost:8000/.

You should see the Home page with the heading "Welcome to Modular MVC" and a list of the libraries your application uses.

  • Click the Blog link in the navigation bar, or visit http://localhost:8000/blog directly.

You should see the blog post listing with "First Post" and "Second Post" from your database seed data.

  • Click First Post to visit http://localhost:8000/blog/first-post.

You should see the full post content with a "Back to Blog" link at the bottom.

  • Test the 404 handling by visiting http://localhost:8000/blog/nonexistent.

You should see the "404 Not Found" message. This confirms your error handling catches missing posts and returns a proper HTTP 404 response.

Seeing a blank page or error?

  • If you see a 500 error about a missing file, confirm that config/modules.php exists and returns a PHP array.
  • If the blog page shows "No posts yet", confirm your MySQL database has the posts table with seed data from database/schema.sql.
  • If you get a database connection error, check that your .env file has the correct DB_HOST, DB_NAME, DB_USER, and DB_PASS values for your local MySQL instance.
  • To see detailed errors while debugging, set APP_DEBUG=true in your .env file and restart the server.

Help me debug my modular MVC application

✔️ Awesome, I've got everything!

Great. Double check you've saved config/modules.php and confirmed all routes respond correctly.

ⓧ I'd like to double check the full code

Here is the full config/modules.php file:

<?php

declare(strict_types=1);

return [
    'Home',
    'Blog',
];

Here is the full public/index.php for reference:

<?php

declare(strict_types=1);

require_once dirname(__DIR__) . '/vendor/autoload.php';

use DI\ContainerBuilder;
use Dotenv\Dotenv;
use Laminas\Diactoros\ServerRequestFactory;
use Laminas\HttpHandlerRunner\Emitter\SapiEmitter;
use League\Route\Router;
use League\Route\Strategy\ApplicationStrategy;

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

$containerBuilder = new ContainerBuilder();
$containerBuilder->addDefinitions(dirname(__DIR__) . '/config/container.php');
$container = $containerBuilder->build();

$strategy = new ApplicationStrategy();
$strategy->setContainer($container);

$router = new Router();
$router->setStrategy($strategy);

$modules = require dirname(__DIR__) . '/config/modules.php';

foreach ($modules as $module) {
    $routeFile = dirname(__DIR__) . '/modules/' . $module . '/routes.php';
    if (file_exists($routeFile)) {
        $registerRoutes = require $routeFile;
        $registerRoutes($router);
    }
}

$request = ServerRequestFactory::fromGlobals($_SERVER, $_GET, $_POST, $_COOKIE, $_FILES);

try {
    $response = $router->dispatch($request);
} catch (\League\Route\Http\Exception\NotFoundException $e) {
    $response = new \Laminas\Diactoros\Response\HtmlResponse('<h1>404 Not Found</h1>', 404);
} catch (\Throwable $e) {
    $debug = $_ENV['APP_DEBUG'] === 'true';
    $body = $debug ? '<pre>' . htmlspecialchars($e->getMessage()) . '</pre>' : '<h1>500 Internal Server Error</h1>';
    $response = new \Laminas\Diactoros\Response\HtmlResponse($body, 500);
}

(new SapiEmitter())->emit($response);

Here is the full config/container.php for reference:

<?php

declare(strict_types=1);

use Twig\Environment;
use Twig\Loader\FilesystemLoader;

return [
    PDO::class => function () {
        $dsn = sprintf(
            'mysql:host=%s;dbname=%s;charset=%s',
            $_ENV['DB_HOST'],
            $_ENV['DB_NAME'],
            $_ENV['DB_CHARSET']
        );
        $pdo = new PDO($dsn, $_ENV['DB_USER'], $_ENV['DB_PASS'], [
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            PDO::ATTR_EMULATE_PREPARES => false,
        ]);
        return $pdo;
    },

    Environment::class => function () {
        $loader = new FilesystemLoader([
            dirname(__DIR__) . '/templates',
            dirname(__DIR__) . '/modules/Home/templates',
            dirname(__DIR__) . '/modules/Blog/templates',
        ]);
        return new Environment($loader, [
            'cache' => $_ENV['APP_DEBUG'] === 'true' ? false : dirname(__DIR__) . '/var/cache/twig',
            'debug' => $_ENV['APP_DEBUG'] === 'true',
            'auto_reload' => true,
        ]);
    },
];

Your modular MVC application is fully wired and production-ready. You've proven that composing PSR-standard Composer libraries creates a scalable architecture with strict module boundaries and zero custom framework code.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.

Resources you used:

  • The modular-mvc project folder on your Desktop (all PHP files, templates, config, and vendor/ packages).
  • The modular_mvc MySQL database with the posts table.
  • The .env file containing your database credentials and admin password hash.

Keep everything running

No action needed. Choose this if you're still actively building or want to keep experimenting with new modules.

  • Your project folder stays on your Desktop, ready to use.
  • Your MySQL modular_mvc database remains available for queries.
  • Apache continues serving the application at localhost whenever the server is running.

Pause - I'll come back to this later

Stop running processes to free up memory while keeping all your files intact.

  • Stop Apache if it is running as a standalone process. Your project files and database remain untouched for when you return.
  • Stop MySQL if you don't need it for other projects. The modular_mvc database persists on disk.
  • Your modular-mvc folder stays on your Desktop. When you return, start Apache and MySQL again and the application will work immediately.

Delete - I don't want to use this again

Remove all project resources and start fresh. This cannot be undone.

  • Delete the project folder by running this command in your terminal:

macOS

rm -rf ~/Desktop/modular-mvc

Windows

rmdir /s /q %USERPROFILE%\Desktop\modular-mvc
  • Drop the MySQL database by running this command:
mysql -u root -e "DROP DATABASE modular_mvc;"
  • If your MySQL user requires a password, add -p to the command and enter your password when prompted.

Nice Work!

Nice Work!

You just built a production-grade modular MVC application without a single framework dependency. Every piece of infrastructure comes from a battle-tested Composer library, wired together with PSR-standard interfaces.

You've learned how to:

  • Assemble a full MVC architecture from PSR-standard Composer libraries including league/route, PHP-DI, Twig, and Laminas Diactoros.
  • Create independent modules (Home and Blog) each with their own routes, controllers, models, and templates, registered through a single config array.
  • Wire dependencies automatically with PHP-DI autowiring and secure database access with PDO prepared statements, all configured through environment variables with zero hardcoded values.
  • Added an Admin module with PSR-15 middleware authentication, CSRF protection, and session-based login, proving that module isolation scales cleanly.

Ready to quiz yourself?