Building MVC architecture in PHP without a framework sounds like an exercise in reinvention. It is not. It is an exercise in understanding. Frameworks like Laravel, Symfony, and CodeIgniter abstract away the mechanics of request handling, routing, and data access with good reason. They solve real problems at scale. But relying on that abstraction without understanding what happens underneath it means making architecture decisions based on familiarity rather than fit. Learning how to build MVC in PHP from scratch gives you the knowledge to evaluate when a framework genuinely serves your application and when it adds unnecessary weight.
What MVC Means in PHP Development
MVC stands for Model-View-Controller. This architectural pattern originated in Smalltalk during the late 1970s and has since been adapted across virtually every programming language used for web development. The core idea is straightforward: separate the parts of your application that handle data logic from the parts that handle presentation and the parts that coordinate between them.
The Model layer manages data access and business logic. It communicates with the database, enforces data validation rules, and represents the underlying entities your application works with. The View layer handles everything related to output: generating HTML, formatting data for API responses, or producing any other representation of information that the user sees. The Controller layer sits between them, receiving input from the user, invoking the appropriate Model methods, and passing the results to the appropriate View.
In framework-based PHP development, these layers are enforced through class inheritance, middleware systems, and templating engines. In vanilla PHP MVC, you implement this separation through disciplined architecture choices. There is no router provided, no ORM to handle database interactions, and no templating engine to enforce view separation. You build each layer deliberately, which means you understand exactly what each component does and how it connects to the others.
Why Build MVC in PHP Without a Framework
The practical value of building MVC in PHP without a framework is not production-readiness. A custom MVC implementation rarely matches the robustness of a battle-tested framework. The value is architectural understanding.
When you understand how a router matches URL patterns to controller methods, you make better decisions about URL structure. When you understand how a base Model class interacts with PDO, you write more efficient database queries. When you understand how views receive data and render output, you build cleaner interfaces. This knowledge compounds. Every layer you implement yourself makes you more effective when you work with frameworks that implement those same layers for you.
There are also legitimate cases where a full framework is not the right fit. Small internal tools, contained web applications, learning projects, and prototypes can all benefit from the clarity and simplicity of a custom MVC structure without the overhead of framework configuration and conventions.
Directory Structure for a Vanilla PHP MVC Application
The first architectural decision is how to organise files on disk. A clear directory structure makes each layer easy to locate and modify without affecting the others. The standard approach separates the public web root from application logic, keeps each MVC layer in its own directory, and groups related infrastructure components together.
project/
├── public/
│ ├── index.php
│ └── .htaccess
├── app/
│ ├── controllers/
│ ├── models/
│ └── views/
│ └── layouts/
├── core/
│ ├── Router.php
│ ├── Request.php
│ ├── Response.php
│ ├── Controller.php
│ └── Model.php
├── config/
│ └── database.php
├── routes/
│ └── web.php
├── vendor/
└── composer.json
The public directory is the web root. Only files inside it are accessible via HTTP. This separation is a security boundary. Configuration files, application code, and anything else that should not be directly accessible by URL stays outside public.
The app directory contains the three MVC layers: controllers handle request processing, models handle data operations, and views contain presentation logic. The core directory holds the base classes that each layer extends: a Router that matches URLs to controllers, a Request object that represents incoming HTTP data, a Response object that wraps output, a Controller base class with shared functionality, and a Model base class with database interaction methods.
Composer handles autoloading through the PSR-4 standard. You define namespace prefixes and their corresponding directories in composer.json, and Composer generates an autoloader that loads any class automatically based on its namespace and file path.
Front Controller Pattern and Request Routing
The front controller is the single entry point for all HTTP requests. Instead of relying on PHP to route requests based on file paths, every request goes through public/index.php, which initialises the application and delegates to the appropriate controller. This centralises request handling and makes it possible to implement clean URL routing without exposing application internals.
// public/index.php
require_once __DIR__ . '/../vendor/autoload.php';
use App\Core\Router;
use App\Core\Request;
$router = new Router();
require_once __DIR__ . '/../routes/web.php';
$request = new Request($_GET, $_POST, $_SERVER);
$response = $router->dispatch($request);
$response->send();
The Router class maintains a routing table that maps HTTP method and URL pattern combinations to controller method specifications. When a request arrives, the router looks up the method and path in its table, instantiates the correct controller, calls the correct method, and returns a Response object.
// app/core/Router.php
namespace App\Core;
class Router {
protected array $routes = [];
public function add(string $method, string $path, string $handler): void {
$this->routes[$method . ':' . $path] = $handler;
}
public function get(string $path, string $handler): void {
$this->add('GET', $path, $handler);
}
public function post(string $path, string $handler): void {
$this->add('POST', $path, $handler);
}
public function dispatch(Request $request): Response {
$key = $request->method() . ':' . $request->path();
if (isset($this->routes[$key])) {
[$controllerName, $method] = explode('@', $this->routes[$key]);
$controllerClass = "App\\Controllers\\{$controllerName}";
$controller = new $controllerClass();
return $controller->$method($request);
}
return new Response(404, 'Not Found');
}
}
Dynamic routing with URL parameters extends this pattern. Routes like /posts/{slug} capture values from the URL and pass them to the controller method. A simple implementation uses regular expression matching to extract these parameters.
Database Interaction Through the Model Layer
The Model layer abstracts database operations behind a consistent interface. A base Model class handles the mechanics of PDO connection and query execution, while specific model classes represent individual database tables and define table-specific query logic. This follows the same pattern that ORM layers in frameworks use, just without the abstraction magic.
// app/core/Model.php
namespace App\Core;
use PDO;
abstract class Model {
protected static ?PDO $pdo = null;
protected string $table = '';
protected array $fillable = [];
public static function setConnection(array $config): void {
$dsn = "mysql:host={$config['host']};dbname={$config['database']};charset=utf8mb4";
self::$pdo = new PDO($dsn, $config['username'], $config['password'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
}
public function find(int $id): ?array {
$stmt = self::$pdo->prepare("SELECT * FROM {$this->table} WHERE id = :id");
$stmt->execute(['id' => $id]);
$result = $stmt->fetch();
return $result ?: null;
}
public function all(): array {
$stmt = self::$pdo->query("SELECT * FROM {$this->table}");
return $stmt->fetchAll();
}
public function create(array $data): int {
$fields = array_intersect_key($data, array_flip($this->fillable));
$columns = implode(', ', array_keys($fields));
$placeholders = implode(', ', array_fill(0, count($fields), '?'));
$sql = "INSERT INTO {$this->table} ({$columns}) VALUES ({$placeholders})";
$stmt = self::$pdo->prepare($sql);
$stmt->execute(array_values($fields));
return (int) self::$pdo->lastInsertId();
}
public function update(int $id, array $data): bool {
$fields = array_intersect_key($data, array_flip($this->fillable));
$set = implode(' = ?, ', array_keys($fields)) . ' = ?';
$values = array_values($fields);
$values[] = $id;
$sql = "UPDATE {$this->table} SET {$set} WHERE id = ?";
$stmt = self::$pdo->prepare($sql);
return $stmt->execute($values);
}
public function delete(int $id): bool {
$stmt = self::$pdo->prepare("DELETE FROM {$this->table} WHERE id = ?");
return $stmt->execute([$id]);
}
}
Concrete model classes extend this base class and define their table name, fillable fields, and any custom query methods. The fillable array is a simple mass-assignment protection mechanism that prevents arbitrary database columns from being set through the create method.
// app/models/Post.php
namespace App\Models;
use App\Core\Model;
class Post extends Model {
protected string $table = 'posts';
protected array $fillable = ['title', 'slug', 'content', 'published_at'];
public function findBySlug(string $slug): ?array {
$stmt = self::$pdo->prepare("SELECT * FROM {$this->table} WHERE slug = :slug");
$stmt->execute(['slug' => $slug]);
$result = $stmt->fetch();
return $result ?: null;
}
public function published(): array {
$stmt = self::$pdo->prepare(
"SELECT * FROM {$this->table} WHERE published_at IS NOT NULL ORDER BY published_at DESC"
);
$stmt->execute();
return $stmt->fetchAll();
}
}
Controllers and Request Processing
Controllers receive the Request object, invoke the appropriate Model methods to fetch or modify data, and return a Response. A base Controller class provides shared functionality like view rendering, JSON responses, and redirects that all specific controllers inherit.
// app/core/Controller.php
namespace App\Core;
abstract class Controller {
protected function render(string $view, array $data = []): Response {
extract($data);
ob_start();
require __DIR__ . "/../../app/views/{$view}.php";
$content = ob_get_clean();
return new Response(200, $content);
}
protected function json(array $data, int $status = 200): Response {
return new Response($status, json_encode($data), ['Content-Type' => 'application/json']);
}
protected function redirect(string $url): Response {
return new Response(302, '', ['Location' => $url]);
}
}
Specific controllers extend this base class and define methods that correspond to route handlers. Each method receives a Request object and returns a Response.
// app/controllers/PostController.php
namespace App\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Models\Post;
class PostController extends Controller {
public function index(Request $request): Response {
$post = new Post();
$posts = $post->published();
return $this->render('posts/index', ['posts' => $posts]);
}
public function show(Request $request): Response {
$post = new Post();
$record = $post->findBySlug($request->param('slug'));
if (!$record) {
return new Response(404, 'Post not found');
}
return $this->render('posts/show', ['post' => $record]);
}
public function store(Request $request): Response {
$post = new Post();
$post->create($request->only(['title', 'slug', 'content']));
return $this->redirect('/posts');
}
}
This pattern keeps controller methods thin. The controller coordinates between the Model and the View, but the actual data logic lives in the Model and the presentation logic lives in the View. Understanding object-oriented PHP principles helps when structuring controllers and models in this way.
Views and Output Rendering
Views in vanilla PHP are plain PHP files that receive data from the controller and generate HTML output. There is no template engine syntax to learn. You use PHP's native language features to iterate over arrays, format dates, and conditionally render content. The discipline required is keeping views focused on presentation only, without embedding business logic.
// app/views/posts/index.php
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title><?php echo htmlspecialchars($title, ENT_QUOTES, 'UTF-8'); ?></title>
</head>
<body>
<header>
<h1><?php echo htmlspecialchars($title, ENT_QUOTES, 'UTF-8'); ?></h1>
</header>
<main>
<?php foreach ($posts as $post): ?>
<article>
<h2>
<a href="/posts/<?php echo htmlspecialchars($post['slug'], ENT_QUOTES, 'UTF-8'); ?>">
<?php echo htmlspecialchars($post['title'], ENT_QUOTES, 'UTF-8'); ?>
</a>
</h2>
<p><?php echo htmlspecialchars($post['excerpt'], ENT_QUOTES, 'UTF-8'); ?></p>
</article>
<?php endforeach; ?>
</main>
</body>
</html>
Output escaping is not optional in this context. Every variable that renders user-provided content must be passed through htmlspecialchars with the correct character encoding. This is the primary defence against cross-site scripting attacks in PHP views. A framework often handles this automatically through its templating engine. In vanilla PHP MVC, you are responsible for it explicitly.
When to Use a Framework Instead
A vanilla PHP MVC application is appropriate when the scope is contained, the team has sufficient PHP knowledge to build and maintain their own infrastructure components, and the overhead of framework conventions is not justified by the requirements. Internal tools, small web applications, learning projects, and prototypes all fit this description.
A framework becomes appropriate when the application grows beyond what the team can maintain in vanilla PHP, when features like background queues, scheduled tasks, or WebSockets are needed, and when the operational cost of maintaining custom infrastructure exceeds the cost of adopting and learning a framework. Most production web applications reach this point eventually.
The goal is not to avoid frameworks. The goal is to understand what they provide well enough to make an informed decision about whether and when the framework investment is justified. If you have never built MVC from scratch, you do not fully understand what a framework is abstracting away.
Security Considerations in Custom MVC Applications
Custom MVC implementations require careful attention to security because you are building components that frameworks typically handle with vetted, battle-tested code. Several areas deserve particular attention.
SQL injection prevention relies on using prepared statements consistently. The PDO implementation shown here uses prepared statements for all queries, which is the correct approach. Never concatenate user input directly into SQL queries, even for seemingly harmless operations like sorting or filtering.
Cross-site scripting protection requires escaping all output that contains user-provided content. The htmlspecialchars function with proper encoding prevents most XSS vulnerabilities in views. For applications that accept HTML input, a sanitisation library is necessary.
Request validation should happen in controllers or a dedicated validation layer before data reaches the Model. Validate that required fields are present, that values match expected formats, and that user input does not exceed reasonable length limits.
Server configuration adds another layer of protection. Placing the application root outside the web-accessible directory prevents accidental exposure of configuration files and source code. Proper server security setup on the underlying host matters regardless of whether you use a framework or custom code.
Testing a Custom MVC Application
Testing becomes more manual in a custom MVC setup, but the separation of layers makes unit testing more practical than it would be in a tightly coupled codebase. You can test models by mocking the PDO connection, test controllers by providing mock Request objects, and test views by asserting that they produce expected HTML output for given data.
As the application grows, adding a testing framework like PHPUnit makes this process systematic rather than ad-hoc. The structure shown here is compatible with PHPUnit because the layered architecture makes dependencies explicit and mockable.