Skip to content

PHP Tutorial Practical PHP Projects

Project 11: Final Project, a Mini CMS

Lesson 357 of 369 4 min read By Deepak Paswan Updated Oct 5, 2026

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), /about and any published page by its slug, with a navigation menu built from the database. Drafts return 404. /sitemap.xml lists 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

Text
PLAIN
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

query.sql
SQL
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

PageRepository.php
PHP
<?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>.

Formatter.php
PHP
<?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

index.php
PHP
<?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

index.php
PHP
<?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";
Output
OUTPUT
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>&lt;script&gt;alert(1)&lt;/script&gt; [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 the javascript: link stayed as plain text.
  • The sitemap lists only published pages.

The front controller and routing

public/index.php
PHP
<?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

templates/layout.php
PHP
<!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>&copy; <?= 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

bootstrap.php
PHP
<?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)

AreaCheck
SQL injectionEvery query uses placeholders
XSSh() on every value, and Formatter for page text. A CSP header as a second layer
CSRFA token on login, logout and every admin form (csrfCheck())
Authenticationpassword_hash(), generic errors, rate limiting, session_regenerate_id(true)
AuthorisationThe admin check runs on every admin route, not only the menu
SessionsHttpOnly, SameSite, Secure; no-store on admin pages
RoutingSlugs are validated with a strict pattern before use
Errorsdisplay_errors off in production; log to a file outside the web root
SecretsDatabase password and keys in the environment, not in Git
FilesOnly public/ is web-accessible
BackupsA 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

  1. Add a media library with the upload manager.
  2. Add page revisions: keep old versions and restore them.
  3. Add a search box using the search project.
  4. Expose the pages through a JSON API (the API project).
  5. Add user roles (admin, editor) with different permissions.
  6. Add caching of rendered pages with cache invalidation when a page changes.
  7. Write tests for PageRepository and Formatter.
info
Congratulations
If you built this project yourself, you can build most PHP and MySQL websites. Keep going: read other people’s code, and build something you actually want to use.