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 📖 参照専用
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)

▶ サンプル: Singleton Database Connection

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;
    }
}
▶ 試してみよう

(3) Router (Router.php)

▶ サンプル: Dynamic Route Matching

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>';
    }
}
▶ 試してみよう

(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]

❓ よくある質問

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.

❓ よくある質問

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.

📖 まとめ

📝 練習問題

  1. Set up the blog system locally following the code above: register an account → log in → publish a post → view the post.
  2. Add a "My Posts" page to the blog system (showing only the currently logged-in user's articles).
  3. Add a post deletion feature (only the post author can delete their own posts).
Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%