Mixing database queries, decisions and HTML in one file makes code hard to read and change. The MVC idea (Model, View, Controller) separates them:
| Part | Job |
|---|---|
| Model | The data and its rules (a Product, a repository) |
| View | The HTML template. It only displays data |
| Controller | Receives the request, uses models, picks a view |
The view: a template renderer
A view is a PHP file that prints data. A small function renders it with variables and returns the HTML as a string.
<?php
function render(string $template, array $data = []): string
{
extract($data, EXTR_SKIP);
ob_start();
include $template;
return (string) ob_get_clean();
}
function h(string $text): string
{
return htmlspecialchars($text, ENT_QUOTES, "UTF-8");
}
file_put_contents(
"product-list.php",
"<h1><?= h(\$title) ?></h1>\n<ul>\n<?php foreach (\$products as \$p): ?>\n <li><?= h(\$p['name']) ?></li>\n<?php endforeach; ?>\n</ul>"
);
echo render(__DIR__ . "/product-list.php", [
"title" => "Our products",
"products" => [["name" => "Pen"], ["name" => "Tom & Jerry book"]],
]);
<h1>Our products</h1>
<ul>
<li>Pen</li>
<li>Tom & Jerry book</li>
</ul>
The template has no SQL, no $_GET, no business rules. It only loops and prints (with escaping).
The controller
A controller method is small: read the request, ask the model, render the view.
<?php
final class ProductController
{
public function __construct(private ProductRepository $products) {}
public function index(): array
{
$items = $this->products->all();
return ["status" => 200, "view" => "product-list", "data" => ["title" => "Our products", "products" => $items]];
}
public function show(int $id): array
{
$product = $this->products->find($id);
if ($product === null) {
return ["status" => 404, "view" => "not-found", "data" => []];
}
return ["status" => 200, "view" => "product-show", "data" => ["product" => $product]];
}
}
final class ProductRepository
{
private array $rows = [1 => ["id" => 1, "name" => "Pen"], 2 => ["id" => 2, "name" => "Book"]];
public function all(): array
{
return array_values($this->rows);
}
public function find(int $id): ?array
{
return $this->rows[$id] ?? null;
}
}
$controller = new ProductController(new ProductRepository());
echo $controller->index()["status"] . " " . count($controller->index()["data"]["products"]) . " products\n";
echo $controller->show(2)["data"]["product"]["name"] . "\n";
echo $controller->show(99)["status"];
200 2 products
Book
404
The controller returns a description of the response (status, view name, data). It does not print. A small piece of code at the edge renders the view and sends it. That makes the controller easy to test: no HTML, no headers, no output.
What goes where
| Question | Answer |
|---|---|
| Where does SQL go? | Repository (model layer) |
| Where do business rules go? | Services or the model |
Where does $_GET/$_POST get read? | The controller (or a request object) |
| Where does HTML go? | The view |
Where does header() / http_response_code() go? | The edge of the app (front controller), not the views |
Layouts
Pages share a header and footer. Render the page into a string, then pass it to a layout.
<?php
function renderLayout(string $title, string $content): string
{
return "<!DOCTYPE html><html lang=\"en\"><head><title>" . htmlspecialchars($title) . "</title></head><body>" . $content . "</body></html>";
}
echo renderLayout("Home", "<h1>Welcome</h1>");
<!DOCTYPE html><html lang="en"><head><title>Home</title></head><body><h1>Welcome</h1></body></html>
Keep views dumb
- Only display, loop and
if. - Escape every value.
- No queries, no calculations that belong in a service, no
$_SESSIONwrites.
Template engines
Twig, Blade and Plates give templates a cleaner syntax and escape by default. For larger projects they are a good idea. Plain PHP templates work well for small ones, as long as you use the escape helper every time.
Why this matters in Laravel
return view("products.index", ["products" => $products]);) and the framework renders Blade templates. The structure matches this lesson.