Project Walkthrough: Building a Blog Platform with Comments and Moderation

Design and build a blog platform in PHP covering user/post schema design, flat comments with moderation workflow, role-based permissions (author/moderator/admin), validated file uploads for cover images, basic search, page/query caching, and a PHPUnit feature test for post creation — combining database design, OOP, security, and testing lessons already covered in the course.

This project moves from a pure API (Project 4) to a content-driven application with real users, permissions, and media. A blog platform sounds simple on the surface — users write posts, other users comment — but it exercises nearly every skill in the course at once: relational schema design for users/posts/comments, role-based access control, file upload validation, search, caching, and automated testing. We'll build it incrementally: first the schema and the plain post-authoring flow, then comments and a moderation workflow, then cover-image uploads, then search and caching for the post listing, finishing with a PHPUnit feature test that exercises the whole post-creation path end to end. As with the REST API project, the goal isn't new syntax — it's seeing how the pieces you already know (PDO, sessions, validation, hashing, testing) compose into something a real team could ship.

Project Overview and Requirements

The platform needs three kinds of users distinguished by role: an author can write and edit their own posts; a moderator can additionally hide or approve any comment; an admin can do everything moderators can plus manage users and roles. Every post has a title, body (stored as Markdown or HTML, rendered at display time), an optional cover image, a status (draft or published), and timestamps. Comments are flat (not threaded, to keep the schema and moderation logic approachable) and belong to exactly one post and one commenting user.

Moderation is the feature that most distinguishes this from a toy blog: new comments start in a pending state and only become publicly visible once approved, OR — a common alternative — comments are visible immediately but can be flagged by other users and then hidden by a moderator. We'll implement the second approach (visible-by-default, flag-and-hide) since it's the more common real-world pattern for a low-moderation-overhead blog, and it's a good vehicle for practicing role checks: only a moderator or admin can act on a flag.

Rounding out the requirements: authors can attach a cover image to a post, which must be validated for both file type and size before being accepted (a classic file-upload security concern from the earlier uploads lesson); visitors can search posts by title/content keyword; and the post listing page, which runs a moderately expensive query (joining posts with author info and comment counts), should be cached so it doesn't hit the database on every single request.

• Roles: author (own posts), moderator (comment moderation), admin (everything). • Posts: title, body, cover image, status (draft/published), timestamps. • Comments: flat structure, visible-by-default with flag-and-hide moderation. • Cover image uploads validated for MIME type and size. • Keyword search over post title/content; cached post listing.

Schema Design: Users, Posts, and Comments

The schema follows the normalization principles from the database design lessons: users (id, name, email, password_hash, role), posts (id, user_id FK, title, slug, body, cover_image_path, status, published_at, created_at), comments (id, post_id FK, user_id FK, body, is_hidden, created_at), and comment_flags (id, comment_id FK, flagged_by_user_id FK, reason, created_at) to record who flagged what and why, which is useful both for moderator context and for detecting abuse of the flagging feature itself.

posts.slug is a URL-friendly, unique, generated-from-title column (e.g. "Building a Blog" becomes building-a-blog), indexed for fast lookups on the public post page — a direct application of the indexing lesson, since post pages are read far more often than posts are written, making a unique index on slug one of the highest-value indexes in the whole schema. posts.status combined with published_at lets the listing query cleanly express "published posts, newest first" as WHERE status = 'published' ORDER BY published_at DESC, and lets scheduled publishing be added later just by filtering on published_at <= NOW().

The role column lives directly on users as an enum-like string (author, moderator, admin) rather than a separate roles/permissions table, which is a deliberate simplification appropriate for a project this size — the earlier lesson on when to normalize versus denormalize applies here: a full RBAC system with a roles and permissions join table is the right call for a large application with dozens of fine-grained permissions, but three fixed roles checked with simple if statements is the right call here, and adding that complexity prematurely would just be over-engineering.

• users(id, name, email, password_hash, role) • posts(id, user_id, title, slug, body, cover_image_path, status, published_at, created_at) • comments(id, post_id, user_id, body, is_hidden, created_at) • comment_flags(id, comment_id, flagged_by_user_id, reason, created_at) • Unique index on posts.slug; composite consideration on (status, published_at) for the listing query.

Core Implementation: Authoring and Publishing a Post

The post-creation flow follows the same validate-then-persist shape from the REST API project: PostController::store() checks that the current session user is authenticated and has the author role (or higher), validates the submitted title and body (title required, 3–150 characters; body required, non-empty after stripping tags), generates a unique slug from the title, handles the optional cover image upload, and inserts the row inside a single transaction so that a failure partway through — say, the image finishes uploading but the database insert fails — doesn't leave an orphaned file with no corresponding post.

Slug generation deserves care: naively slugifying the title can collide (two posts both titled "Getting Started") so the implementation lowercases, replaces non-alphanumeric runs with hyphens, and then checks the database for an existing slug, appending -2, -3, and so on until it finds one that's free — the same incrementing-suffix strategy used by many CMSs and easy to get subtly wrong if you don't loop until you actually find a free slug rather than just trying once.

Authorization here is a single, cheap role check ($user->role === 'author' || $user->role === 'admin'), but it's worth calling back to the authentication/authorization lessons on why this check must happen server-side on every request that mutates data, never trusted from a hidden form field or a client-side check alone — a malicious or just curious user can submit a POST request directly, bypassing any UI-level restriction entirely.

• Validate title/body before any database or filesystem operation. • Generate a unique slug by checking the database and appending an incrementing suffix on collision. • Wrap image upload + database insert in a transaction to avoid orphaned files. • Role check (author/admin) is enforced server-side, never trusted from the client.

Comments, Roles, and the Moderation Workflow

Posting a comment is deliberately lightweight — any authenticated user can comment, with the same validation discipline as everywhere else (non-empty body, reasonable max length to prevent abuse). The interesting logic is moderation: CommentController::flag() lets any logged-in user flag a comment, recording a row in comment_flags with an optional reason; CommentController::hide() and CommentController::approve() are restricted to users whose role is moderator or admin, and simply toggle the comment's is_hidden column, which the post-rendering query filters on (WHERE is_hidden = 0) so hidden comments simply don't appear to ordinary visitors.

A useful refinement is auto-hiding a comment once it accumulates enough flags (say, three distinct flaggers) so obviously abusive content disappears without waiting for a moderator to be online, while still leaving the comment visible to moderators (via a ?show_hidden=1 view gated on role) so they can review and either confirm the hide or reverse it. This two-tier visibility — hidden from the public, visible to moderators — is a pattern worth recognizing because it shows up constantly in content-moderation systems: never truly delete disputed content immediately, because moderators and admins need to be able to review the actual thing that was flagged.

Role checks throughout the comment controller follow one consistent helper, Auth::requireRole(['moderator', 'admin']), rather than repeating the same if condition in every method — a small refactor that pays off the moment a new role or a new permission rule needs to change, since there's exactly one place to update instead of several scattered checks that can drift out of sync.

• Any authenticated user can comment and can flag any comment. • hide()/approve() restricted to moderator/admin via a shared Auth::requireRole() helper. • Optional auto-hide after N distinct flags, with hidden comments still visible to moderators. • Comment queries filter on is_hidden so the public view never renders hidden content.

File Uploads: Cover Images

Cover image uploads follow the file-upload security checklist from the earlier uploads lesson closely, because uploaded files are one of the riskiest inputs any web application accepts. First, the size is checked against $_SERVER limits and an explicit application-level cap (say, 5 MB) before doing anything else. Second, the file's actual content — not its client-supplied filename or Content-Type header, both of which are trivially spoofable — is inspected with finfo_file() to determine the real MIME type, and only image/jpeg, image/png, and image/webp are accepted.

Third, the file is renamed to a generated, unpredictable name (e.g. a UUID plus the correct extension for its detected type) before being stored outside of any directly-executable path, or at minimum in a directory where the web server is configured never to execute PHP — this prevents the classic upload-a-.php-file-disguised-as-an-image attack, where an attacker uploads a file containing PHP code, tricks a naive validator that only checks the filename extension, and then requests that file directly to have the server execute it.

Finally, storing only the relative path (not a full filesystem path, and never trusting a path the client suggests) in posts.cover_image_path, and serving it back through a URL that maps to the storage directory, keeps the filesystem layout decoupled from anything a client can influence. Combined, these steps mean an attacker controls neither the stored filename, the execution context, nor (after the MIME sniff) even the actual file type — only the pixel content of a genuine image.

• Enforce a max size cap in addition to whatever php.ini already limits. • Detect real MIME type via finfo_file(), never trust the client's filename or Content-Type. • Store with a generated, unpredictable filename in a non-executable directory. • Persist only a relative path; never let client input dictate the filesystem path.

Search and Caching the Post Listing

Search is implemented as a simple LIKE-based keyword match across title and body for a project at this scale (WHERE status = 'published' AND (title LIKE :q OR body LIKE :q)), which is perfectly adequate for a blog with hundreds or low thousands of posts; the earlier lesson on full-text search covers MATCH ... AGAINST (MySQL) or a dedicated search engine like Elasticsearch or Meilisearch as the upgrade path once LIKE scans become a measurable bottleneck or ranking quality matters more than exact substring matching.

The post listing page is a good caching candidate because it's read constantly (it's usually the site's homepage) but only changes when a post is published, edited, or a comment count changes — a low write-to-read ratio that's exactly what caching is for, per the caching lesson. We cache the rendered result (or just the expensive joined query result) behind a key like posts:listing:page:{page}, with a short TTL (say, 60 seconds) as a safety net, plus explicit invalidation — deleting the relevant cache keys — whenever a post is published or unpublished, so readers never wait more than a minute to see a genuinely new post even if invalidation is somehow missed.

This combination of a short TTL and explicit invalidation on write is a deliberately defensive pattern: TTL alone risks staleness for longer than acceptable if you pick too generous a duration, and invalidation alone is fragile if some code path that changes post state forgets to clear the cache — together they give you a system that's both mostly instantly fresh and safe against a missed invalidation call.

• Search: LIKE-based match on title/body for this scale; full-text search as the identified upgrade path. • Cache the listing query/render behind a page-numbered key with a short TTL. • Explicit cache invalidation on publish/unpublish/edit, not just reliance on TTL expiry. • TTL + invalidation together guard against both staleness and a missed invalidation call.

Testing the Post Creation Flow

A PHPUnit feature test for post creation exercises the whole authoring path the way a real request would: authenticate as an author, submit a title and body (and, in an extended version, a fake uploaded file via PHPUnit's ability to populate $_FILES), and assert both on the HTTP response (status 201, JSON body containing the new post's slug) and on the database state (a row now exists in posts with the expected title and a slug that was actually generated), following the feature-testing lesson's guidance to test through the same entry point real users hit rather than calling internal methods directly, which is what makes the test meaningful evidence that the feature works end to end.

Running this test against a real (but disposable) test database — wrapped in a transaction that's rolled back after each test, per the database-testing lesson — rather than mocking the database out entirely, catches an entire category of bugs that pure mocks miss: a typo in a column name, a missing NOT NULL constraint the insert violates, or a unique-slug collision bug, all of which would pass a mocked-repository test but fail the moment they hit real SQL.

• Feature test authenticates as an author and submits through the real controller/route, not a bypassed internal call. • Assertions cover both the HTTP response and the resulting database row. • Test database wrapped in a rolled-back transaction per test for isolation and speed. • Real SQL against a real (disposable) schema catches bugs a fully mocked test would miss.

Code Examples

The transaction wraps only the database insert, but the code still cleans up the uploaded file if the insert fails, avoiding an orphaned file on disk. generateUniqueSlug() loops until it finds a free slug rather than trying once, and storeCoverImage() checks size, sniffs the real MIME type with finfo, and generates an unguessable filename — the same three-part discipline described in the file-upload section above.

getPage() checks the cache before running the (moderately expensive, joined and correlated-subquery-bearing) query, and stores the result with a short 60-second TTL as a fallback. invalidateListingCache() is called explicitly whenever a post's published status changes, so the combination of TTL and invalidation keeps the listing fresh without hitting the database on every homepage view.

The test exercises PostController::store() exactly the way a real request would — building a Request, calling the controller, and asserting on both the HTTP-shaped response and the resulting database row — rather than checking internal state directly. Wrapping setUp/tearDown around a transaction that's rolled back keeps every test isolated and fast even though it runs against a real SQLite schema instead of a mock.

$mime = $_FILES['cover_image']['type']; // trusts the browser-supplied Content-Type

$mime = finfo_file(finfo_open(FILEINFO_MIME_TYPE), $_FILES['cover_image']['tmp_name']);

The type field in $_FILES is supplied by the client and is trivially spoofable — an attacker can upload a PHP file and simply claim it's image/png. Only inspecting the file's actual bytes with finfo tells you what it really is.

$slug = strtolower(str_replace(' ', '-', $title)); // no uniqueness check

// generate base slug, then loop appending -2, -3, ... until the slug is free in the database

Two posts with the same or similar title (or even the same title submitted twice) will silently collide on a naive slug, either causing a unique-constraint database error or, worse, letting two posts share a URL if there's no unique constraint at all. Slugs must be checked against existing rows and disambiguated before insert.

// Deleting a flagged comment immediately on the first flag if ($flagCount >= 1) { $db->exec('DELETE FROM comments WHERE id = ' . $id); }

// After N flags, set is_hidden = 1 (never DELETE), keeping the comment visible to moderators for review

Permanently deleting content the moment it's flagged means a single bad-faith flag can erase a legitimate comment with no way to review or reverse the decision, and it destroys evidence a moderator might need. Hiding (a reversible, filtered-out state) rather than deleting is the safer default for anything moderation-adjacent.

Why does storeCoverImage() check the file's real MIME type with finfo_file() instead of trusting $_FILES['cover_image']['type']? A. finfo_file() is faster to call B. The client-supplied Content-Type/type field can be spoofed; only inspecting actual file bytes is trustworthy C. PHP does not populate the type field on some servers D. finfo_file() is required by the HTTP specification for uploads

B. The client-supplied Content-Type/type field can be spoofed; only inspecting actual file bytes is trustworthy — Any value the client sends, including the declared MIME type of an uploaded file, is untrusted input. Only sniffing the file's real content reliably identifies what it actually is.

Why does generateUniqueSlug() loop and check the database rather than generating the slug once from the title? A. Looping is a PHP performance best practice B. Because two different post titles could slugify to the same base string, and the loop disambiguates by appending a suffix until it finds one that's free C. Because PDO requires slugs to be generated iteratively D. It doesn't matter; the loop is just for readability

B. Because two different post titles could slugify to the same base string, and the loop disambiguates by appending a suffix until it finds one that's free — Without checking for collisions, two similarly-titled posts could end up with the same slug, breaking uniqueness or causing a database error. The loop guarantees the returned slug isn't already in use.

In the flag-and-hide moderation model used in this project, what happens to a comment once it's flagged and hidden? A. It is permanently deleted from the database immediately B. It is set to a hidden state (is_hidden = 1), filtered from public view but still visible to moderators for review C. It is emailed to the site admin and then deleted D. Nothing changes until an admin manually deletes it

B. It is set to a hidden state (is_hidden = 1), filtered from public view but still visible to moderators for review — Hiding rather than deleting keeps the content reviewable and reversible, which is important for both fairness (avoiding a bad-faith flag erasing legitimate content) and moderation context.

Why does PostListingService cache the query result with both a short TTL and explicit invalidation on publish, rather than relying on just one of the two? A. TTL alone or invalidation alone each has a failure mode: TTL risks staleness, invalidation alone is fragile if a code path forgets to call it — together they cover both cases B. Using both is required by every caching library C. TTL and invalidation must always be used together or the cache won't work at all D. It has no real benefit; it's done purely for consistency with other services

A. TTL alone or invalidation alone each has a failure mode: TTL risks staleness, invalidation alone is fragile if a code path forgets to call it — together they cover both cases — TTL bounds worst-case staleness even if invalidation is missed somewhere, while explicit invalidation on writes keeps the common case (a normal publish) reflected almost immediately rather than waiting out the TTL.

<?php
declare(strict_types=1);

final class PostController
{
    public function __construct(private PDO $db, private CacheInterface $cache) {}

    public function store(Request $request, User $currentUser): Response
    {
        if (!in_array($currentUser->role, ['author', 'admin'], true)) {
            return Response::json(['error' => 'Forbidden'], 403);
        }

        $title = trim((string) $request->input('title', ''));
        $body  = trim((string) $request->input('body', ''));
        $errors = [];

        if ($title === '' || mb_strlen($title) < 3 || mb_strlen($title) > 150) {
            $errors['title'] = 'Title must be between 3 and 150 characters.';
        }
        if ($body === '') {
            $errors['body'] = 'Body cannot be empty.';
        }
        if ($errors !== []) {
            return Response::json(['errors' => $errors], 422);
        }

        $coverPath = null;
        if ($request->hasFile('cover_image')) {
            try {
                $coverPath = $this->storeCoverImage($request->file('cover_image'));
            } catch (RuntimeException $e) {
                return Response::json(['errors' => ['cover_image' => $e->getMessage()]], 422);
…
<?php
declare(strict_types=1);

final class PostListingService
{
    public function __construct(private PDO $db, private CacheInterface $cache) {}

    public function getPage(int $page, int $perPage = 10): array
    {
        $cacheKey = "posts:listing:page:{$page}:per:{$perPage}";

        $cached = $this->cache->get($cacheKey);
        if ($cached !== null) {
            return $cached;
        }

        $offset = ($page - 1) * $perPage;
        $stmt = $this->db->prepare(
            'SELECT p.id, p.title, p.slug, p.published_at, u.name AS author_name,
                    (SELECT COUNT(*) FROM comments c
                     WHERE c.post_id = p.id AND c.is_hidden = 0) AS comment_count
             FROM posts p
             JOIN users u ON u.id = p.user_id
             WHERE p.status = :status
             ORDER BY p.published_at DESC
             LIMIT :limit OFFSET :offset'
        );
        $stmt->bindValue(':status', 'published');
        $stmt->bindValue(':limit', $perPage, PDO::PARAM_INT);
        $stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
        $stmt->execute();
        $result = $stmt->fetchAll(PDO::FETCH_ASSOC);

        // Short TTL as a safety net; exp
…
<?php
declare(strict_types=1);

use PHPUnit\Framework\TestCase;

final class PostCreationTest extends TestCase
{
    private PDO $db;

    protected function setUp(): void
    {
        $this->db = new PDO('sqlite::memory:');
        $this->db->exec(file_get_contents(__DIR__ . '/../schema.sql'));
        $this->db->beginTransaction();
    }

    protected function tearDown(): void
    {
        $this->db->rollBack();
    }

    public function testAuthorCanCreateAPost(): void
    {
        $author = $this->createUser('author');
        $controller = new PostController($this->db, new NullCache());

        $request = Request::fake('POST', '/posts', [
            'title' => 'Understanding PHP Generators',
            'body'  => 'Generators let you iterate without building the whole array in memory.',
        ]);

        $response = $controller->store($request, $author);

        $this->assertSame(201, $response->statusCode());
        $body = json_decode($response->body(), true);
        $this->assertSame('understanding-php-generators', $body['slug']);

        $stmt = $this->db->prepare('SELECT * FROM posts WHERE slug = :slug');
        $stmt->execute(['slug' => $body['slug']]);
  
…