Model-View-Controller (MVC) is the way almost every PHP framework, from Laravel to Symfony, organises code. Building a small MVC app by hand is the best way to understand what those frameworks do for you. This guide builds one in plain modern PHP: a front controller, a safe router, a model that talks to MySQL with PDO, escaped views and a form that is protected against the common attacks.
MVC splits an app into the model (data and rules), the view (what the user sees) and the controller (takes the request, asks the model, picks the view). Send every request through one index.php, map URLs to controllers with an explicit route list (never build class names from user input), use PDO prepared statements, and escape every value in views with htmlspecialchars. For real projects, use a framework such as Laravel or Symfony.
1. What each part does
| Part | Its job | In our example |
|---|---|---|
| Model | Reads and writes data and enforces business rules | UserModel with PDO queries |
| View | Turns data into HTML; no queries, no business logic | views/users.php |
| Controller | Reads the request, calls the model, chooses a view or redirect | UserController |
| Front controller and router | One entry point that sends each URL to the right controller | public_html/index.php |
The point is separation of concerns. You can change the page design without touching SQL, or change the database without touching HTML, and each part is easier to test.
2. Project layout
Keep your application code outside the web root, so nobody can request a model or config file directly. Only index.php and your static files are public.
/home/username/
├── myapp/
│ ├── config.php # database settings (not web-accessible)
│ ├── src/
│ │ ├── Router.php
│ │ ├── UserController.php
│ │ └── UserModel.php
│ └── views/
│ └── users.php
└── public_html/
├── .htaccess
└── index.php # front controller3. Send every request to index.php
On Apache, this .htaccess in public_html sends every URL that isn't a real file or folder to index.php:
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [QSA,L]The front controller loads the classes, creates the objects and hands the request to the router:
<?php
declare(strict_types=1);
// public_html/index.php
spl_autoload_register(function (string $class): void {
$file = __DIR__ . '/../myapp/src/' . $class . '.php';
if (is_file($file)) {
require $file;
}
});
session_start();
$config = require __DIR__ . '/../myapp/config.php';
$pdo = new PDO($config['dsn'], $config['user'], $config['pass'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$users = new UserController(new UserModel($pdo), __DIR__ . '/../myapp/views');
$router = new Router();
$router->get('/users', [$users, 'index']);
$router->post('/users', [$users, 'store']);
$router->dispatch($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI']);4. A router that only runs what you allow
A common beginner router reads ?controller=home&action=index and calls whatever class and method the URL names. That lets a visitor instantiate classes you never meant to expose. Use an explicit route list instead:
<?php
declare(strict_types=1);
final class Router
{
private array $routes = [];
public function get(string $path, callable $handler): void { $this->routes['GET'][$path] = $handler; }
public function post(string $path, callable $handler): void { $this->routes['POST'][$path] = $handler; }
public function dispatch(string $method, string $uri): void
{
$path = rtrim(parse_url($uri, PHP_URL_PATH) ?: '/', '/') ?: '/';
$handler = $this->routes[$method][$path] ?? null;
if ($handler === null) {
http_response_code(404);
echo 'Page not found';
return;
}
$handler();
}
}Only the paths you registered can run. Anything else gets a 404.
5. The model: data access with PDO
The model is the only place that knows about the database. Prepared statements keep user input out of the SQL text, which prevents SQL injection.
<?php
declare(strict_types=1);
final class UserModel
{
public function __construct(private readonly PDO $db) {}
public function all(): array
{
return $this->db->query('SELECT id, name, email FROM users ORDER BY id')->fetchAll();
}
public function create(string $name, string $email): void
{
$stmt = $this->db->prepare('INSERT INTO users (name, email) VALUES (:name, :email)');
$stmt->execute(['name' => $name, 'email' => $email]);
}
}The table behind it:
CREATE TABLE users (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(190) NOT NULL UNIQUE
);6. The controller: validate, then act
The controller validates input, checks a CSRF token, calls the model and then redirects after a successful POST (the Post/Redirect/Get pattern), so refreshing the page doesn't submit the form twice.
<?php
declare(strict_types=1);
final class UserController
{
public function __construct(private UserModel $users, private string $viewDir) {}
public function index(array $errors = []): void
{
$_SESSION['csrf'] ??= bin2hex(random_bytes(32));
$this->render('users', [
'users' => $this->users->all(),
'errors' => $errors,
'csrf' => $_SESSION['csrf'],
]);
}
public function store(): void
{
if (!hash_equals($_SESSION['csrf'] ?? '', $_POST['csrf'] ?? '')) {
http_response_code(419);
exit('Session expired. Reload the page and try again.');
}
$name = trim($_POST['name'] ?? '');
$email = trim($_POST['email'] ?? '');
$errors = [];
if ($name === '') { $errors[] = 'Name is required.'; }
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { $errors[] = 'Enter a valid email address.'; }
if ($errors) {
$this->index($errors);
return;
}
$this->users->create($name, $email);
header('Location: /users', true, 303);
}
private function render(string $view, array $data): void
{
extract($data, EXTR_SKIP);
require $this->viewDir . '/' . $view . '.php';
}
}A real app would also catch the duplicate-email database error and show it as a friendly message.
7. The view: escape everything
Views only display data. Every value that came from a user or the database goes through htmlspecialchars, which prevents cross-site scripting (XSS).
<?php $e = fn(string $s): string => htmlspecialchars($s, ENT_QUOTES, 'UTF-8'); ?>
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Users</title></head>
<body>
<h1>Users</h1>
<?php foreach ($errors as $error): ?>
<p class="error"><?= $e($error) ?></p>
<?php endforeach; ?>
<ul>
<?php foreach ($users as $user): ?>
<li><?= $e($user['name']) ?> (<?= $e($user['email']) ?>)</li>
<?php endforeach; ?>
</ul>
<form method="post" action="/users">
<input type="hidden" name="csrf" value="<?= $e($csrf) ?>">
<input name="name" placeholder="Name" required>
<input name="email" type="email" placeholder="Email" required>
<button type="submit">Add user</button>
</form>
</body>
</html>8. When to move to a framework
A hand-built MVC app is excellent for learning and fine for a very small tool. Once you need authentication, migrations, queues, email or an API, a framework gives you tested versions of all of it.
- Learning how routing, controllers and views fit together
- A tiny internal tool with a few pages
- Code that must run with no dependencies at all
- You need user accounts, password resets and roles
- The app has more than a handful of routes or developers
- You want database migrations, validation rules and tests built in
For an API-focused take on the same ideas, see RESTful APIs, MVC and state management. Security deep dives: preventing SQL injection and preventing XSS.
9. Running this on Domain India
This example runs as-is on Domain India cPanel and DirectAdmin shared hosting:
- URL rewriting works. mod_rewrite is loaded and
.htaccessis allowed on our cPanel, DirectAdmin and Webuzo servers. See enabling mod_rewrite. - No
php_valuein .htaccess. PHP does not run as an Apache module on our servers, sophp_valueorphp_flaglines give a 500 error. Change PHP settings in the control panel instead. - PHP version. On cPanel, new accounts use PHP 8.3 and you can switch per domain in MultiPHP Manager. On DirectAdmin, choose the version per domain in the panel.
- Database. Create the database and user in your control panel; both names carry your account prefix, and the host is
localhost. See how to connect to the MySQL database. - Composer. Composer isn't pre-installed on our shared hosting, but you can run
composer.pharover jailed SSH (on request). This example needs no Composer; for a framework, install dependencies on your computer or in CI and upload the project with itsvendor/folder. - DirectAdmin note. DirectAdmin serves
index.htmlbeforeindex.php, so delete any placeholderindex.htmlinpublic_html.
For a Laravel app on shared hosting, see deploying Laravel on cPanel. Prices on the cards are Domain India list prices on 19 September 2026, excluding 18% GST.
- 25 GB NVMe SSD Storage
- 50 GB Monthly Bandwidth
- 1 Website
- 10 Email Accounts
- 10 GB NVMe SSD Storage
- 50 GB Monthly Bandwidth
- 1 Website
- 5 Email Accounts
What is MVC in PHP?
MVC stands for Model-View-Controller. The model handles data and business rules, the view produces the HTML, and the controller takes each request, calls the model and chooses the view. Laravel and Symfony are both built on this pattern.
Is it safe to route with ?controller=home&action=index?
Not if the code builds the class and method name from the URL, because a visitor can then call classes you never meant to expose. Use an explicit list of routes that maps each path to one handler.
How do I stop SQL injection in a PHP MVC app?
Keep all queries in the model and use PDO prepared statements with placeholders, so user input is never inserted into the SQL text.
How do I stop XSS in PHP views?
Escape every value you print with htmlspecialchars($value, ENT_QUOTES, 'UTF-8'), or use a template engine that escapes by default, such as Twig or Laravel's Blade.
Do I need Composer to build an MVC app in PHP?
No. A small app can load its classes with spl_autoload_register, as in this guide. Frameworks use Composer; on Domain India shared hosting, run Composer on your computer or in CI and upload the vendor folder.
Does URL rewriting work on Domain India shared hosting?
Yes. mod_rewrite is loaded and .htaccess files are allowed on our cPanel, DirectAdmin and Webuzo servers, so a front-controller rewrite to index.php works.
Ready to deploy your PHP app? Compare cPanel hosting and DirectAdmin hosting, or chat with us 24/7 if you have a question before you buy.
PHP 8.3 by default, mod_rewrite and .htaccess, MySQL databases and free SSL on every plan.
See cPanel plans