Level: capstone. This final project pulls everything together: a front controller, routing, repositories, templates, authentication, validation, CSRF, escaping and security headers. The result is a small content management system where an admin writes pages and visitors read them at clean URLs.
What it does
- Public:
/(home),/aboutand any published page by its slug, with a navigation menu built from the database. Drafts return404./sitemap.xmllists published pages. - Admin: log in, then list, create, edit, publish or unpublish and delete pages.
- Safe formatting: page text supports paragraphs, bold, italic and links, and nothing else, so an editor cannot inject scripts.
The layout
mini-cms/
├── public/
│ ├── index.php front controller
│ └── .htaccess
├── src/
│ ├── Auth.php AuthService from the login project
│ ├── PageRepository.php
│ ├── Formatter.php safe text to HTML
│ └── helpers.php h(), csrf, security headers
├── templates/
│ ├── layout.php
│ ├── page.php
│ └── admin/ list.php, form.php, login.php
├── database/schema.sql
├── bootstrap.php
└── .env.example
The schema
CREATE TABLE cms_pages (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
slug VARCHAR(120) NOT NULL UNIQUE,
title VARCHAR(200) NOT NULL,
body TEXT NOT NULL,
status ENUM('draft', 'published') NOT NULL DEFAULT 'draft',
created_at DATETIME NOT NULL,
updated_at DATETIME NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
Users and login come from the login project.
The page repository
<?php
declare(strict_types=1);
final class PageRepository
{
public function __construct(private PDO $pdo) {}
public static function validSlug(string $slug): bool
{
return preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $slug) === 1 && strlen($slug) <= 120;
}
/** @return array<string, string> */
public function validate(array $input, ?int $ignoreId = null): array
{
$errors = [];
$title = trim((string) ($input["title"] ?? ""));
$slug = trim((string) ($input["slug"] ?? ""));
$body = (string) ($input["body"] ?? "");
if ($title === "" || mb_strlen($title) > 200) {
$errors["title"] = "Enter a title (up to 200 characters).";
}
if (!self::validSlug($slug)) {
$errors["slug"] = "Use lowercase letters, numbers and single dashes.";
} elseif (in_array($slug, ["admin", "login", "logout", "sitemap-xml"], true)) {
$errors["slug"] = "That address is reserved.";
} elseif ($this->slugTaken($slug, $ignoreId)) {
$errors["slug"] = "Another page already uses that address.";
}
if (mb_strlen($body) > 50000) {
$errors["body"] = "The text is too long.";
}
return $errors;
}
private function slugTaken(string $slug, ?int $ignoreId): bool
{
$stmt = $this->pdo->prepare("SELECT COUNT(*) FROM cms_pages WHERE slug = :slug AND id <> :id");
$stmt->execute(["slug" => $slug, "id" => $ignoreId ?? 0]);
return (int) $stmt->fetchColumn() > 0;
}
public function save(?int $id, string $slug, string $title, string $body, string $status): int
{
$status = $status === "published" ? "published" : "draft";
if ($id === null) {
$this->pdo->prepare("INSERT INTO cms_pages (slug, title, body, status, created_at) VALUES (:slug, :title, :body, :status, :now)")
->execute(["slug" => $slug, "title" => $title, "body" => $body, "status" => $status, "now" => date("Y-m-d H:i:s")]);
return (int) $this->pdo->lastInsertId();
}
$this->pdo->prepare("UPDATE cms_pages SET slug = :slug, title = :title, body = :body, status = :status, updated_at = :now WHERE id = :id")
->execute(["slug" => $slug, "title" => $title, "body" => $body, "status" => $status, "now" => date("Y-m-d H:i:s"), "id" => $id]);
return $id;
}
public function publishedBySlug(string $slug): ?array
{
$stmt = $this->pdo->prepare("SELECT id, slug, title, body, created_at, updated_at FROM cms_pages WHERE slug = :slug AND status = 'published'");
$stmt->execute(["slug" => $slug]);
return $stmt->fetch() ?: null;
}
public function find(int $id): ?array
{
$stmt = $this->pdo->prepare("SELECT id, slug, title, body, status FROM cms_pages WHERE id = :id");
$stmt->execute(["id" => $id]);
return $stmt->fetch() ?: null;
}
/** @return array<int, array{slug: string, title: string}> for the menu and sitemap */
public function menu(): array
{
return $this->pdo->query("SELECT slug, title FROM cms_pages WHERE status = 'published' ORDER BY id")->fetchAll();
}
public function all(): array
{
return $this->pdo->query("SELECT id, slug, title, status FROM cms_pages ORDER BY id")->fetchAll();
}
public function delete(int $id): bool
{
$stmt = $this->pdo->prepare("DELETE FROM cms_pages WHERE id = :id");
$stmt->execute(["id" => $id]);
return $stmt->rowCount() === 1;
}
}
Safe formatting
The admin writes plain text with a little formatting. The formatter escapes everything first, and only then turns a few simple patterns into HTML. Whatever is typed, only these tags can appear: <p>, <br>, <strong>, <em> and <a>.
<?php
declare(strict_types=1);
final class Formatter
{
public static function toHtml(string $text): string
{
$text = str_replace(["\r\n", "\r"], "\n", trim($text));
if ($text === "") {
return "";
}
$paragraphs = preg_split('/\n{2,}/', $text) ?: [];
$html = array_map(function (string $paragraph): string {
$safe = htmlspecialchars($paragraph, ENT_QUOTES, "UTF-8");
// **bold** and *italic*
$safe = preg_replace('/\*\*(.+?)\*\*/s', "<strong>$1</strong>", $safe) ?? $safe;
$safe = preg_replace('/(?<!\*)\*(?!\s)(.+?)(?<!\s)\*(?!\*)/s', "<em>$1</em>", $safe) ?? $safe;
// [text](https://...) links: only http and https
$safe = preg_replace_callback('/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/', function (array $m): string {
return '<a href="' . $m[2] . '" rel="noopener noreferrer">' . $m[1] . "</a>";
}, $safe) ?? $safe;
return "<p>" . nl2br($safe, false) . "</p>";
}, $paragraphs);
return implode("\n", $html);
}
}
The link URL has already been HTML-escaped, so a " in it cannot break out of the attribute. A link such as [x](javascript:alert(1)) does not match the pattern (it must start with http:// or https://), so it stays as harmless text.
A sitemap
<?php
function sitemapXml(array $pages, string $baseUrl): string
{
$urls = array_map(
fn(array $p) => " <url><loc>" . htmlspecialchars(rtrim($baseUrl, "/") . ($p["slug"] === "home" ? "/" : "/" . $p["slug"]), ENT_XML1) . "</loc></url>",
$pages
);
return '<?xml version="1.0" encoding="UTF-8"?>' . "\n"
. '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n"
. implode("\n", $urls) . "\n</urlset>";
}
Test the core of the CMS
<?php
require __DIR__ . "/PageRepository.php";
require __DIR__ . "/Formatter.php";
function sitemapXml(array $pages, string $baseUrl): string
{
$urls = array_map(
fn(array $p) => " <url><loc>" . htmlspecialchars(rtrim($baseUrl, "/") . ($p["slug"] === "home" ? "/" : "/" . $p["slug"]), ENT_XML1) . "</loc></url>",
$pages
);
return '<?xml version="1.0" encoding="UTF-8"?>' . "\n"
. '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n"
. implode("\n", $urls) . "\n</urlset>";
}
$pages = new PageRepository(require __DIR__ . "/db.php");
echo "Menu: " . implode(", ", array_column($pages->menu(), "title")) . "\n";
echo "Draft visible? " . var_export($pages->publishedBySlug("secret") !== null, true) . "\n";
echo "About: " . $pages->publishedBySlug("about")["title"] . "\n\n";
print_r($pages->validate(["title" => "", "slug" => "About Us!", "body" => "x"]));
print_r($pages->validate(["title" => "Another", "slug" => "about", "body" => "x"]));
echo count($pages->validate(["title" => "Contact", "slug" => "contact", "body" => "Write to us."])) . " errors for a good page\n\n";
$id = $pages->save(null, "contact", "Contact", "Write to us.", "published");
$pages->save($id, "contact", "Contact us", "Write to **us** today.", "published");
echo $pages->find($id)["title"] . "\n\n";
echo Formatter::toHtml("Hello **world** and *friends*.\nSecond line.\n\nSee [PHP](https://www.php.net) now.\n\n<script>alert(1)</script> [bad](javascript:alert(1))") . "\n\n";
echo sitemapXml($pages->menu(), "https://example.com") . "\n\n";
var_dump($pages->delete($id));
echo count($pages->all()) . " pages remain";
Menu: Welcome, About us
Draft visible? false
About: About us
Array
(
[title] => Enter a title (up to 200 characters).
[slug] => Use lowercase letters, numbers and single dashes.
)
Array
(
[slug] => Another page already uses that address.
)
0 errors for a good page
Contact us
<p>Hello <strong>world</strong> and <em>friends</em>.<br>
Second line.</p>
<p>See <a href="https://www.php.net" rel="noopener noreferrer">PHP</a> now.</p>
<p><script>alert(1)</script> [bad](javascript:alert(1))</p>
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url><loc>https://example.com/</loc></url>
<url><loc>https://example.com/about</loc></url>
<url><loc>https://example.com/contact</loc></url>
</urlset>
bool(true)
3 pages remain
Check the important results:
- The draft page is not visible to the public, and not in the menu.
- Validation caught a bad slug and a duplicate slug.
- The
<script>tag was escaped and thejavascript:link stayed as plain text. - The sitemap lists only published pages.
The front controller and routing
<?php
declare(strict_types=1);
$app = require dirname(__DIR__) . "/bootstrap.php"; // sessions, security headers, $pdo, $pages, $auth
$method = $_SERVER["REQUEST_METHOD"];
$path = trim((string) parse_url($_SERVER["REQUEST_URI"], PHP_URL_PATH), "/");
// ---- public routes ----
if ($method === "GET" && $path === "sitemap.xml") {
header("Content-Type: application/xml; charset=utf-8");
echo sitemapXml($app["pages"]->menu(), $app["config"]["base_url"]);
exit;
}
if ($path === "login") {
require dirname(__DIR__) . "/templates/admin/login.php"; // handles GET and POST, see the login project
exit;
}
// ---- admin routes (login required) ----
if ($path === "admin" || str_starts_with($path, "admin/")) {
$user = requireLogin($app["auth"]);
if ($user["role"] !== "admin") {
http_response_code(403);
exit("Forbidden");
}
// admin/pages, admin/pages/new, admin/pages/{id}, admin/pages/{id}/delete ...
require dirname(__DIR__) . "/templates/admin/router.php";
exit;
}
// ---- public page by slug ----
$slug = $path === "" ? "home" : $path;
$page = PageRepository::validSlug($slug) ? $app["pages"]->publishedBySlug($slug) : null;
if ($page === null) {
http_response_code(404);
$page = ["title" => "Page not found", "body" => "Sorry, that page does not exist.", "slug" => ""];
}
$menu = $app["pages"]->menu();
$content = Formatter::toHtml($page["body"]);
require dirname(__DIR__) . "/templates/layout.php";
The layout template
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= h($page["title"]) ?> | Mini CMS</title>
</head>
<body>
<header>
<nav aria-label="Main">
<?php foreach ($menu as $item): ?>
<a href="<?= $item["slug"] === "home" ? "/" : "/" . h($item["slug"]) ?>"
<?= $item["slug"] === $page["slug"] ? 'aria-current="page"' : "" ?>><?= h($item["title"]) ?></a>
<?php endforeach; ?>
</nav>
</header>
<main>
<h1><?= h($page["title"]) ?></h1>
<?= $content /* already safe: produced by Formatter::toHtml() */ ?>
</main>
<footer><p>© <?= date("Y") ?> Mini CMS</p></footer>
</body>
</html>
$content is the only value printed without escaping, and it is safe because Formatter escapes first. Every other value passes through h().
The bootstrap
<?php
declare(strict_types=1);
require __DIR__ . "/vendor/autoload.php"; // or the require lines for src/*.php
require __DIR__ . "/src/helpers.php";
error_reporting(E_ALL);
ini_set("display_errors", getenv("APP_DEBUG") === "true" ? "1" : "0");
ini_set("log_errors", "1");
foreach ([
"X-Content-Type-Options: nosniff",
"Referrer-Policy: strict-origin-when-cross-origin",
"Content-Security-Policy: default-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'",
] as $header) {
header($header);
}
session_set_cookie_params(["httponly" => true, "samesite" => "Lax", "secure" => !empty($_SERVER["HTTPS"])]);
session_start();
$pdo = require __DIR__ . "/db.php";
return [
"config" => ["base_url" => getenv("APP_URL") ?: "http://localhost:8000"],
"pdo" => $pdo,
"pages" => new PageRepository($pdo),
"auth" => new AuthService($pdo),
];
The security review (do this before you call it done)
| Area | Check |
|---|---|
| SQL injection | Every query uses placeholders |
| XSS | h() on every value, and Formatter for page text. A CSP header as a second layer |
| CSRF | A token on login, logout and every admin form (csrfCheck()) |
| Authentication | password_hash(), generic errors, rate limiting, session_regenerate_id(true) |
| Authorisation | The admin check runs on every admin route, not only the menu |
| Sessions | HttpOnly, SameSite, Secure; no-store on admin pages |
| Routing | Slugs are validated with a strict pattern before use |
| Errors | display_errors off in production; log to a file outside the web root |
| Secrets | Database password and keys in the environment, not in Git |
| Files | Only public/ is web-accessible |
| Backups | A tested database backup |
What you built
A real application with the skills from the whole course: syntax, functions, arrays, forms, validation, OOP, namespaces and Composer, PDO and MySQL, CRUD, authentication, security, routing and project structure.
Where to go next
- Laravel or Symfony: they give you this architecture (and much more) ready-made. You now understand what they do.
- WordPress development: you can read themes and plugins with confidence (PHP for WordPress Developers).
- Testing with PHPUnit or Pest.
- A deeper database course, and React or another front-end framework to talk to your APIs.
Final challenges
- Add a media library with the upload manager.
- Add page revisions: keep old versions and restore them.
- Add a search box using the search project.
- Expose the pages through a JSON API (the API project).
- Add user roles (admin, editor) with different permissions.
- Add caching of rendered pages with cache invalidation when a page changes.
- Write tests for
PageRepositoryandFormatter.