
Chapter 18: Project: Structuring a Simple Application
Overview
We've built a router, which is the heart of a modern application's architecture. But right now, our route handlers are simple anonymous functions living in index.php. As our application grows, this will become incredibly messy.
To solve this, we'll introduce a foundational architectural pattern: Model-View-Controller (MVC). MVC is a way of organizing your code that separates concerns into three distinct layers:
- Model: Represents your application's data and business logic. Think of this as the code that interacts with your database (e.g., a
Usermodel or aPostmodel). - View: Represents the presentation layer—the HTML that gets sent to the user. It should be as "dumb" as possible, only responsible for displaying data it's given.
- Controller: Acts as the intermediary. It receives the request from the router, uses the Model to fetch or update data, and then passes that data to the appropriate View to be rendered.
In this chapter, we'll refactor our project to use this powerful and scalable structure.
Prerequisites
Before starting this chapter, ensure you have:
- Completed Chapter 17: You should have a working router from the previous chapter
- PHP 8.4 installed and running
- Composer installed and configured
- A working
simple-blogproject with the router from Chapter 17 - Estimated Time: ~25 minutes
What You'll Build
By the end of this chapter, you'll have:
- A clean MVC directory structure (
Controllers/,Models/,Views/) - A
PageControllerwith methods for handling home and about pages - An enhanced router that can dispatch to controller methods
- A simple view rendering system with data passing capabilities
- A layout system to eliminate duplicate HTML across views
- A helper function to load and render view templates
- Working routes that demonstrate the separation of concerns
Objectives
- Understand the roles of Model, View, and Controller.
- Create a directory structure that reflects the MVC pattern.
- Refactor our router to call controller methods instead of closures.
- Create a simple templating system for rendering views and passing data to them.
- Implement a layout system to eliminate duplicate HTML across views.
- Use output buffering to compose views within layouts.
MVC Architecture: Visual Overview
Here's how the Model-View-Controller pattern organizes your application:
Key Layers:
- Model: Database interaction, data validation, business rules
- View: HTML templates, presentation logic only
- Controller: Coordinates between Model and View, handles requests
- Router: Determines which Controller to use based on URL
Data Flow:
- Browser sends request to Router
- Router passes to appropriate Controller
- Controller requests data from Model
- Model queries Database
- Controller passes data to View
- View renders HTML response
Step 1: Creating the Application Structure (~4 min)
Goal: Set up the MVC directory structure and reorganize existing files.
Let's organize our simple-blog project to follow the MVC pattern.
Create New Directories:
Inside your
src/directory, create the following new folders:
# From your project root
mkdir -p src/Controllers src/Models src/Views src/RoutingThe Views directory is where we'll put our HTML templates. Since views are not PHP classes, they don't need to follow PSR-4, but it's good practice to keep them organized.
Move the Router File:
Move
src/Core/Router.phpto the newsrc/Routing/Router.phplocation:
# Move the Router
mv src/Core/Router.php src/Routing/Router.phpUpdate the Router Namespace:
Open
src/Routing/Router.phpand update the namespace at the top:
<?php
declare(strict_types=1);
namespace App\Routing; // Changed from App\Core
class Router
{
// ... rest of the class remains the same
}- Update the Autoloader:
# Rebuild Composer's autoloader
composer dump-autoloadExpected Result: Your src/ directory should now look like this:
src/
├── Controllers/ (empty, ready for controllers)
├── Models/ (empty, ready for models)
├── Routing/
│ └── Router.php
└── Views/ (empty, ready for view templates)Validation: Run this command to verify your structure:
ls -la src/You should see all four directories listed.
Step 2: Creating Controllers (~6 min)
Goal: Build a controller class and update the router to dispatch to controller methods.
A controller is a class that groups related request-handling logic. For example, a PostController might have methods for showing all posts (index), showing a single post (show), creating a new post (create), and so on.
2.1 Create a PageController
Let's create a controller for our static pages (Home, About).
File: src/Controllers/PageController.php
<?php
declare(strict_types=1);
namespace App\Controllers;
class PageController
{
public function home()
{
echo "This is the Home page from the controller.";
}
public function about()
{
echo "This is the About page from the controller.";
}
}2.2 Update the Router to Support Controllers
We need to teach our router how to handle a ['ClassName', 'methodName'] syntax for handlers. Here's the complete updated dispatch method:
File: src/Routing/Router.php
<?php
declare(strict_types=1);
namespace App\Routing;
class Router
{
protected array $routes = [];
public function get(string $uri, $handler): void
{
$this->addRoute('GET', $uri, $handler);
}
public function post(string $uri, $handler): void
{
$this->addRoute('POST', $uri, $handler);
}
protected function addRoute(string $method, string $uri, $handler): void
{
$this->routes[] = [
'method' => $method,
'uri' => $uri,
'handler' => $handler,
];
}
public function dispatch(): void
{
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$method = $_SERVER['REQUEST_METHOD'];
foreach ($this->routes as $route) {
if ($route['uri'] === $uri && $route['method'] === $method) {
$handler = $route['handler'];
// Check if handler is an array [ControllerClass, 'method']
if (is_array($handler)) {
$controller = new $handler[0]();
$controllerMethod = $handler[1];
return $controller->$controllerMethod();
}
// Handler is a closure/callable
return call_user_func($handler);
}
}
// No route found
http_response_code(404);
echo "404 Not Found";
}
}Why it works: We check if the handler is an array. If it is, we know it contains a class name and method name. We instantiate the controller and call the method dynamically.
2.3 Update index.php to Use the Controller
Now, let's refactor our routes to use the new controller.
File: public/index.php
<?php
declare(strict_types=1);
require_once '../vendor/autoload.php';
use App\Controllers\PageController;
use App\Routing\Router;
$router = new Router();
// Use the [Controller::class, 'method'] syntax
$router->get('/', [PageController::class, 'home']);
$router->get('/about', [PageController::class, 'about']);
$router->dispatch();Using PageController::class gives us the fully qualified class name as a string (App\Controllers\PageController), which is a clean and reliable way to reference classes.
Expected Result: When you visit http://localhost:8000/, you should see:
This is the Home page from the controller.And when you visit http://localhost:8000/about, you should see:
This is the About page from the controller.Validation: Test both routes:
# Start the dev server if not already running
php -S localhost:8000 -t public
# In another terminal, test the routes
curl http://localhost:8000/
curl http://localhost:8000/aboutTroubleshooting
Problem: "Class 'App\Controllers\PageController' not found"
Solution: Run composer dump-autoload to regenerate the autoloader.
Problem: "404 Not Found" on valid routes
Solution: Check that your PageController.php namespace is exactly App\Controllers and the class name matches the filename.
Step 3: Creating a Simple View System (~7 min)
Goal: Separate presentation logic from controllers by creating a view rendering system.
Right now, our controllers are echoing HTML directly. This is bad practice. The controller's job is to fetch data, not to render HTML. Let's create a simple system to handle views.
3.1 Create the View Files
Create two simple view files. These are just HTML with PHP tags where we want to insert data.
File: src/Views/home.php
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?php echo htmlspecialchars($pageTitle); ?></title>
</head>
<body>
<h1><?php echo htmlspecialchars($pageTitle); ?></h1>
<p>Welcome to the homepage!</p>
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
</nav>
</body>
</html>File: src/Views/about.php
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?php echo htmlspecialchars($pageTitle); ?></title>
</head>
<body>
<h1><?php echo htmlspecialchars($pageTitle); ?></h1>
<p>This is the about page. Learn more about our simple blog application.</p>
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
</nav>
</body>
</html>Note: We use htmlspecialchars() to prevent XSS attacks by escaping any HTML in our data.
3.2 Create a View Helper Function
We'll create a simple helper function that can render these views and pass data to them. Don't worry—we'll enhance this function in the next step to support layouts!
File: src/helpers.php
<?php
declare(strict_types=1);
/**
* Render a view with the given data.
*
* @param string $viewName The name of the view file (without .php extension)
* @param array $data Associative array of data to pass to the view
* @return void
*/
function view(string $viewName, array $data = []): void
{
// Build the full path to the view file
$viewPath = __DIR__ . "/Views/{$viewName}.php";
// Check if the view file exists
if (!file_exists($viewPath)) {
throw new Exception("View not found: {$viewName}");
}
// Extract the data array into variables
// EXTR_SKIP prevents overwriting existing variables (security measure)
// e.g., ['pageTitle' => 'Home'] becomes $pageTitle = 'Home'
extract($data, EXTR_SKIP);
// Require the view file - it now has access to the extracted variables
require $viewPath;
}Why it works: The extract() function with EXTR_SKIP flag safely converts array keys into variable names, making them available inside the view file.
3.3 Load the Helper and Update the Controller
First, tell Composer to always load our helpers.php file. Open composer.json and add a files array to your autoload section:
{
"autoload": {
"psr-4": {
"App\\": "src/"
},
"files": ["src/helpers.php"]
},
"require": {}
}Then, rebuild the autoloader:
composer dump-autoloadNow, update PageController.php to use our new view() function:
File: src/Controllers/PageController.php
<?php
declare(strict_types=1);
namespace App\Controllers;
class PageController
{
public function home(): void
{
view('home', [
'pageTitle' => 'Home Page'
]);
}
public function about(): void
{
view('about', [
'pageTitle' => 'About Us'
]);
}
}Expected Result: Visit http://localhost:8000/ and you should see a properly formatted HTML page with:
- A page title of "Home Page" in the browser tab
- An
<h1>heading saying "Home Page" - A welcome message
- Navigation links to Home and About
Validation: Check the HTML source:
curl http://localhost:8000/You should see complete HTML with <!DOCTYPE html>, proper head section, and the content from your view.
Note: You might notice we're duplicating HTML structure between home.php and about.php. This is intentional for now—we'll solve this problem elegantly in Step 4 with a layout system!
Troubleshooting
Problem: "View not found: home"
Solution: Ensure src/Views/home.php exists and the path in helpers.php is correct. The __DIR__ should point to the src/ directory.
Problem: Variable $pageTitle is undefined in view
Solution: Make sure you're passing the data array as the second argument to view() and that Composer's autoloader has been rebuilt with composer dump-autoload.
Problem: "Failed to open stream" error
Solution: Check file permissions on the src/Views/ directory. On Unix systems, run chmod 755 src/Views.
Step 4: Adding a Layout System (~5 min)
Goal: Eliminate duplicate HTML structure across views by implementing a master layout template.
Right now, both home.php and about.php contain the full HTML structure (<!DOCTYPE>, <head>, <body>, navigation, etc.). As we add more pages, this duplication becomes a maintenance nightmare. What if we need to add a new navigation link or change the site's title format? We'd have to edit every single view file.
The solution is a layout system (also called a "master template"). This is a fundamental pattern used by all modern PHP frameworks.
4.1 Create the Layout File
Create a master layout that will wrap all our views.
File: src/Views/layout.php
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?php echo htmlspecialchars($pageTitle ?? 'Simple Blog'); ?></title>
<style>
body { font-family: sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; }
nav { margin-bottom: 20px; padding: 10px; background: #f0f0f0; }
nav a { margin-right: 15px; text-decoration: none; color: #333; }
nav a:hover { color: #0066cc; }
footer { margin-top: 40px; padding-top: 20px; border-top: 1px solid #ddd; color: #666; }
</style>
</head>
<body>
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
</nav>
<main>
<?php echo $content; ?>
</main>
<footer>
<p>© <?php echo date('Y'); ?> Simple Blog. Built with PHP from scratch.</p>
</footer>
</body>
</html>Note: The $content variable will hold the rendered output from our individual view files.
4.2 Update the View Helper Function
Now we need to update our view() function to support layouts. We'll use PHP's output buffering to capture the view content and then inject it into the layout.
File: src/helpers.php
<?php
declare(strict_types=1);
/**
* Render a view with the given data, wrapped in a layout.
*
* @param string $viewName The name of the view file (without .php extension)
* @param array $data Associative array of data to pass to the view
* @param string|null $layout The layout file to use (null for no layout)
* @return void
*/
function view(string $viewName, array $data = [], ?string $layout = 'layout'): void
{
// Build the full path to the view file
$viewPath = __DIR__ . "/Views/{$viewName}.php";
// Check if the view file exists
if (!file_exists($viewPath)) {
throw new Exception("View not found: {$viewName}");
}
// Extract the data array into variables
// EXTR_SKIP prevents overwriting existing variables (security measure)
extract($data, EXTR_SKIP);
// Start output buffering to capture the view content
ob_start();
// Require the view file - its output will be captured
require $viewPath;
// Get the captured content and clean the buffer
$content = ob_get_clean();
// If a layout is specified, render the content within it
if ($layout !== null) {
$layoutPath = __DIR__ . "/Views/{$layout}.php";
if (!file_exists($layoutPath)) {
throw new Exception("Layout not found: {$layout}");
}
// Require the layout file, which has access to $content
require $layoutPath;
} else {
// No layout, just echo the content
echo $content;
}
}Why it works:
- Output buffering (
ob_start()) captures all output from the view file - We store that captured output in
$content - We then render the layout, which has access to
$content - The layout injects
$contentinto the appropriate place in the HTML structure
4.3 Simplify the View Files
Now we can dramatically simplify our view files since they only need to contain their specific content—no more duplicate HTML!
File: src/Views/home.php
<h1><?php echo htmlspecialchars($pageTitle); ?></h1>
<p>Welcome to the homepage! This is a simple blog application built from scratch using PHP and the MVC pattern.</p>
<p>Navigate through the site using the menu above, or check out our blog posts.</p>File: src/Views/about.php
<h1><?php echo htmlspecialchars($pageTitle); ?></h1>
<p>This is the about page. Learn more about our simple blog application.</p>
<h2>About This Project</h2>
<p>This blog was built as a learning project to demonstrate:</p>
<ul>
<li>The Model-View-Controller (MVC) pattern</li>
<li>Routing and controllers</li>
<li>View rendering with layouts</li>
<li>Separation of concerns</li>
</ul>Notice how much cleaner these files are! They focus solely on their specific content.
4.4 Rebuild and Test
Since we modified helpers.php, rebuild the autoloader:
composer dump-autoloadExpected Result: Your pages should look better with consistent styling, navigation, and a footer—all without duplicating code!
Validation: View the source of any page:
curl http://localhost:8000/You should see the complete HTML structure from layout.php with the page-specific content from home.php inserted in the middle.
4.5 Using Layouts Flexibly
The view() function now accepts an optional third parameter. You can:
Use the default layout (most common):
view('home', ['pageTitle' => 'Home']); // Uses layout.phpSpecify a different layout:
view('home', ['pageTitle' => 'Home'], 'admin-layout'); // Uses admin-layout.phpRender without any layout:
view('home', ['pageTitle' => 'Home'], null); // No layout, just the viewThis flexibility is useful for AJAX responses, API endpoints, or special pages that need different layouts.
Troubleshooting
Problem: "Cannot modify header information - headers already sent"
Solution: This happens when there's output before ob_start(). Check for whitespace or echo statements before the view is rendered. Make sure declare(strict_types=1); in helpers.php has no spaces before the <?php tag.
Problem: Layout shows but view content is missing
Solution: Check that ob_get_clean() is returning content. Add var_dump($content); before the layout is required to debug. The view file path might be wrong.
Problem: Undefined variable $content in layout
Solution: Ensure output buffering is working correctly and that $content = ob_get_clean(); comes before require $layoutPath;.
Exercises
Exercise 1: Create a Base Controller (⭐⭐)
Right now, if we wanted to add functionality that all controllers share, we'd have to duplicate code. Let's create a base controller.
Tasks:
- Create an abstract
Controllerclass insrc/Core/Controller.php - Add a
protected function view()method that wraps the globalview()helper - Make your
PageControllerextend this newControllerclass - Update
PageControllermethods to use$this->view()instead of the global function
Hints:
// src/Core/Controller.php
abstract class Controller
{
protected function view(string $viewName, array $data = []): void
{
view($viewName, $data);
}
}This object-oriented approach makes it easier to add shared functionality like authentication checks or logging.
Exercise 2: Create a Post Model and Controller (⭐⭐⭐)
Build out the Model layer of MVC by creating a Post model and displaying posts.
Tasks:
- Create
src/Models/Post.phpwith apublic static function all()method - Have
all()return hardcoded blog post data (array of arrays withtitle,content,datekeys) - Create
src/Controllers/PostController.phpwith anindex()method - In
index(), fetch posts usingPost::all()and pass to a view - Create
src/Views/posts/index.phpto display all posts (use a loop) - Register a
/postsroute inpublic/index.php
Expected Output: Visiting http://localhost:8000/posts should display a list of blog posts with titles, content, and dates.
Hint for the view:
// src/Views/posts/index.php
foreach ($posts as $post): ?>
<article>
<h2><?php echo htmlspecialchars($post['title']); ?></h2>
<p><?php echo htmlspecialchars($post['content']); ?></p>
</article>
<?php endforeach;Exercise 3: Add a 404 Page (⭐)
Improve the user experience when visiting invalid routes.
Tasks:
- Create
src/Views/404.phpwith a friendly "Page not found" message - Update the router's
dispatch()method to callview('404')instead ofecho "404 Not Found" - Test by visiting
http://localhost:8000/nonexistent-page
Exercise 4: Create an Admin Layout (⭐⭐)
Practice using multiple layouts by creating a different layout for administrative pages.
Tasks:
- Create
src/Views/admin-layout.phpwith a different color scheme and navigation - Add a link to a "Dashboard" (
/admin) in the admin navigation - Create
src/Controllers/AdminController.phpwith adashboard()method - Have the controller render a view using the admin layout:
view('admin/dashboard', [...], 'admin-layout') - Create
src/Views/admin/dashboard.phpwith some dashboard content - Add the
/adminroute toindex.php
Expected Behavior: The admin pages should look visually different (different colors, different nav) from the public pages while still using the same view rendering system.
Hint: Your admin layout could have:
<style>
body { background: #2c3e50; color: white; }
nav { background: #34495e; }
/* ... more admin-specific styling ... */
</style>Wrap-up
Congratulations! This was a massive step forward in building a real application. You've successfully:
- ✅ Implemented the MVC pattern
- ✅ Separated concerns into Models, Views, and Controllers
- ✅ Created a reusable view rendering system with layouts
- ✅ Learned about output buffering for template composition
- ✅ Built a scalable directory structure
- ✅ Enhanced your router to work with controllers
- ✅ Eliminated code duplication with master templates
You now have a clean, organized, and scalable structure that mirrors the foundation of almost every modern PHP framework like Laravel (Blade layouts), Symfony (Twig layouts), and CodeIgniter (template inheritance).
What's Next: In the final project chapter, we'll bring everything together by connecting our Post model to a real database, building out full CRUD (Create, Read, Update, Delete) functionality, and completing our blog application.
Code Examples
The complete structured application from this chapter is available in:
public/index.php- Front controller entry pointapp/- Application directory with Controllers, Models, and Viewsconfig/- Configuration filesroutes.php- Route definitionssolutions/- Solutions to chapter exercises
This directory contains the complete MVC structure with router integration and template layouts.
Knowledge Check
Test your understanding of MVC architecture:
Chapter 18 Quiz: MVC Architecture
Further Reading
- MVC Pattern Explained - PHP manual on basic architecture
- PSR-4 Autoloading Standard - Understanding PHP autoloading conventions
- Composer Autoloading - Official Composer documentation
- Output Buffering in PHP - Official documentation on output control functions
- Security Best Practices - PHP security guidelines including XSS prevention
- Template Inheritance Patterns - Understanding layout and template patterns