
Chapter 19: Project: Building a Simple Blog
Overview
This is it. It's time to bring together everything you have learned throughout this series—from variables and control structures to OOP, Composer, PDO, and MVC—to build a complete, working web application. We're going to build a simple, database-driven blog.
This chapter will be less about introducing new concepts and more about applying the ones you already know to a real-world project. We'll set up the database, create a Post model to interact with it, build a PostController to handle the logic, and create the views to display our blog posts and a form for creating new ones.
Prerequisites
Before starting this chapter, ensure you have:
- Completed Chapter 18: You should have a working MVC structure with router, controllers, and views
- PHP 8.4 installed and running
- Composer installed and configured
- A working
simple-blogproject with the MVC structure from Chapter 18 - SQLite enabled (usually enabled by default in PHP)
- Estimated Time: ~35-40 minutes (or 5 minutes for Quick Start only)
Verify your setup:
# Check PHP version
php --version
# Check SQLite support
php -m | grep -i sqlite
# Verify your project structure
ls -la simple-blog/What You'll Build
By the end of this chapter, you'll have created:
- A complete database-driven blog application with CRUD functionality
- A
Databasesingleton class for PDO connection management - A
Postmodel with methods for all database operations (create, read, update, delete) - A
PostControllerwith methods:index,show,create,store - Three view files:
posts/index.php,posts/show.php,posts/create.php, and404.php - An initialization script (
init-db.php) to set up the database schema - Proper error handling and validation
- A working blog where you can list, view, and create posts
- Dynamic routing support for individual post URLs like
/posts/123 - Security features: prepared statements, XSS prevention, input validation
Blog Application Architecture
Here's the complete architecture of your blog application:
Directory Structure:
simple-blog/
├── public/
│ └── index.php # Front controller
├── Controllers/
│ └── PostController.php # Blog logic
├── Models/
│ └── Post.php # Database operations
└── Views/
├── layout.php # Master template
└── posts/
├── index.php # List posts
├── show.php # Display single post
└── create.php # Create new postQuick Start
If you want to see the complete blog in action immediately, follow these steps:
# 1. Navigate to your simple-blog project
cd simple-blog
# 2. Create the data directory for the database
mkdir -p data
# 3. Download the complete example files
# (Or manually create them following the chapter)
# 4. Initialize the database
php init-db.php
# 5. Start the server
php -S localhost:8000 -t public
# 6. Visit http://localhost:8000/postsExpected result: You should see a blog post listing page with a "Create New Post" button. The application should be fully functional with list, create, and view capabilities.
Objectives
- Solidify your understanding of the MVC application flow.
- Implement full CRUD (Create, Read, Update, Delete) functionality.
- Build a
Postmodel that connects to the database. - Create a
PostControllerwith methods forindex,show,create, andstore. - Build the corresponding views to display posts and a creation form.
- Handle dynamic route parameters for individual post pages.
- Implement proper error handling and validation.
Step 1: Setting Up the Database and Model (~8 min)
Goal: Create a reusable database connection class and a Post model for all blog operations.
First, let's set up our database connection in a more reusable way and create our Post model.
Actions
Create the Data Directory: The database file needs a place to live. Create a
data/directory in your project root:bash# From your simple-blog directory mkdir -p dataCreate a Database Connection Class: Instead of connecting in a random script, let's create a singleton
Databaseclass that gives us a PDO connection.File:
src/Core/Database.phpphp<?php namespace App\Core; use PDO; use PDOException; class Database { private static ?PDO $instance = null; public static function getInstance(): PDO { if (self::$instance === null) { $dbPath = __DIR__ . '/../../data/database.sqlite'; $dsn = "sqlite:$dbPath"; try { self::$instance = new PDO($dsn); self::$instance->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); self::$instance->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); } catch (PDOException $e) { die("Database connection failed: " . $e->getMessage()); } } return self::$instance; } }Create the
PostModel: This model will handle all our database queries for blog posts.File:
src/Models/Post.phpphp<?php declare(strict_types=1); namespace App\Models; use App\Core\Database; use PDO; class Post { public static function all(): array { $pdo = Database::getInstance(); $stmt = $pdo->query("SELECT * FROM posts ORDER BY id DESC"); return $stmt->fetchAll(); } public static function find(int $id): array|false { $pdo = Database::getInstance(); $stmt = $pdo->prepare("SELECT * FROM posts WHERE id = ?"); $stmt->execute([$id]); return $stmt->fetch(); } public static function create(string $title, string $content): bool { $pdo = Database::getInstance(); $stmt = $pdo->prepare("INSERT INTO posts (title, content) VALUES (?, ?)"); return $stmt->execute([$title, $content]); } }Initialize the Database Table: Create a simple script to set up our table. Create
init-db.phpin the root of your project.File:
init-db.phpphp<?php declare(strict_types=1); require 'vendor/autoload.php'; use App\Core\Database; try { $pdo = Database::getInstance(); $pdo->exec("CREATE TABLE IF NOT EXISTS posts ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, content TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP )"); echo "✓ Database table 'posts' is ready." . PHP_EOL; echo "✓ Database file: data/database.sqlite" . PHP_EOL; } catch (Exception $e) { die("✗ Database initialization failed: " . $e->getMessage() . PHP_EOL); }Run the Initialization Script:
bashphp init-db.php
Expected Result
You should see output like:
✓ Database table 'posts' is ready.
✓ Database file: data/database.sqliteAnd a new file should exist at data/database.sqlite.
Validation
Verify the database was created:
# Check the file exists
ls -lh data/database.sqlite
# Optional: Inspect the database structure using sqlite3
sqlite3 data/database.sqlite ".schema posts"Expected schema output:
CREATE TABLE posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);Why It Works
- Singleton Pattern: The
Databaseclass ensures only one PDO connection exists throughout the application lifetime, improving performance. - Static Methods: The
Postmodel uses static methods since we're not working with individual post instances yet—we're just performing database operations. - Type Declarations: PHP 8.4's
array|falseunion type accurately represents thatfind()returns either an array orfalsewhen no post exists. - Prepared Statements: All queries use prepared statements to prevent SQL injection attacks.
Troubleshooting
Problem: "Failed to open database file"
- Cause: The
data/directory doesn't exist or lacks write permissions - Solution:bash
mkdir -p data chmod 755 data
Problem: "Class 'App\Core\Database' not found"
- Cause: Autoloader not updated
- Solution: Run
composer dump-autoload
Problem: "Database connection failed"
- Cause: SQLite extension not enabled
- Solution: Check
php -m | grep sqliteand enablepdo_sqliteinphp.ini
Step 2: Listing and Showing Posts (The "R" in CRUD) (~10 min)
Goal: Build controller methods and views to list all posts and show individual posts with dynamic routing.
Let's build the public-facing part of the blog: the list of all posts and the page for a single post.
Actions
Create the
PostController:File:
src/Controllers/PostController.phpphp<?php declare(strict_types=1); namespace App\Controllers; use App\Models\Post; class PostController { public function index(): void { $posts = Post::all(); view('posts/index', [ 'posts' => $posts, 'pageTitle' => 'All Posts' ]); } public function show(string $id): void { $post = Post::find((int)$id); if (!$post) { http_response_code(404); view('404'); return; } view('posts/show', [ 'post' => $post, 'pageTitle' => htmlspecialchars($post['title']) ]); } public function create(): void { view('posts/create', [ 'pageTitle' => 'Create New Post' ]); } public function store(): void { $title = $_POST['title'] ?? ''; $content = $_POST['content'] ?? ''; // Basic validation $errors = []; if (empty(trim($title))) { $errors[] = 'Title is required.'; } if (empty(trim($content))) { $errors[] = 'Content is required.'; } if (!empty($errors)) { view('posts/create', [ 'pageTitle' => 'Create New Post', 'errors' => $errors, 'title' => $title, 'content' => $content ]); return; } // Create the post Post::create($title, $content); // Redirect to the posts index header('Location: /posts'); exit; } }Create the Views Directory:
bashmkdir -p src/Views/postsCreate the Post Index View:
File:
src/Views/posts/index.phpphp<h1><?php echo htmlspecialchars($pageTitle); ?></h1> <a href="/posts/create" style="display: inline-block; padding: 10px 15px; background: #0066cc; color: white; text-decoration: none; border-radius: 4px; margin-bottom: 20px;"> Create New Post </a> <?php if (empty($posts)): ?> <p>No posts yet. <a href="/posts/create">Create the first one!</a></p> <?php else: ?> <?php foreach ($posts as $post): ?> <article style="margin-bottom: 30px; padding-bottom: 20px; border-bottom: 1px solid #ddd;"> <h2> <a href="/posts/<?php echo $post['id']; ?>" style="color: #333; text-decoration: none;"> <?php echo htmlspecialchars($post['title']); ?> </a> </h2> <p style="color: #666; margin-top: 10px;"> <?php $excerpt = substr($post['content'], 0, 200); echo nl2br(htmlspecialchars($excerpt)); if (strlen($post['content']) > 200) echo '...'; ?> </p> <small style="color: #999;"> Posted on <?php echo date('F j, Y', strtotime($post['created_at'])); ?> </small> </article> <?php endforeach; ?> <?php endif; ?>Create the Single Post View:
File:
src/Views/posts/show.phpphp<article> <h1><?php echo htmlspecialchars($post['title']); ?></h1> <p style="color: #999; margin-bottom: 20px;"> <small>Posted on <?php echo date('F j, Y \a\t g:i a', strtotime($post['created_at'])); ?></small> </p> <div style="line-height: 1.6;"> <?php echo nl2br(htmlspecialchars($post['content'])); ?> </div> <hr style="margin: 30px 0;"> <a href="/posts" style="color: #0066cc; text-decoration: none;">← Back to all posts</a> </article>Create the 404 View:
File:
src/Views/404.phpphp<h1 style="color: #e74c3c;">404 - Page Not Found</h1> <p>Sorry, the page you're looking for doesn't exist.</p> <p><a href="/" style="color: #0066cc;">Go to homepage</a> or <a href="/posts" style="color: #0066cc;">view all posts</a></p>Register the Routes:
Great news! If you completed Chapter 17, your router already supports dynamic parameters like
/posts/{id}. If not, make sure yourRouterclass has the parameter matching functionality from that chapter.Your router should have a
matchRoute()method that handles patterns like/posts/{id}. If you need a refresher, see Chapter 17: Building a Basic HTTP Router.Update your
public/index.phpfile to add the blog routes:File:
public/index.php(add these routes)php<?php declare(strict_types=1); require_once __DIR__ . '/../vendor/autoload.php'; use App\Controllers\PageController; use App\Controllers\PostController; use App\Routing\Router; $router = new Router(); // Existing routes $router->get('/', [PageController::class, 'home']); $router->get('/about', [PageController::class, 'about']); // Blog routes $router->get('/posts', [PostController::class, 'index']); $router->get('/posts/create', [PostController::class, 'create']); $router->post('/posts/store', [PostController::class, 'store']); $router->get('/posts/{id}', [PostController::class, 'show']); $router->dispatch();Important: The order matters! Register
/posts/createbefore/posts/{id}, otherwisecreatewill be matched as an ID.
Expected Result
Your routes are now registered and ready to handle all blog operations.
Validation
Test the routes manually:
# Start the dev server if not running
php -S localhost:8000 -t public
# In another terminal, test the routes
curl http://localhost:8000/posts # Should show posts list
curl http://localhost:8000/posts/create # Should show create form
curl http://localhost:8000/posts/1 # Should show 404 (no posts yet)Why It Works
- Dynamic routing: The
/posts/{id}route uses the router's parameter matching from Chapter 17 - Array syntax:
[PostController::class, 'index']tells the router to instantiatePostControllerand call itsindex()method - Route order: Specific routes like
/posts/createmust come before wildcard routes like/posts/{id}
Troubleshooting
Problem: "Class 'App\Controllers\PostController' not found"
- Cause: Autoloader not updated or typo in namespace
- Solution: Run
composer dump-autoloadand verify the namespace
Problem: All routes show 404
- Cause: Router not supporting dynamic parameters
- Solution: Review Chapter 17 and ensure your
Routerclass has thematchRoute()method with regex support
Problem: /posts/create shows a specific post
- Cause: Route order is wrong
- Solution: Move
/posts/createroute above/posts/{id}route
Step 3: Creating Posts (The "C" in CRUD) (~6 min)
Goal: Build a form view for creating new posts with validation and error handling.
The PostController already has create() and store() methods from Step 2. Now let's create the view.
Actions
Create the View for the Form:
File:
src/Views/posts/create.phpphp<h1><?php echo htmlspecialchars($pageTitle); ?></h1> <?php if (isset($errors) && !empty($errors)): ?> <div style="background: #fee; border: 1px solid #fcc; padding: 15px; margin-bottom: 20px; border-radius: 4px;"> <strong>Please fix the following errors:</strong> <ul style="margin: 10px 0 0 20px;"> <?php foreach ($errors as $error): ?> <li><?php echo htmlspecialchars($error); ?></li> <?php endforeach; ?> </ul> </div> <?php endif; ?> <form action="/posts/store" method="post" style="max-width: 600px;"> <div style="margin-bottom: 20px;"> <label for="title" style="display: block; margin-bottom: 5px; font-weight: bold;"> Title <span style="color: red;">*</span> </label> <input type="text" name="title" id="title" value="<?php echo htmlspecialchars($title ?? ''); ?>" required style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px; font-size: 16px;" > </div> <div style="margin-bottom: 20px;"> <label for="content" style="display: block; margin-bottom: 5px; font-weight: bold;"> Content <span style="color: red;">*</span> </label> <textarea name="content" id="content" rows="12" required style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px; font-size: 16px; font-family: inherit; resize: vertical;" ><?php echo htmlspecialchars($content ?? ''); ?></textarea> </div> <div style="display: flex; gap: 10px;"> <button type="submit" style="padding: 12px 24px; background: #0066cc; color: white; border: none; border-radius: 4px; font-size: 16px; cursor: pointer;" > Create Post </button> <a href="/posts" style="padding: 12px 24px; background: #f0f0f0; color: #333; border: none; border-radius: 4px; font-size: 16px; text-decoration: none; display: inline-block;" > Cancel </a> </div> </form> <p style="margin-top: 30px; color: #666;"> <small><span style="color: red;">*</span> Required field</small> </p>
Expected Result
When you visit /posts/create, you should see a well-styled form with:
- Title input field
- Content textarea
- Create and Cancel buttons
- Error messages display area (hidden by default)
When you submit the form:
- If valid: Redirects to
/postswith the new post at the top - If invalid: Shows error messages and preserves your input
Validation
Test the form:
# Visit the create form
open http://localhost:8000/posts/create
# Test validation by submitting empty form
# Should show error messages
# Fill in the form and submit
# Should redirect to /posts with new post visibleWhy It Works
- Server-side validation: The
store()method validates before saving - Error preservation: Validation errors are passed back to the view
- Input preservation: On error, the form remembers what you typed using
$title ?? '' - XSS protection: All user input is escaped with
htmlspecialchars() - User-friendly: Clear labels, required indicators, and helpful error messages
Troubleshooting
Problem: Form submits but no post is created
- Cause: Database not initialized or model method failing
- Solution: Run
php init-db.phpand check for errors
Problem: Validation errors not showing
- Cause: The
$errorsvariable isn't being passed to the view - Solution: Verify the
store()method passeserrorsarray to view on validation failure
Problem: Content has extra slashes (e.g., it\'s)
- Cause: Magic quotes or double escaping
- Solution: This shouldn't happen in PHP 8.4. Check you're not manually escaping before saving
Security Note
This form doesn't include CSRF (Cross-Site Request Forgery) protection. For production applications, you should implement CSRF tokens. This is covered in advanced security topics but is beyond the scope of this foundational chapter. For now, remember: never deploy a form handler without CSRF protection in a production environment.
Now you can visit /posts, click "Create New Post," fill out the form, and you will see your new post at the top of the list!
Understanding Key Patterns and Concepts
Before moving to the exercises, let's explicitly discuss some important patterns and concepts that our blog application uses. Understanding these will help you recognize them in professional frameworks and codebases.
The Post-Redirect-Get (PRG) Pattern
You may have noticed this code in the store() method:
Post::create($title, $content);
// Redirect to the posts index
header('Location: /posts');
exit;This is called the Post-Redirect-Get (PRG) pattern, and it's a crucial web development technique.
The Problem It Solves:
Without the redirect, after submitting a form:
- User submits form → Browser sends POST request
- Server creates post → Returns success page
- User refreshes page (F5) → Browser re-sends POST request
- Server creates duplicate post → Uh oh!
How PRG Fixes It:
- User submits form → Browser sends POST request
- Server creates post → Returns redirect (Location header)
- Browser automatically sends GET request to
/posts - User refreshes → Browser re-sends GET request (safe, no duplicate)
Key Points:
- Always redirect after POST/PUT/DELETE operations that modify data
- The redirect should use a GET request to view the result
- Use the
exitstatement afterheader()to prevent further code execution - This pattern prevents duplicate submissions and bookmark issues
HTTP Status Codes:
While our simple header('Location: /posts') works, you could be more explicit:
// 303 See Other - Explicitly tells browser to use GET for redirect
http_response_code(303);
header('Location: /posts');
exit;Security: Input Validation vs Output Escaping
Our blog uses two different security techniques that serve different purposes:
1. Input Validation (Server-Side)
Purpose: Ensure data meets your business rules before storing it.
Where: In the controller, before calling the model:
if (empty(trim($title))) {
$errors[] = 'Title is required.';
}What it does:
- Checks if data is present
- Checks data format (length, type, pattern)
- Rejects invalid submissions
- Prevents storing garbage in your database
What it doesn't do:
- Doesn't prevent SQL injection (that's prepared statements)
- Doesn't prevent XSS attacks (that's output escaping)
2. Output Escaping (XSS Prevention)
Purpose: Prevent Cross-Site Scripting (XSS) attacks by escaping HTML in user data.
Where: In views, when displaying user-generated content:
<h1><?php echo htmlspecialchars($post['title']); ?></h1>Why it matters:
Without htmlspecialchars(), if a post title was:
My Post <script>alert('Hacked!')</script>The browser would execute the script! With escaping, it displays as harmless text:
My Post <script>alert('Hacked!')</script>Golden Rule:
- Validate on input (what goes into the database)
- Escape on output (what comes out to the browser)
- Never try to "sanitize" input by removing characters—validate and reject instead
SQL Injection Prevention
While we're using prepared statements throughout the code, let's explicitly understand why:
Vulnerable Code (NEVER do this):
$id = $_GET['id'];
$stmt = $pdo->query("SELECT * FROM posts WHERE id = $id");Why it's dangerous:
If $id is 1 OR 1=1, the query becomes:
SELECT * FROM posts WHERE id = 1 OR 1=1This returns all posts, bypassing security. Worse, with ; DROP TABLE posts; --, an attacker could delete your entire database.
Safe Code (our approach):
$stmt = $pdo->prepare("SELECT * FROM posts WHERE id = ?");
$stmt->execute([$id]);Why it's safe:
The ? placeholder tells PDO: "This is a value, not SQL code." Even if $id contains SQL syntax, PDO treats it as literal text. The database never interprets user input as commands.
Rule: Never concatenate user input into SQL. Always use prepared statements with placeholders.
Environment Configuration Basics
Our Database class hardcodes the database path:
$dbPath = __DIR__ . '/../../data/database.sqlite';This works for learning, but in professional applications, you'd use environment variables:
$dbPath = $_ENV['DATABASE_PATH'] ?? __DIR__ . '/../../data/database.sqlite';With a .env file:
DATABASE_PATH=/var/www/production/data/database.sqlite
APP_ENV=production
APP_DEBUG=falseWhy it matters:
- Different paths for development, staging, and production
- Keep sensitive data (passwords, API keys) out of source control
- Configure without changing code
For this tutorial: Hardcoding is fine. Just remember to use environment configuration in real projects.
Flash Messages (What's Missing)
Notice what happens when you create a post:
- Submit form
- Redirect to
/posts - See the new post in the list
What's missing? Feedback! The user has no confirmation that the action succeeded.
Flash messages are temporary session data that survive one redirect:
// In store() method, after creating post
$_SESSION['flash_message'] = 'Post created successfully!';
header('Location: /posts');// In layout or view
if (isset($_SESSION['flash_message'])) {
echo '<div class="success">' . htmlspecialchars($_SESSION['flash_message']) . '</div>';
unset($_SESSION['flash_message']); // Clear after display
}Note: Chapter 15 covers sessions, but doesn't cover this flash message pattern. Consider implementing it as an additional enhancement!
HTTP Status Codes Reference
Our blog uses http_response_code(404) for missing posts. Here are other useful codes:
| Code | Name | When to Use |
|---|---|---|
| 200 | OK | Successful GET request |
| 201 | Created | Successfully created a resource (after POST) |
| 303 | See Other | Redirect after POST (PRG pattern) |
| 400 | Bad Request | Invalid input format |
| 404 | Not Found | Resource doesn't exist |
| 422 | Unprocessable Entity | Validation failed |
| 500 | Internal Server Error | Unexpected error |
In practice:
// After creating a post
http_response_code(201); // Created
header('Location: /posts/' . $postId);
exit;
// After validation fails
http_response_code(422); // Unprocessable
view('posts/create', ['errors' => $errors]);Key Takeaways
- PRG Pattern: Always redirect after POST to prevent duplicate submissions
- Validate Input: Check business rules before storing data
- Escape Output: Use
htmlspecialchars()when displaying user data - Prepared Statements: Never concatenate user input into SQL
- Environment Config: Use
.envfiles for sensitive/environment-specific data - Flash Messages: Provide user feedback across redirects
- HTTP Status Codes: Use appropriate codes for different scenarios
These patterns are fundamental to web development and are used consistently across all major PHP frameworks. Understanding them now will make learning Laravel, Symfony, or any other framework much easier.
Exercises
Now that you have a working blog with create and read operations, challenge yourself to add the remaining CRUD functionality:
Exercise 1: Implement Update (⭐⭐⭐)
Add the ability to edit existing posts.
Tasks:
Add an
update()method to thePostmodel:phppublic static function update(int $id, string $title, string $content): bool { $pdo = Database::getInstance(); $stmt = $pdo->prepare("UPDATE posts SET title = ?, content = ? WHERE id = ?"); return $stmt->execute([$title, $content, $id]); }Add
edit()andupdatePost()methods toPostController:edit($id)should fetch the post and render an edit formupdatePost($id)should validate and update the post
Create
src/Views/posts/edit.php(copy and modifycreate.php)Add routes:
php$router->get('/posts/{id}/edit', [PostController::class, 'edit']); $router->post('/posts/{id}/update', [PostController::class, 'updatePost']);Add an "Edit" link to
posts/show.php
Expected Outcome: You can click "Edit" on any post, modify it, and see the changes reflected immediately.
Exercise 2: Implement Delete (⭐⭐)
Add the ability to delete posts.
Tasks:
Add a
delete()method to thePostmodel:phppublic static function delete(int $id): bool { $pdo = Database::getInstance(); $stmt = $pdo->prepare("DELETE FROM posts WHERE id = ?"); return $stmt->execute([$id]); }Add a
destroy()method toPostController:phppublic function destroy(string $id): void { Post::delete((int)$id); header('Location: /posts'); exit; }Add a delete form to
posts/show.php:php<form action="/posts/<?php echo $post['id']; ?>/delete" method="post" style="display: inline;"> <button type="submit" onclick="return confirm('Are you sure you want to delete this post?');" style="background: #e74c3c; color: white; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer;"> Delete Post </button> </form>Add route:
php$router->post('/posts/{id}/delete', [PostController::class, 'destroy']);
Expected Outcome: You can delete posts with a confirmation dialog, and they disappear from the list.
Exercise 3: Add Timestamps to Edit (⭐)
Update the schema and views to track when posts were last edited.
Tasks:
Update the
poststable schema ininit-db.phpto addupdated_at:sqlCREATE TABLE IF NOT EXISTS posts ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, content TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP )Modify the
update()method to setupdated_at = CURRENT_TIMESTAMPDisplay "Last updated" on post pages when
updated_atdiffers fromcreated_at
Exercise 4: Add Post Search (⭐⭐⭐)
Implement a search feature to find posts by title or content.
Tasks:
- Add a
search()method to thePostmodel using LIKE queries - Add a search form to the posts index page
- Modify the
index()method to handle search queries - Display search results with a "Clear search" link
Exercise 5: Add Pagination (⭐⭐⭐⭐)
Limit the posts shown per page and add navigation links.
Tasks:
- Modify
Post::all()to accept$limitand$offsetparameters - Add a
Post::count()method to get total posts - Calculate total pages in the controller
- Add "Previous" and "Next" links to the view
- Handle the
?page=Nquery parameter
Exercise 6: Implement Flash Messages (⭐⭐)
Add user feedback for create/update/delete actions.
Tasks:
Start a session at the beginning of
public/index.php:phpsession_start();Create a helper function in
src/helpers.php:phpfunction flash(string $key, ?string $value = null): ?string { if ($value !== null) { $_SESSION["flash_{$key}"] = $value; return null; } $message = $_SESSION["flash_{$key}"] ?? null; unset($_SESSION["flash_{$key}"]); return $message; }Update controller methods to set flash messages:
php// In store() Post::create($title, $content); flash('success', 'Post created successfully!'); header('Location: /posts'); // In updatePost() Post::update($postId, $title, $content); flash('success', 'Post updated successfully!'); header("Location: /posts/{$postId}"); // In destroy() Post::delete((int)$id); flash('success', 'Post deleted successfully!'); header('Location: /posts');Display flash messages in
layout.php:php<?php if ($successMessage = flash('success')): ?> <div style="background: #d4edda; border: 1px solid #c3e6cb; color: #155724; padding: 12px 20px; margin-bottom: 20px; border-radius: 4px;"> <?php echo htmlspecialchars($successMessage); ?> </div> <?php endif; ?>
Expected Outcome: Users see confirmation messages after every action, improving the user experience significantly.
Wrap-up
🎉 Congratulations! This is a huge milestone! You have successfully built a complete, database-driven web application from scratch.
What You've Accomplished
In this chapter, you've brought together everything you've learned throughout this series:
- ✅ Object-Oriented Programming: Classes, namespaces, static methods
- ✅ Composer & Autoloading: PSR-4 autoloading for clean project structure
- ✅ Database Operations: PDO with prepared statements for security
- ✅ MVC Architecture: Separation of concerns with Models, Views, and Controllers
- ✅ Routing: Dynamic URL parameters and route dispatch
- ✅ Form Handling: POST requests, validation, error messages
- ✅ Security: XSS prevention with
htmlspecialchars(), SQL injection prevention with prepared statements - ✅ User Experience: Error handling, input preservation, friendly messages
Real-World Skills
The application you just built demonstrates the core architecture that powers millions of websites:
- E-commerce sites: Product listings, shopping carts
- Social networks: User profiles, posts, comments
- Content management systems: Articles, pages, media
- Business applications: Dashboards, reports, data management
Understanding the Bigger Picture
The foundation you've built is a simplified version of what every major PHP framework does:
| What You Built | Laravel Equivalent | Symfony Equivalent |
|---|---|---|
Router class | Illuminate\Routing\Router | Symfony\Component\Routing\Router |
Post model | Eloquent models | Doctrine entities |
PostController | Controller classes | Controller classes |
view() function | Blade templating | Twig templating |
Database singleton | Database facades | Doctrine DBAL |
The concepts are identical—frameworks just add:
- More features (authentication, caching, queuing)
- Better tooling (CLI commands, testing)
- Community packages (payment processing, image manipulation)
- Performance optimizations
- Advanced patterns (middleware, service containers, events)
Key Takeaways
- MVC keeps code organized: Models handle data, Views handle presentation, Controllers connect them
- Prepared statements are non-negotiable: Never concatenate user input into SQL
- Validation happens on the server: Never trust client-side validation alone
- Always escape output:
htmlspecialchars()prevents XSS attacks - URL structure matters: Clean URLs improve UX and SEO
- Single entry point is powerful: The front controller pattern enables routing and middleware
- Type declarations catch bugs early: PHP 8.4's type system makes code safer
What's Next
You're now ready to explore professional PHP frameworks:
Chapter 20: A Gentle Introduction to Laravel - See how Laravel builds on these concepts with elegant syntax, powerful tools, and a vibrant ecosystem.
Chapter 21: A Gentle Introduction to Symfony - Discover Symfony's component-based approach and enterprise-grade features.
Chapter 22: What to Learn Next - Chart your path forward in PHP development.
Going Further
Before moving on, consider enhancing your blog:
- Add user authentication (login/logout)
- Implement post categories or tags
- Add comments to posts
- Upload and display images
- Add a rich text editor (TinyMCE, CKEditor)
- Implement post drafts (published vs draft status)
- Add RSS feed generation
- Create an admin dashboard
Each of these features will deepen your understanding and give you more to showcase in a portfolio.
Deployment Considerations
When you're ready to deploy your blog to a live server:
- Use environment variables for database credentials (not hardcoded paths)
- Enable HTTPS (Let's Encrypt is free)
- Implement CSRF protection for forms
- Add rate limiting to prevent abuse
- Set up error logging (don't show errors to users)
- Use a real database (PostgreSQL or MySQL) instead of SQLite
- Add caching for better performance
- Implement backups for your database
Final Thought
You've gone from understanding PHP basics to building a complete web application. That's no small feat! The skills you've developed here form the foundation for any web development work you'll do in PHP.
Remember: every expert developer started exactly where you are now. Keep building, keep learning, and don't be afraid to break things—that's how you'll truly master these concepts.
Happy coding! 🚀
Code Samples
All code from this chapter is available in the code directory for easy reference:
19-blog-database.php- Complete Database singleton class19-blog-post-model.php- Post model with all CRUD operations including search19-blog-post-controller.php- Complete PostController with all methods including update and delete19-blog-init-db.php- Database initialization script with optional sample data
These files include the complete implementations from the chapter plus the exercise solutions. Use them as a reference while building your blog application.
Quick Reference
Project Structure
simple-blog/
├── data/
│ └── database.sqlite # SQLite database file
├── public/
│ └── index.php # Front controller (entry point)
├── src/
│ ├── Controllers/
│ │ ├── PageController.php # Static pages
│ │ └── PostController.php # Blog post operations
│ ├── Core/
│ │ └── Database.php # Database connection singleton
│ ├── Models/
│ │ └── Post.php # Post model
│ ├── Routing/
│ │ └── Router.php # HTTP router (from Chapter 17)
│ ├── Views/
│ │ ├── layout.php # Master layout template
│ │ ├── 404.php # Not found page
│ │ ├── posts/
│ │ │ ├── index.php # List all posts
│ │ │ ├── show.php # Single post view
│ │ │ ├── create.php # Create post form
│ │ │ └── edit.php # Edit post form (exercise)
│ │ └── ... # Other views
│ └── helpers.php # Global helper functions
├── vendor/ # Composer dependencies
├── composer.json # Composer configuration
└── init-db.php # Database setup scriptKey Routes
// Static pages
$router->get('/', [PageController::class, 'home']);
$router->get('/about', [PageController::class, 'about']);
// Blog posts
$router->get('/posts', [PostController::class, 'index']);
$router->get('/posts/create', [PostController::class, 'create']);
$router->post('/posts/store', [PostController::class, 'store']);
$router->get('/posts/{id}', [PostController::class, 'show']);
// Exercise routes (update/delete)
$router->get('/posts/{id}/edit', [PostController::class, 'edit']);
$router->post('/posts/{id}/update', [PostController::class, 'updatePost']);
$router->post('/posts/{id}/delete', [PostController::class, 'destroy']);Database Schema
CREATE TABLE posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);Common Commands
# Initialize database
php init-db.php
# Start development server
php -S localhost:8000 -t public
# Regenerate autoloader (after adding new classes)
composer dump-autoload
# Inspect database with SQLite CLI
sqlite3 data/database.sqlite
> SELECT * FROM posts;
> .schema posts
> .quitKnowledge Check
Test your understanding of building a complete application:
Chapter 19 Quiz: Building a Blog Application
Further Reading
- PHP 8.4 Release Notes - New features and improvements
- PDO Documentation - Complete PDO reference
- OWASP SQL Injection - Understanding SQL injection attacks
- OWASP XSS Prevention - XSS attack prevention
- PSR-4 Autoloading - PHP autoloading standard
- Composer Documentation - Dependency management
- Laravel Documentation - Popular PHP framework
- Symfony Documentation - Enterprise PHP framework