PHP MVC Pattern — Model-View-Controller Architecture for Web Applications
In this tutorial, you will learn about PHP MVC Pattern. We cover key concepts, practical examples, and best practices to help you master this topic.
The PHP MVC pattern divides application code into three interconnected layers: Models handle data and business logic, Views manage presentation and templates, and Controllers process HTTP requests and coordinate between models and views.
Why It Matters
Without a structured architecture, PHP applications become a mix of database queries, HTML, and business logic in single files -- spaghetti code that is impossible to maintain. MVC separates concerns so each part has a single responsibility. Controllers are thin, models handle data, and views only display. This separation makes code testable, maintainable, and scalable.
Real-World Use
Laravel, Symfony, and CodeIgniter all implement MVC. Laravel's Controllers handle HTTP requests, Eloquent Models represent database tables, and Blade Views render HTML. WordPress follows a variant of MVC with template files as views and plugins as controllers. DodaTech's web applications use a custom MVC framework with Twig templates, separate model repositories, and action-based controllers.
What You Will Learn
- The three MVC components and their responsibilities
- Front controller pattern for request routing
- Creating models with business logic and data access
- Creating views with template engines
- Creating controllers that coordinate requests
- URL routing and parameter extraction
- Best practices for MVC organization
Learning Path
flowchart LR A[PDO] --> B[MVC Pattern
You are here] B --> C[Dependency Injection] C --> D[Testing] D --> E[Composer] style B fill:#f90,color:#fff
MVC Overview
flowchart LR B[Browser] --> C[Controller] C --> M[Model] M --> V[View] V --> B style C fill:#f90,color:#fff
- Browser sends a request to a URL
- The front controller routes the request to the appropriate controller
- The controller calls models to fetch or save data
- The controller passes data to a view template
- The view renders HTML and sends it back to the browser
Front Controller Pattern
All requests go through a single entry point (index.php):
<?php
// public/index.php
require __DIR__ . '/../vendor/autoload.php';
use App\Core\Router;
use App\Core\Request;
$request = new Request();
$router = new Router();
$response = $router->dispatch($request);
$response->send();
URL Rewriting with Apache (.htaccess)
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [QSA,L]
URL Rewriting with Nginx
location / {
try_files $uri $uri/ /index.php?$query_string;
}
The Request Class
<?php
namespace App\Core;
class Request {
public readonly string $method;
public readonly string $path;
public readonly array $query;
public readonly array $body;
public function __construct() {
$this->method = $_SERVER['REQUEST_METHOD'];
$this->path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$this->query = $_GET;
$this->body = json_decode(file_get_contents('php://input'), true) ?? $_POST;
}
public function input(string $key, mixed $default = null): mixed {
return $this->body[$key] ?? $this->query[$key] ?? $default;
}
public function isMethod(string $method): bool {
return strtoupper($this->method) === strtoupper($method);
}
}
The Router
<?php
namespace App\Core;
class Router {
private array $routes = [];
public function get(string $path, callable|array $handler): void {
$this->routes['GET'][$path] = $handler;
}
public function post(string $path, callable|array $handler): void {
$this->routes['POST'][$path] = $handler;
}
public function dispatch(Request $request): Response {
$method = $request->method;
$path = $request->path;
foreach ($this->routes[$method] ?? [] as $route => $handler) {
$params = $this->matchRoute($route, $path);
if ($params !== false) {
if (is_array($handler)) {
[$controllerClass, $action] = $handler;
$controller = new $controllerClass();
return $controller->$action($request, ...$params);
}
return $handler($request, ...$params);
}
}
return new Response('Not Found', 404);
}
private function matchRoute(string $route, string $path): array|false {
$pattern = preg_replace('/\{(\w+)\}/', '(?P<$1>[^/]+)', $route);
$pattern = '#^' . $pattern . '$#';
if (preg_match($pattern, $path, $matches)) {
return array_filter($matches, 'is_string', ARRAY_FILTER_USE_KEY);
}
return false;
}
}
The Response Class
<?php
namespace App\Core;
class Response {
public function __construct(
private readonly mixed $content = '',
private readonly int $status = 200,
private readonly array $headers = [],
) {}
public function send(): void {
http_response_code($this->status);
foreach ($this->headers as $name => $value) {
header("$name: $value");
}
if (is_array($this->content) || is_object($this->content)) {
header('Content-Type: application/json');
echo json_encode($this->content);
} else {
echo $this->content;
}
}
}
Controllers
Controllers are thin -- they validate input, call models, and return responses:
<?php
namespace App\Controllers;
use App\Core\Request;
use App\Core\Response;
use App\Models\User;
use App\Repositories\UserRepository;
class UserController {
public function __construct(
private readonly UserRepository $users,
) {}
public function index(Request $request): Response {
$page = (int)($request->query['page'] ?? 1);
$users = $this->users->findAll($page);
return new Response(['data' => $users]);
}
public function show(Request $request, int $id): Response {
$user = $this->users->findById($id);
if (!$user) {
return new Response(['error' => 'User not found'], 404);
}
return new Response(['data' => $user]);
}
public function store(Request $request): Response {
$name = $request->input('name');
$email = $request->input('email');
if (!$name || !$email) {
return new Response(['error' => 'Name and email required'], 422);
}
$id = $this->users->create([
'name' => $name,
'email' => $email,
'password' => $request->input('password'),
]);
return new Response(['data' => ['id' => $id]], 201);
}
}
Models
Models contain business logic and data access:
<?php
namespace App\Models;
class User {
public function __construct(
public readonly int $id,
public readonly string $name,
public readonly string $email,
public readonly bool $active,
public readonly \DateTimeImmutable $createdAt,
) {}
public static function fromArray(array $data): self {
return new self(
id: (int)$data['id'],
name: $data['name'],
email: $data['email'],
active: (bool)$data['active'],
createdAt: new \DateTimeImmutable($data['created_at']),
);
}
public function toArray(): array {
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
'active' => $this->active,
];
}
public function canAccess(string $resource): bool {
return $this->active && !$this->isExpired();
}
private function isExpired(): bool {
return $this->createdAt->modify('+1 year') < new \DateTimeImmutable();
}
}
Views
Views receive data and render output:
<?php
namespace App\Core;
class View {
private string $basePath;
public function __construct(string $basePath) {
$this->basePath = rtrim($basePath, '/');
}
public function render(string $template, array $data = []): string {
$file = $this->basePath . '/' . $template . '.php';
if (!file_exists($file)) {
throw new \RuntimeException("Template not found: $template");
}
extract($data, EXTR_SKIP);
ob_start();
require $file;
return ob_get_clean();
}
}
Example view template (views/users/index.php):
<h1>Users</h1>
<ul>
<?php foreach ($users as $user): ?>
<li><?= htmlspecialchars($user['name']) ?> (<?= htmlspecialchars($user['email']) ?>)</li>
<?php endforeach; ?>
</ul>
Application Bootstrap
<?php
// public/index.php
require __DIR__ . '/../vendor/autoload.php';
use App\Core\Router;
use App\Core\Request;
use App\Core\Response;
use App\Core\View;
use App\Controllers\UserController;
use App\Repositories\UserRepository;
// Dependency setup
$pdo = new PDO('mysql:host=localhost;dbname=app', 'root', '', [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$userRepo = new UserRepository($pdo);
$view = new View(__DIR__ . '/../views');
// Routing
$router = new Router();
$router->get('/', function (Request $req) use ($view) {
return new Response($view->render('home', ['title' => 'Welcome']));
});
$router->get('/users', [new UserController($userRepo), 'index']);
$router->get('/users/{id}', [new UserController($userRepo), 'show']);
$router->post('/users', [new UserController($userRepo), 'store']);
// Dispatch
$request = new Request();
$response = $router->dispatch($request);
$response->send();
Common Mistakes
1. Fat Controllers
Putting business logic in controllers makes them untestable and hard to maintain. Controllers should only validate input and call services.
2. Fat Models
Putting everything in models creates God classes. Split into repositories for data access, services for business logic, and entities for data structures.
3. Echoing in Controllers
class UserController {
public function show(int $id) {
echo "User details"; // Wrong
}
}
Return a Response object. Never echo directly in a controller.
4. Database Queries in Views
// views/user.php
$user = $pdo->query("SELECT ..."); // Wrong
Views should only display data passed to them. No database calls in templates.
5. No Error Handling
Every controller action should handle errors gracefully and return appropriate status codes. Wrap operations in try-catch.
Practice Questions
What is the responsibility of each MVC component? Model: data and business logic. View: presentation. Controller: request handling and coordination.
Why should controllers be thin? Controllers should only orchestrate: validate input, call services, return responses. Logic in controllers is hard to test and reuse.
What is the front controller pattern? A single entry point (index.php) that handles all requests. The front controller sets up routing, loads dependencies, and dispatches requests.
How does URL rewriting work with MVC? Apache's mod_rewrite or Nginx's try_files redirect all requests to index.php, allowing the PHP router to parse the URL and dispatch to the correct controller.
Challenge: Build a complete MVC application with CRUD for products, including routing, controllers, models, and views, with PDO database access.
Mini Project: Simple MVC Framework
Build a minimal MVC core:
<?php
// App/Core/Application.php
namespace App\Core;
class Application {
private Router $router;
private array $bindings = [];
public function __construct() {
$this->router = new Router();
}
public function bind(string $abstract, callable $concrete): void {
$this->bindings[$abstract] = $concrete;
}
public function make(string $abstract): mixed {
if (!isset($this->bindings[$abstract])) {
throw new \RuntimeException("No binding for $abstract");
}
return ($this->bindings[$abstract])($this);
}
public function getRouter(): Router {
return $this->router;
}
public function run(): void {
$request = new Request();
$response = $this->router->dispatch($request);
$response->send();
}
}
FAQ
What is Next
Now that you understand MVC, proceed to Dependency Injection to learn how to wire components together loosely. Then read Testing with PHPUnit to write automated tests.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro