PHP: Project: Blog System Part 1
Through 36 lessons so far, you've mastered PHP, MySQL, Sessions, OOP, and file handling—all the foundational skills. In the next two lessons, we'll string these scattered pieces of knowledge together into a complete application—a real blog system.
1. Project Architecture
TEXT
📖 Display only
myblog/ ← Project root
├── app/
│ ├── Controllers/
│ │ └── PostController.php ← Post controller
│ ├── Models/
│ │ └── Post.php ← Post model
│ └── Core/
│ ├── Router.php ← Router
│ └── Database.php ← Database connection
├── views/
│ ├── layout.php ← Page layout
│ ├── home.php ← Homepage
│ └── posts/
│ ├── index.php ← Post list
│ └── show.php ← Post detail
├── public/
│ └── index.php ← Entry point (all requests enter here)
├── config.php ← Configuration file
├── composer.json
└── uploads/ ← Upload directory
2. Core Files
(1) config.php
PHP
<?php
return [
'db' => [
'host' => 'localhost',
'dbname' => 'myblog',
'username' => 'root',
'password' => '',
'charset' => 'utf8mb4',
],
'app' => [
'name' => 'MyBlog',
'url' => 'http://localhost/myblog',
],
];
(2) Database Connection (Database.php)
▶ Example: Singleton Database Connection
Output:
TEXT
📖 Display only
(no visible output - class defined)
PHP
<?php
namespace App\Core;
use PDO;
class Database
{
private static ?PDO $instance = null;
public static function getInstance(): PDO
{
if (self::$instance === null) {
$config = require __DIR__ . '/../../config.php';
$db = $config['db'];
$dsn = "mysql:host={$db['host']};dbname={$db['dbname']};charset={$db['charset']}";
self::$instance = new PDO($dsn, $db['username'], $db['password'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
}
return self::$instance;
}
}
Output:
TEXT
📖 Display only
Output displayed
(3) Router (Router.php)
▶ Example: Dynamic Route Matching
Output:
TEXT
📖 Display only
value
<h2>404 — Page Not Found</h2>
PHP
<?php
namespace App\Core;
class Router
{
private array $routes = [];
public function add(string $method, string $path, callable $handler): void
{
$this->routes[] = compact('method', 'path', 'handler');
}
public function dispatch(string $method, string $uri): void
{
$path = parse_url($uri, PHP_URL_PATH);
foreach ($this->routes as $route) {
// Support dynamic params like /posts/123
$pattern = preg_replace('/\{(\w+)\}/', '(?P<$1>\d+)', $route['path']);
$pattern = '#^' . $pattern . '$#';
if ($route['method'] === $method && preg_match($pattern, $path, $matches)) {
$params = array_filter($matches, 'is_string', ARRAY_FILTER_USE_KEY);
echo $route['handler']($params);
return;
}
}
http_response_code(404);
echo '<h2>404 — Page Not Found</h2>';
}
}
Output:
TEXT
📖 Display only
<h1>Welcome to MyBlog</h1> (for route "/")
<h2>404 - Page Not Found</h2> (for unknown routes)
(4) Entry Point (public/index.php)
PHP
<?php
session_start();
require_once __DIR__ . '/../app/Core/Database.php';
require_once __DIR__ . '/../app/Core/Router.php';
require_once __DIR__ . '/../app/Models/Post.php';
require_once __DIR__ . '/../app/Controllers/PostController.php';
use App\Core\Router;
use App\Controllers\PostController;
$router = new Router();
$ctrl = new PostController();
$router->add('GET', '/', [$ctrl, 'home']);
$router->add('GET', '/posts', [$ctrl, 'index']);
$router->add('GET', '/posts/{id}', [$ctrl, 'show']);
$router->add('GET', '/posts/create', [$ctrl, 'createForm']);
$router->add('POST', '/posts/create', [$ctrl, 'create']);
$router->add('GET', '/register', [$ctrl, 'registerForm']);
$router->add('POST', '/register', [$ctrl, 'register']);
$router->add('GET', '/login', [$ctrl, 'loginForm']);
$router->add('POST', '/login', [$ctrl, 'login']);
$router->add('GET', '/logout', [$ctrl, 'logout']);
$router->dispatch($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI']);
3. Post Model (Post.php)
PHP
<?php
namespace App\Models;
use App\Core\Database;
use PDO;
class Post
{
private PDO $db;
public function __construct()
{
$this->db = Database::getInstance();
}
/** Get all published posts */
public function getAll(): array
{
$stmt = $this->db->query(
"SELECT p.*, u.username
FROM posts p
JOIN users u ON p.user_id = u.id
WHERE p.status = 'published'
ORDER BY p.created_at DESC"
);
return $stmt->fetchAll();
}
/** Get a single post by ID */
public function findById(int $id): ?array
{
$stmt = $this->db->prepare(
"SELECT p.*, u.username
FROM posts p
JOIN users u ON p.user_id = u.id
WHERE p.id = :id AND p.status = 'published'"
);
$stmt->execute(['id' => $id]);
$post = $stmt->fetch();
return $post ?: null;
}
/** Create a new post */
public function create(int $userId, string $title, string $content): int
{
$stmt = $this->db->prepare(
"INSERT INTO posts (user_id, title, content)
VALUES (:user_id, :title, :content)"
);
$stmt->execute([
'user_id' => $userId,
'title' => $title,
'content' => $content,
]);
return (int)$this->db->lastInsertId();
}
}
4. Controller (PostController.php)
PHP
<?php
namespace App\Controllers;
use App\Models\Post;
use PDO;
class PostController
{
private Post $post;
private PDO $db;
public function __construct()
{
$this->post = new Post();
$this->db = \App\Core\Database::getInstance();
}
/** Homepage */
public function home(): string
{
$posts = $this->post->getAll();
ob_start();
require __DIR__ . '/../../views/home.php';
return ob_get_clean();
}
/** Post list */
public function index(): string
{
$posts = $this->post->getAll();
ob_start();
require __DIR__ . '/../../views/posts/index.php';
return ob_get_clean();
}
/** Post detail */
public function show(array $params): string
{
$post = $this->post->findById((int)$params['id']);
if (!$post) {
http_response_code(404);
return '<h2>Post not found</h2>';
}
ob_start();
require __DIR__ . '/../../views/posts/show.php';
return ob_get_clean();
}
/** Create post form */
public function createForm(): string
{
$this->requireLogin();
ob_start();
require __DIR__ . '/../../views/posts/create.php';
return ob_get_clean();
}
/** Handle creating a post */
public function create(): string
{
$this->requireLogin();
$title = trim($_POST['title'] ?? '');
$content = trim($_POST['content'] ?? '');
if ($title === '' || $content === '') {
return '<p style="color:red">Title and content cannot be empty</p><a href="/posts/create">Go back</a>';
}
$id = $this->post->create(
$_SESSION['user']['id'],
$title,
$content
);
header("Location: /posts/{$id}");
exit;
}
/** Registration form */
public function registerForm(): string
{
ob_start();
require __DIR__ . '/../../views/auth/register.php';
return ob_get_clean();
}
/** Handle registration */
public function register(): string
{
$username = trim($_POST['username'] ?? '');
$email = trim($_POST['email'] ?? '');
$password = $_POST['password'] ?? '';
if ($username === '' || $email === '' || strlen($password) < 6) {
return '<p style="color:red">Please fill out all fields correctly (password must be at least 6 characters)</p><a href="/register">Go back</a>';
}
// Check if the user already exists
$stmt = $this->db->prepare("SELECT COUNT(*) FROM users WHERE username = :u OR email = :e");
$stmt->execute(['u' => $username, 'e' => $email]);
if ($stmt->fetchColumn() > 0) {
return '<p style="color:red">Username or email already registered</p><a href="/register">Go back</a>';
}
$hash = password_hash($password, PASSWORD_DEFAULT);
$stmt = $this->db->prepare("INSERT INTO users (username, email, password) VALUES (:u, :e, :p)");
$stmt->execute(['u' => $username, 'e' => $email, 'p' => $hash]);
$_SESSION['user'] = ['id' => (int)$this->db->lastInsertId(), 'username' => $username];
header("Location: /");
exit;
}
/** Login form */
public function loginForm(): string
{
ob_start();
require __DIR__ . '/../../views/auth/login.php';
return ob_get_clean();
}
/** Handle login */
public function login(): string
{
$username = trim($_POST['username'] ?? '');
$password = $_POST['password'] ?? '';
$stmt = $this->db->prepare("SELECT * FROM users WHERE username = :u");
$stmt->execute(['u' => $username]);
$user = $stmt->fetch();
if (!$user || !password_verify($password, $user['password'])) {
return '<p style="color:red">Invalid username or password</p><a href="/login">Go back</a>';
}
$_SESSION['user'] = ['id' => (int)$user['id'], 'username' => $user['username']];
header("Location: /");
exit;
}
/** Logout */
public function logout(): void
{
session_unset();
session_destroy();
header("Location: /login");
exit;
}
private function requireLogin(): void
{
if (!isset($_SESSION['user'])) {
header("Location: /login");
exit;
}
}
}
5. View Files
(1) views/layout.php
PHP
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title><?= $title ?? 'MyBlog' ?> — MyBlog</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: system-ui, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; color: #333; }
header { border-bottom: 2px solid #4a90d9; padding-bottom: 10px; margin-bottom: 20px; display: flex; justify-content: space-between; align-items: center; }
.post-card { border: 1px solid #ddd; border-radius: 8px; padding: 15px; margin-bottom: 15px; }
.post-card h2 { margin-bottom: 8px; }
.post-card h2 a { color: #4a90d9; text-decoration: none; }
.post-meta { color: #999; font-size: 14px; margin-bottom: 10px; }
nav a { margin-left: 15px; color: #4a90d9; text-decoration: none; }
form div { margin-bottom: 12px; }
form label { display: block; margin-bottom: 4px; font-weight: bold; }
form input, form textarea { width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px; font-size: 16px; }
form textarea { height: 200px; }
button { background: #4a90d9; color: white; border: none; padding: 10px 24px; border-radius: 4px; font-size: 16px; cursor: pointer; }
button:hover { background: #357abd; }
</style>
</head>
<body>
<header>
<h1>📝 <a href="/" style="text-decoration:none;color:#333">MyBlog</a></h1>
<nav>
<a href="/">Home</a>
<?php if (isset($_SESSION['user'])): ?>
<a href="/posts/create">Write</a>
<span><?= htmlspecialchars($_SESSION['user']['username']) ?></span>
<a href="/logout">Logout</a>
<?php else: ?>
<a href="/login">Login</a>
<a href="/register">Register</a>
<?php endif; ?>
</nav>
</header>
<main>
<?= $content ?? '' ?>
</main>
</body>
</html>
(2) views/posts/index.php
PHP
<?php $title = 'Posts'; ob_start(); ?>
<h2>All Posts</h2>
<?php if (empty($posts)): ?>
<p>No posts yet. Be the first author!</p>
<?php else: ?>
<?php foreach ($posts as $post): ?>
<div class="post-card">
<h2><a href="/posts/<?= $post['id'] ?>"><?= htmlspecialchars($post['title']) ?></a></h2>
<div class="post-meta">
Author: <?= htmlspecialchars($post['username']) ?> |
<?= date('Y-m-d', strtotime($post['created_at'])) ?>
</div>
<p><?= htmlspecialchars(substr(strip_tags($post['content']), 0, 200)) ?>...</p>
</div>
<?php endforeach; ?>
<?php endif; ?>
<?php $content = ob_get_clean(); require __DIR__ . '/../layout.php'; ?>
(3) views/posts/show.php
PHP
<?php $title = $post['title']; ob_start(); ?>
<article>
<h2><?= htmlspecialchars($post['title']) ?></h2>
<div class="post-meta">
Author: <?= htmlspecialchars($post['username']) ?> |
Published: <?= $post['created_at'] ?>
</div>
<div style="line-height:1.8;margin-top:20px">
<?= nl2br(htmlspecialchars($post['content'])) ?>
</div>
</article>
<p style="margin-top:30px"><a href="/posts">← Back to posts</a></p>
<?php $content = ob_get_clean(); require __DIR__ . '/../layout.php'; ?>
(4) views/auth/login.php
PHP
<?php $title = 'Login'; ob_start(); ?>
<h2>Login</h2>
<form method="POST" action="/login">
<div>
<label>Username</label>
<input type="text" name="username" required>
</div>
<div>
<label>Password</label>
<input type="password" name="password" required>
</div>
<button type="submit">Login</button>
</form>
<p style="margin-top:15px">Don't have an account? <a href="/register">Register now</a></p>
<?php $content = ob_get_clean(); require __DIR__ . '/../layout.php'; ?>
6. Database Setup
SQL
CREATE DATABASE IF NOT EXISTS myblog CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE myblog;
-- Users table (skip if it already exists)
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
email VARCHAR(100) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
age INT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Posts table
CREATE TABLE IF NOT EXISTS posts (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
title VARCHAR(200) NOT NULL,
content TEXT NOT NULL,
status ENUM('draft', 'published') DEFAULT 'published',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
7. Creating the Directory Structure
In your myphp/public/ directory, run:
BASH
# Create directory structure
mkdir -p myblog/app/{Controllers,Models,Core}
mkdir -p myblog/views/{posts,auth}
mkdir -p myblog/public
# Create .htaccess (Apache URL rewriting)
APACHE
# public/.htaccess
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [L]
▶ Example: Simple URL Dispatcher with Query Routing
Output:
TEXT
📖 Display only
value
<h2>404 — Page Not Found</h2>
PHP
<?php
$routes = [
'/' => fn() => '<h1>Welcome to MyBlog</h1>',
'/about' => fn() => '<h1>About Us</h1><p>A simple PHP blog.</p>',
'/contact' => fn() => '<h1>Contact</h1><p>Email us at hello@blog.com</p>',
];
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$handler = $routes[$path] ?? null;
if ($handler) {
echo $handler();
} else {
http_response_code(404);
echo '<h2>404 — Page Not Found</h2>';
}
?>
Output:
TEXT
📖 Display only
Output displayed
❓ FAQ
Q Why handle all requests through a single entry point index.php?
A This is called the Front Controller pattern and is the standard architecture of modern PHP frameworks (Laravel/Symfony). Benefits: unified authentication, unified error handling, and URL routing configured in one place.
Q Why use output buffering (
ob_start/ob_get_clean) to return HTML?A A controller's responsibility is to "return a value," not to "echo HTML." The ob mechanism lets the controller return a string, which
Router::dispatch() then outputs. This makes it easier to add middleware, set response headers, and more down the road.❓ FAQ
Q How should I handle image upload failures in the blog?
A Always validate file type and size server-side before moving the uploaded file. Check $_FILES["error"] for UPLOAD_ERR_OK, verify the extension is in your whitelist (jpg/png/gif/webp), and keep file sizes under a reasonable limit like 2MB. Never trust client-side validation alone.
📖 Summary
- Project architecture: Entry → Router → Controller → Model → View
- Database singleton pattern: one shared PDO connection globally
- Router dynamic routes:
/posts/{id}via regex matching - MVC layering: Controller coordinates, Model operates the database, View renders HTML
- Sessions maintain login state; unauthenticated users are redirected to login
- Output buffering lets controllers return HTML strings
📝 Exercises
- Set up the blog system locally following the code above: register an account → log in → publish a post → view the post.
- Add a "My Posts" page to the blog system (showing only the currently logged-in user's articles).
- Add a post deletion feature (only the post author can delete their own posts).