MVC in PHP: Models, Views, Controllers and Routing
What is MVC in PHP?
MVC (Model-View-Controller) is a way of structuring a PHP application into three roles. The model handles data and business rules, the view produces the HTML, and the controller receives the request, asks the model for data and passes it to the view. Laravel, CodeIgniter and most other PHP frameworks are organised around this pattern.
The three roles and the terms around them
| Part | Responsibility | Should not |
|---|---|---|
| Model | Reads and writes data, applies business rules | Echo HTML or read $_GET directly |
| View | Turns data into HTML, escaping every value | Run database queries |
| Controller | Reads the request, calls models, chooses a view | Contain SQL or long HTML |
Two more terms appear in every framework. A front controller is a single entry file, usually public/index.php, that receives every request. A router matches the request method and URL to a controller method. Separating these concerns means a designer can change a view without touching SQL, and you can test business rules without a browser.
The request lifecycle in an MVC application
- The web server sends every request that is not a real file, such as an image, to
public/index.php. - The front controller loads the autoloader and configuration and connects services such as the database.
- The router compares the method and path, for example
GET /products/5, with its route list. - Middleware (Laravel) or filters (CodeIgniter) can run first, for example to check that the user is logged in.
- The matching controller method runs and asks one or more models for data.
- The controller passes that data to a view, which renders the HTML.
- The response, with its status code and headers, is sent back to the browser.
Every request follows this path, whether it is a page view, a form submission or an AJAX call. Because there is only one entry point, cross-cutting work such as loading configuration, starting the session, handling errors and checking authentication is written once in the front controller or middleware instead of being copied into dozens of separate PHP files, as often happens in older sites where each URL maps to its own script.
A tiny working MVC app in plain PHP
This example lists products and shows one product. It needs a products table with id, name and price columns. First the front controller and router:
<?php
// public/index.php
declare(strict_types=1);
require __DIR__ . '/../src/Models/Product.php';
require __DIR__ . '/../src/Controllers/ProductController.php';
$pdo = new PDO('mysql:host=localhost;dbname=shop;charset=utf8mb4', 'shop_app', getenv('DB_PASSWORD') ?: '', [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$controller = new ProductController(new Product($pdo));
$routes = [
['GET', '#^/products$#', 'index'],
['GET', '#^/products/(\d+)$#', 'show'],
];
$method = $_SERVER['REQUEST_METHOD'];
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
foreach ($routes as [$verb, $pattern, $action]) {
if ($verb === $method && preg_match($pattern, $path, $m)) {
$controller->$action(...array_map('intval', array_slice($m, 1)));
exit;
}
}
http_response_code(404);
echo 'Page not found';The model owns the SQL and uses prepared statements, as recommended in the guide to secure PHP:
<?php
// src/Models/Product.php
declare(strict_types=1);
final class Product
{
public function __construct(private PDO $db) {}
public function all(): array
{
return $this->db->query('SELECT id, name, price FROM products ORDER BY name')->fetchAll();
}
public function find(int $id): ?array
{
$stmt = $this->db->prepare('SELECT id, name, price FROM products WHERE id = ?');
$stmt->execute([$id]);
return $stmt->fetch() ?: null;
}
}The controller stays thin: it gets data and picks a view.
<?php
// src/Controllers/ProductController.php
declare(strict_types=1);
final class ProductController
{
public function __construct(private Product $products) {}
public function index(): void
{
$this->render('products/index', ['products' => $this->products->all()]);
}
public function show(int $id): void
{
$product = $this->products->find($id);
if ($product === null) {
http_response_code(404);
$this->render('errors/404', []);
return;
}
$this->render('products/show', ['product' => $product]);
}
private function render(string $view, array $data): void
{
extract($data, EXTR_SKIP);
require __DIR__ . '/../../views/' . $view . '.php';
}
}Because the controller receives the model through its constructor, a test can pass in a fake model that returns fixed data, with no database at all. The render() helper turns the data array into local variables for the template, and EXTR_SKIP stops data keys from overwriting existing variables such as $view.
Finally the view, which only loops and escapes:
<?php // views/products/index.php
$e = fn ($v) => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); ?>
<h1>Products</h1>
<ul>
<?php foreach ($products as $product): ?>
<li><a href="/products/<?= (int) $product['id'] ?>"><?= $e($product['name']) ?></a> (Rs <?= $e($product['price']) ?>)</li>
<?php endforeach; ?>
</ul>The single product view follows the same rules. It receives one $product array and never queries the database itself:
<?php // views/products/show.php
$e = fn ($v) => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); ?>
<h1><?= $e($product['name']) ?></h1>
<p>Price: Rs <?= $e($product['price']) ?></p>
<p><a href="/products">Back to all products</a></p>Run it locally with php -S localhost:8000 -t public public/index.php and open /products. The classes use constructor injection and promoted properties, explained in the object-oriented PHP guide.
How Laravel and CodeIgniter implement MVC
Frameworks follow the same flow but replace each hand-written piece with a tested component.
| Piece | Laravel | CodeIgniter 4 |
|---|---|---|
| Front controller | public/index.php | public/index.php |
| Routes | routes/web.php | app/Config/Routes.php |
| Controllers | app/Http/Controllers | app/Controllers |
| Models | app/Models (Eloquent ORM) | app/Models (extend CodeIgniter\Model) |
| Views | resources/views (Blade templates) | app/Views (plain PHP views) |
| Code before controllers | Middleware | Filters |
// Laravel: routes/web.php
Route::get('/products/{id}', [ProductController::class, 'show']);
// CodeIgniter 4: app/Config/Routes.php
$routes->get('products/(:num)', 'Products::show/$1');Once you have built the plain version, framework folders stop looking mysterious: each one is a role you already wrote by hand. Frameworks also add validation, escaping helpers and CSRF protection, and the same structure works for JSON responses when you build REST APIs.
Folder structure and thin controllers
public/: onlyindex.phpand static assets. Point the web server document root here so nothing else is reachable by URL.src/Controllers/,src/Models/: application classes, loaded through Composer autoloading in larger projects.views/: templates grouped by feature, such asproducts/index.phpanderrors/404.php.config/and.env: settings and secrets, kept out ofpublic/.
A thin controller reads like a short summary of the feature: validate the input, call the model or service, choose the view or redirect. Everything else lives somewhere more reusable. A useful check is to ask whether the same rule would be needed by a command-line import script or an API endpoint; if so, it belongs in the model or a service, not in the controller.
Practical exercise: extend the mini MVC app
Build the app above, then add a feature that touches every layer.
- Add a
GET /products/searchroute that reads aqparameter. - Add a
search(string $term): arraymodel method using a preparedLIKEquery. - Add a controller method and a
products/searchview that shows "No results" when the array is empty. - Replace the
requirelines with Composer PSR-4 autoloading.
You are done when searching for a product name lists matches, a search containing a quote mark causes no errors, an unknown URL returns a 404 status, and no SQL appears outside the model folder. Commit each step with Git so you can compare versions. The advanced PHP course takes this structure into full framework projects.
Common mistakes
- Writing SQL queries inside controllers or views, which spreads data logic across the whole application.
- Putting business rules such as discount calculations in the controller, where they cannot be reused or tested easily.
- Pointing the web server document root at the project folder instead of
public/, exposing config files. - Printing data in views without escaping it, which opens the door to cross-site scripting.
- Hard-coding database credentials in the front controller instead of loading them from environment configuration.
- Sending output or headers from the model, which makes the same model unusable in a command-line script or API.
Frequently asked questions
Is MVC the only way to structure a PHP application?
No. Small scripts may not need it, and larger systems sometimes add layers such as services, repositories or action classes. MVC remains the common starting point because most PHP frameworks use it.
Should I learn plain PHP MVC before a framework?
Building a small version yourself, as in this guide, makes it much clearer what a framework is doing for you. You do not need to build a complete framework; one working feature is enough before moving on.
What is a service container?
It is a framework component that creates objects and their dependencies for you. Instead of writing new ProductController(new Product($pdo)) by hand, Laravel reads the constructor types and builds the whole chain automatically.
Where does form validation belong in MVC?
The controller usually triggers validation of request data, often through a framework validator, while rules about what makes data valid for the business belong in the model or a service. Views only display the error messages.