Skip to content

PHP MVC Pattern — Model-View-Controller Architecture for Web Applications

DodaTech Updated 2026-06-28 8 min read

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
  1. Browser sends a request to a URL
  2. The front controller routes the request to the appropriate controller
  3. The controller calls models to fetch or save data
  4. The controller passes data to a view template
  5. 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

  1. What is the responsibility of each MVC component? Model: data and business logic. View: presentation. Controller: request handling and coordination.

  2. Why should controllers be thin? Controllers should only orchestrate: validate input, call services, return responses. Logic in controllers is hard to test and reuse.

  3. 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.

  4. 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.

  5. 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

Do I need an MVC framework to use MVC?

No. You can implement MVC with plain PHP. Frameworks like Laravel provide routing, ORM, and templating out of the box, saving development time.

Where do form validation errors go in MVC?

Controllers validate input. If validation fails, the controller returns a response with errors. Views display the errors passed from the controller.

Is MVC still relevant for modern PHP apps?

Yes. MVC remains the dominant architecture for PHP web applications. Even API-only backends follow MVC with JSON views replacing HTML templates.

What is the difference between MVC and HMVC?

HMVC (Hierarchical MVC) allows nested MVC triads. A widget on a page can have its own controller, model, and view within the parent page's MVC.

How does MVC help with testing?

Each component can be tested independently. Test models with unit tests, controllers with integration tests (mocking models), and views with snapshot testing.

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