TL;DR

Build a complete blog system with PHP combining authentication, CRUD operations, SQLite database, templates, and a REST API.

Key concepts

  • PHP project
  • PHP blog tutorial
  • PHP capstone
  • build PHP application

Capstone Project: Blog System

In this final lesson, you will build a complete blog system that exercises every skill you have learned throughout this course. The project includes user authentication with password hashing, full CRUD operations for blog posts, a SQLite database with PDO, an HTML template system, and a JSON API endpoint. This is a realistic project that mirrors the architecture of production PHP applications.

What You'll Build

  • User registration and login with secure password hashing
  • Blog post creation, reading, updating, and deleting
  • A SQLite database with proper schema design
  • A simple template rendering system
  • A REST API endpoint for posts
  • Input validation and error handling throughout

Part 1: Database Setup

First, set up the database schema and a connection manager:

<?php

class Database {
    private static ?PDO $instance = null;

    public static function connect(): PDO {
        if (self::$instance === null) {
            self::$instance = new PDO('sqlite::memory:', null, null, [
                PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
                PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
                PDO::ATTR_EMULATE_PREPARES => false,
            ]);
            self::migrate();
        }
        return self::$instance;
    }

    private static function migrate(): void {
        $pdo = self::$instance;

        $pdo->exec("
            CREATE TABLE IF NOT EXISTS users (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                username TEXT UNIQUE NOT NULL,
                email TEXT UNIQUE NOT NULL,
                password_hash TEXT NOT NULL,
                role TEXT DEFAULT 'author',
                created_at TEXT DEFAULT CURRENT_TIMESTAMP
            )
        ");

        $pdo->exec("
            CREATE TABLE IF NOT EXISTS posts (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                user_id INTEGER NOT NULL,
                title TEXT NOT NULL,
                slug TEXT UNIQUE NOT NULL,
                body TEXT NOT NULL,
                status TEXT DEFAULT 'draft',
                created_at TEXT DEFAULT CURRENT_TIMESTAMP,
                updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
                FOREIGN KEY (user_id) REFERENCES users(id)
            )
        ");

        echo "Database migrated: users, posts tables created\n";
    }
}

// Initialize the database
$db = Database::connect();

// Verify tables
$tables = $db->query("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")->fetchAll();
echo "Tables: " . implode(', ', array_column($tables, 'name')) . "\n";

// Show schema
$schema = $db->query("SELECT sql FROM sqlite_master WHERE type='table' AND name='posts'")->fetchColumn();
echo "\nPosts schema:\n{$schema}\n";

The database uses two tables: users for authentication and posts for blog content. The FOREIGN KEY constraint ensures every post belongs to a valid user. Using a singleton pattern for the database connection ensures only one connection exists throughout the application.

Part 2: User Authentication

Build a complete authentication system with registration, login, and session management:

<?php

class Database {
    private static ?PDO $instance = null;
    public static function connect(): PDO {
        if (self::$instance === null) {
            self::$instance = new PDO('sqlite::memory:', null, null, [
                PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
                PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            ]);
            self::$instance->exec("CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE, email TEXT UNIQUE, password_hash TEXT, role TEXT DEFAULT 'author', created_at TEXT DEFAULT CURRENT_TIMESTAMP)");
        }
        return self::$instance;
    }
}

class Auth {
    private PDO $db;

    public function __construct() {
        $this->db = Database::connect();
    }

    public function register(string $username, string $email, string $password): array {
        // Validate input
        $errors = [];
        if (strlen($username) < 3) $errors[] = 'Username must be at least 3 characters';
        if (!filter_var($email, FILTER_VALIDATE_EMAIL)) $errors[] = 'Invalid email';
        if (strlen($password) < 8) $errors[] = 'Password must be at least 8 characters';

        if (!empty($errors)) {
            return ['success' => false, 'errors' => $errors];
        }

        // Check for existing user
        $stmt = $this->db->prepare("SELECT id FROM users WHERE username = ? OR email = ?");
        $stmt->execute([$username, $email]);
        if ($stmt->fetch()) {
            return ['success' => false, 'errors' => ['Username or email already taken']];
        }

        // Create user with hashed password
        $hash = password_hash($password, PASSWORD_DEFAULT);
        $stmt = $this->db->prepare("INSERT INTO users (username, email, password_hash) VALUES (?, ?, ?)");
        $stmt->execute([$username, $email, $hash]);

        $userId = (int) $this->db->lastInsertId();
        return ['success' => true, 'user_id' => $userId, 'message' => "User {$username} registered"];
    }

    public function login(string $username, string $password): array {
        $stmt = $this->db->prepare("SELECT * FROM users WHERE username = ?");
        $stmt->execute([$username]);
        $user = $stmt->fetch();

        if (!$user || !password_verify($password, $user['password_hash'])) {
            return ['success' => false, 'errors' => ['Invalid username or password']];
        }

        // In a real app: session_regenerate_id(true);
        // $_SESSION['user_id'] = $user['id'];
        return [
            'success' => true,
            'user' => [
                'id' => $user['id'],
                'username' => $user['username'],
                'email' => $user['email'],
                'role' => $user['role'],
            ],
        ];
    }

    public function getUser(int $id): ?array {
        $stmt = $this->db->prepare("SELECT id, username, email, role, created_at FROM users WHERE id = ?");
        $stmt->execute([$id]);
        return $stmt->fetch() ?: null;
    }
}

$auth = new Auth();

// Register users
echo "=== Registration ===\n";
$result = $auth->register('alice', 'alice@example.com', 'SecurePass123');
echo ($result['success'] ? "OK: {$result['message']}" : "FAIL: " . implode(', ', $result['errors'])) . "\n";

$result = $auth->register('bob', 'bob@example.com', 'BobPass456');
echo ($result['success'] ? "OK: {$result['message']}" : "FAIL: " . implode(', ', $result['errors'])) . "\n";

// Try duplicate
$result = $auth->register('alice', 'alice2@example.com', 'AnotherPass789');
echo ($result['success'] ? "OK: {$result['message']}" : "FAIL: " . implode(', ', $result['errors'])) . "\n";

// Login
echo "\n=== Login ===\n";
$result = $auth->login('alice', 'SecurePass123');
echo ($result['success'] ? "OK: Logged in as {$result['user']['username']} ({$result['user']['role']})" : "FAIL: " . implode(', ', $result['errors'])) . "\n";

$result = $auth->login('alice', 'WrongPassword');
echo ($result['success'] ? "OK" : "FAIL: " . implode(', ', $result['errors'])) . "\n";

// Validate short password
echo "\n=== Validation ===\n";
$result = $auth->register('x', 'bad', 'short');
echo "Errors: " . implode(', ', $result['errors']) . "\n";

The authentication system uses password_hash() for secure storage and password_verify() for checking passwords. Input validation catches problems before they reach the database. In production, you would store the authenticated user's ID in $_SESSION and call session_regenerate_id(true) after login.

Part 3: Blog Post CRUD

Build the core blog functionality with full CRUD operations:

<?php

class Database {
    private static ?PDO $instance = null;
    public static function connect(): PDO {
        if (self::$instance === null) {
            self::$instance = new PDO('sqlite::memory:', null, null, [
                PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
                PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            ]);
            self::$instance->exec("CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE, email TEXT, password_hash TEXT, role TEXT DEFAULT 'author')");
            self::$instance->exec("CREATE TABLE posts (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, title TEXT, slug TEXT UNIQUE, body TEXT, status TEXT DEFAULT 'draft', created_at TEXT DEFAULT CURRENT_TIMESTAMP, updated_at TEXT DEFAULT CURRENT_TIMESTAMP)");
            $hash = password_hash('pass', PASSWORD_DEFAULT);
            self::$instance->exec("INSERT INTO users (username, email, password_hash) VALUES ('alice', 'alice@example.com', '{$hash}')");
        }
        return self::$instance;
    }
}

class PostRepository {
    private PDO $db;

    public function __construct() {
        $this->db = Database::connect();
    }

    public function create(int $userId, string $title, string $body, string $status = 'draft'): array {
        $errors = $this->validate(['title' => $title, 'body' => $body, 'status' => $status]);
        if (!empty($errors)) {
            return ['success' => false, 'errors' => $errors];
        }

        $slug = $this->generateSlug($title);

        $stmt = $this->db->prepare("
            INSERT INTO posts (user_id, title, slug, body, status)
            VALUES (:user_id, :title, :slug, :body, :status)
        ");
        $stmt->execute([
            'user_id' => $userId,
            'title' => $title,
            'slug' => $slug,
            'body' => $body,
            'status' => $status,
        ]);

        $id = (int) $this->db->lastInsertId();
        return ['success' => true, 'post' => $this->findById($id)];
    }

    public function findById(int $id): ?array {
        $stmt = $this->db->prepare("
            SELECT p.*, u.username as author
            FROM posts p JOIN users u ON p.user_id = u.id
            WHERE p.id = ?
        ");
        $stmt->execute([$id]);
        return $stmt->fetch() ?: null;
    }

    public function findAll(string $status = '', int $limit = 10): array {
        $sql = "SELECT p.*, u.username as author FROM posts p JOIN users u ON p.user_id = u.id";
        $params = [];

        if ($status) {
            $sql .= " WHERE p.status = ?";
            $params[] = $status;
        }

        $sql .= " ORDER BY p.created_at DESC LIMIT ?";
        $params[] = $limit;

        $stmt = $this->db->prepare($sql);
        $stmt->execute($params);
        return $stmt->fetchAll();
    }

    public function update(int $id, array $data): array {
        $post = $this->findById($id);
        if (!$post) {
            return ['success' => false, 'errors' => ['Post not found']];
        }

        $errors = $this->validate($data);
        if (!empty($errors)) {
            return ['success' => false, 'errors' => $errors];
        }

        $stmt = $this->db->prepare("
            UPDATE posts SET title = ?, slug = ?, body = ?, status = ?, updated_at = CURRENT_TIMESTAMP
            WHERE id = ?
        ");
        $stmt->execute([
            $data['title'] ?? $post['title'],
            $this->generateSlug($data['title'] ?? $post['title']),
            $data['body'] ?? $post['body'],
            $data['status'] ?? $post['status'],
            $id,
        ]);

        return ['success' => true, 'post' => $this->findById($id)];
    }

    public function delete(int $id): bool {
        $stmt = $this->db->prepare("DELETE FROM posts WHERE id = ?");
        $stmt->execute([$id]);
        return $stmt->rowCount() > 0;
    }

    private function validate(array $data): array {
        $errors = [];
        if (isset($data['title']) && strlen($data['title']) < 3) {
            $errors[] = 'Title must be at least 3 characters';
        }
        if (isset($data['body']) && strlen($data['body']) < 10) {
            $errors[] = 'Body must be at least 10 characters';
        }
        if (isset($data['status']) && !in_array($data['status'], ['draft', 'published'])) {
            $errors[] = 'Status must be draft or published';
        }
        return $errors;
    }

    private function generateSlug(string $title): string {
        $slug = strtolower(trim(preg_replace('/[^a-zA-Z0-9]+/', '-', $title), '-'));
        $stmt = $this->db->prepare("SELECT COUNT(*) FROM posts WHERE slug = ?");
        $stmt->execute([$slug]);
        if ($stmt->fetchColumn() > 0) {
            $slug .= '-' . time();
        }
        return $slug;
    }
}

$repo = new PostRepository();

// Create posts
echo "=== Creating Posts ===\n";
$result = $repo->create(1, 'Getting Started with PHP', 'PHP is a powerful server-side language used by millions of developers worldwide.', 'published');
echo "Created: {$result['post']['title']} (/{$result['post']['slug']})\n";

$result = $repo->create(1, 'Understanding OOP', 'Object-oriented programming helps you organize code into reusable classes and objects.', 'published');
echo "Created: {$result['post']['title']} (/{$result['post']['slug']})\n";

$result = $repo->create(1, 'My Draft Ideas', 'This is a draft post with some ideas for future articles and topics.', 'draft');
echo "Created: {$result['post']['title']} (/{$result['post']['slug']})\n";

// List published posts
echo "\n=== Published Posts ===\n";
$posts = $repo->findAll('published');
foreach ($posts as $post) {
    echo "  [{$post['id']}] {$post['title']} by {$post['author']} ({$post['status']})\n";
}

// Update a post
echo "\n=== Updating Post #1 ===\n";
$result = $repo->update(1, ['title' => 'Getting Started with PHP 8', 'body' => 'PHP 8 introduces many modern features including union types, match expressions, and named arguments.']);
echo "Updated: {$result['post']['title']}\n";

// Delete a post
echo "\n=== Deleting Draft ===\n";
$deleted = $repo->delete(3);
echo "Deleted: " . ($deleted ? 'Yes' : 'No') . "\n";

// Final state
echo "\n=== All Remaining Posts ===\n";
foreach ($repo->findAll() as $post) {
    echo "  [{$post['id']}] {$post['title']} ({$post['status']})\n";
}

The PostRepository class encapsulates all database operations for posts. It uses prepared statements for security, validates input before saving, and generates URL-friendly slugs from titles. The JOIN query includes the author's username, demonstrating how to work with related data across tables.

Part 4: Template Rendering

Build a simple template system to render HTML pages:

<?php

class Template {
    private array $globals = [];

    public function addGlobal(string $name, mixed $value): void {
        $this->globals[$name] = $value;
    }

    public function render(string $template, array $data = []): string {
        $data = array_merge($this->globals, $data);

        // Simple template engine: replace {{ variable }} placeholders
        $html = $this->getTemplate($template);

        // Replace escaped variables: {{ var }}
        $html = preg_replace_callback('/\{\{\s*(\w+)\s*\}\}/', function ($matches) use ($data) {
            $key = $matches[1];
            $value = $data[$key] ?? '';
            return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
        }, $html);

        // Replace raw variables: {!! var !!}
        $html = preg_replace_callback('/\{!!\s*(\w+)\s*!!\}/', function ($matches) use ($data) {
            return (string) ($data[$matches[1]] ?? '');
        }, $html);

        return $html;
    }

    private function getTemplate(string $name): string {
        $templates = [
            'layout' => <<<'HTML'
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>{{ title }} - My Blog</title>
</head>
<body>
    <header>
        <h1>My PHP Blog</h1>
        <nav>Home | Posts | About</nav>
    </header>
    <main>
        {!! content !!}
    </main>
    <footer>&copy; 2024 My PHP Blog</footer>
</body>
</html>
HTML,
            'post_list' => <<<'HTML'
<h2>Blog Posts</h2>
{!! posts_html !!}
HTML,
            'post_detail' => <<<'HTML'
<article>
    <h2>{{ title }}</h2>
    <p class="meta">By {{ author }} on {{ date }}</p>
    <div class="content">{{ body }}</div>
    <a href="/posts">Back to all posts</a>
</article>
HTML,
        ];

        return $templates[$name] ?? "Template '{$name}' not found";
    }
}

// Use the template engine
$tpl = new Template();
$tpl->addGlobal('site_name', 'My PHP Blog');

// Render a post list
$posts = [
    ['id' => 1, 'title' => 'Getting Started with PHP 8', 'author' => 'alice', 'date' => '2024-01-15'],
    ['id' => 2, 'title' => 'Understanding OOP', 'author' => 'alice', 'date' => '2024-01-20'],
];

$postsHtml = '';
foreach ($posts as $post) {
    $safeTitle = htmlspecialchars($post['title'], ENT_QUOTES, 'UTF-8');
    $postsHtml .= "<div class=\"post-preview\">\n";
    $postsHtml .= "  <h3><a href=\"/posts/{$post['id']}\">{$safeTitle}</a></h3>\n";
    $postsHtml .= "  <p>By {$post['author']} on {$post['date']}</p>\n";
    $postsHtml .= "</div>\n";
}

$listPage = $tpl->render('post_list', ['posts_html' => $postsHtml]);
$fullPage = $tpl->render('layout', ['title' => 'All Posts', 'content' => $listPage]);

echo "=== Rendered Post List Page ===\n";
echo $fullPage . "\n\n";

// Render a single post
$detailPage = $tpl->render('post_detail', [
    'title' => 'Getting Started with PHP 8',
    'author' => 'alice',
    'date' => '2024-01-15',
    'body' => 'PHP 8 introduces union types, match expressions, named arguments, and more.',
]);

echo "=== Rendered Post Detail ===\n";
echo $detailPage . "\n";

The template system uses {{ }} for escaped output (safe from XSS) and {!! !!} for raw HTML (used only for pre-built trusted content like the post list). In production, you would use a proper template engine like Blade (Laravel) or Twig (Symfony).

Part 5: REST API Endpoint

Add a JSON API so the blog content can be consumed by frontends and mobile apps:

<?php

class Database {
    private static ?PDO $instance = null;
    public static function connect(): PDO {
        if (self::$instance === null) {
            self::$instance = new PDO('sqlite::memory:', null, null, [
                PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
                PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            ]);
            self::$instance->exec("CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT, email TEXT)");
            self::$instance->exec("CREATE TABLE posts (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, title TEXT, slug TEXT, body TEXT, status TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP)");
            self::$instance->exec("INSERT INTO users VALUES (1, 'alice', 'alice@example.com')");

            $stmt = self::$instance->prepare("INSERT INTO posts (user_id, title, slug, body, status) VALUES (?, ?, ?, ?, ?)");
            $stmt->execute([1, 'Getting Started with PHP 8', 'getting-started-with-php-8', 'PHP 8 introduces many modern features.', 'published']);
            $stmt->execute([1, 'Understanding OOP', 'understanding-oop', 'Object-oriented programming in PHP.', 'published']);
            $stmt->execute([1, 'Draft Ideas', 'draft-ideas', 'Some future article ideas.', 'draft']);
        }
        return self::$instance;
    }
}

class BlogApi {
    private PDO $db;

    public function __construct() {
        $this->db = Database::connect();
    }

    public function handleRequest(string $method, string $path, array $body = []): array {
        return match (true) {
            $method === 'GET' && $path === '/api/posts'
                => $this->listPosts(),
            $method === 'GET' && preg_match('#^/api/posts/(\d+)$#', $path, $m)
                => $this->getPost((int) $m[1]),
            $method === 'POST' && $path === '/api/posts'
                => $this->createPost($body),
            $method === 'DELETE' && preg_match('#^/api/posts/(\d+)$#', $path, $m)
                => $this->deletePost((int) $m[1]),
            default
                => $this->jsonResponse(404, ['error' => 'Not found']),
        };
    }

    private function listPosts(): array {
        $stmt = $this->db->query("
            SELECT p.id, p.title, p.slug, p.status, p.created_at, u.username as author
            FROM posts p JOIN users u ON p.user_id = u.id
            WHERE p.status = 'published'
            ORDER BY p.created_at DESC
        ");
        $posts = $stmt->fetchAll();
        return $this->jsonResponse(200, ['data' => $posts, 'count' => count($posts)]);
    }

    private function getPost(int $id): array {
        $stmt = $this->db->prepare("
            SELECT p.*, u.username as author
            FROM posts p JOIN users u ON p.user_id = u.id
            WHERE p.id = ?
        ");
        $stmt->execute([$id]);
        $post = $stmt->fetch();

        if (!$post) {
            return $this->jsonResponse(404, ['error' => "Post #{$id} not found"]);
        }
        return $this->jsonResponse(200, ['data' => $post]);
    }

    private function createPost(array $body): array {
        if (empty($body['title']) || empty($body['body'])) {
            return $this->jsonResponse(422, [
                'error' => 'Validation failed',
                'details' => ['title and body are required'],
            ]);
        }

        $stmt = $this->db->prepare("INSERT INTO posts (user_id, title, slug, body, status) VALUES (?, ?, ?, ?, ?)");
        $slug = strtolower(trim(preg_replace('/[^a-zA-Z0-9]+/', '-', $body['title']), '-'));
        $stmt->execute([
            $body['user_id'] ?? 1,
            $body['title'],
            $slug,
            $body['body'],
            $body['status'] ?? 'draft',
        ]);

        $id = (int) $this->db->lastInsertId();
        $response = $this->getPost($id);
        $response['status'] = 201;
        return $response;
    }

    private function deletePost(int $id): array {
        $stmt = $this->db->prepare("DELETE FROM posts WHERE id = ?");
        $stmt->execute([$id]);

        if ($stmt->rowCount() === 0) {
            return $this->jsonResponse(404, ['error' => "Post #{$id} not found"]);
        }
        return $this->jsonResponse(204, null);
    }

    private function jsonResponse(int $status, mixed $data): array {
        return [
            'status' => $status,
            'headers' => ['Content-Type' => 'application/json'],
            'body' => $data !== null ? json_encode($data, JSON_PRETTY_PRINT) : '',
        ];
    }
}

$api = new BlogApi();

// Simulate API requests
$requests = [
    ['GET', '/api/posts', []],
    ['GET', '/api/posts/1', []],
    ['POST', '/api/posts', ['title' => 'New API Post', 'body' => 'Created via the REST API endpoint', 'status' => 'published']],
    ['GET', '/api/posts/999', []],
    ['DELETE', '/api/posts/3', []],
];

foreach ($requests as [$method, $path, $body]) {
    echo "=== {$method} {$path} ===\n";
    $response = $api->handleRequest($method, $path, $body);
    echo "HTTP {$response['status']}\n";
    if ($response['body']) {
        echo $response['body'] . "\n";
    }
    echo "\n";
}

The API uses PHP 8's match expression to route requests based on method and path. Each handler validates input, performs the database operation, and returns a structured JSON response with the appropriate HTTP status code.

Project Architecture Summary

Here is how all the pieces fit together in a complete application:

<?php

// Architecture overview of the complete blog system

$architecture = [
    'Entry Point' => [
        'public/index.php' => 'Single entry point, loads autoloader and routes requests',
    ],
    'Authentication' => [
        'Auth::register()' => 'Validates input, hashes password, creates user',
        'Auth::login()' => 'Verifies password, creates session, regenerates ID',
        'Auth::logout()' => 'Destroys session, clears cookie',
    ],
    'Blog CRUD' => [
        'PostRepository::create()' => 'Validates, generates slug, inserts into DB',
        'PostRepository::findAll()' => 'Lists posts with filters and author JOIN',
        'PostRepository::findById()' => 'Gets single post with author data',
        'PostRepository::update()' => 'Validates and updates existing post',
        'PostRepository::delete()' => 'Removes post from database',
    ],
    'API Layer' => [
        'GET /api/posts' => 'Returns published posts as JSON array',
        'GET /api/posts/{id}' => 'Returns single post as JSON object',
        'POST /api/posts' => 'Creates post, validates input, returns 201',
        'DELETE /api/posts/{id}' => 'Deletes post, returns 204',
    ],
    'Templates' => [
        'layout' => 'Base HTML structure with header, nav, footer',
        'post_list' => 'Renders list of post previews with links',
        'post_detail' => 'Renders full post with author and date',
    ],
    'Security' => [
        'password_hash/verify' => 'Bcrypt password storage',
        'Prepared statements' => 'SQL injection prevention on every query',
        'htmlspecialchars' => 'XSS prevention in templates',
        'CSRF tokens' => 'Form submission protection',
        'Input validation' => 'Every input validated before use',
    ],
];

echo "=== Blog System Architecture ===\n\n";
foreach ($architecture as $layer => $components) {
    echo "{$layer}:\n";
    foreach ($components as $component => $description) {
        echo "  {$component}\n    -> {$description}\n";
    }
    echo "\n";
}

// Skills used in this project
$skills = [
    'Lesson 2: Variables & Types' => 'Typed properties, union types, string interpolation',
    'Lesson 3: Functions' => 'Type declarations, default parameters, return types',
    'Lesson 4: Control Flow' => 'Match expressions, if/else validation logic',
    'Lesson 5: Arrays & Objects' => 'Associative arrays, array manipulation functions',
    'Lesson 6: OOP & Classes' => 'Classes, constructors, visibility, static methods, singleton',
    'Lesson 7: Error Handling' => 'Try/catch, validation errors, error responses',
    'Lesson 8: Databases' => 'PDO, prepared statements, CRUD, JOINs, transactions',
    'Lesson 9: File Handling' => 'Template loading, configuration files',
    'Lesson 10: Sessions' => 'User login state, session management',
    'Lesson 12: API Development' => 'REST endpoints, JSON responses, HTTP status codes',
    'Lesson 13: Security' => 'Password hashing, XSS prevention, input validation',
    'Lesson 14: Deployment' => 'Project structure, environment config, Composer',
];

echo "=== Skills Applied ===\n\n";
foreach ($skills as $lesson => $applied) {
    echo "  {$lesson}\n    Applied: {$applied}\n";
}

echo "\nCongratulations! You have completed the PHP course.\n";
echo "You now have the skills to build real-world PHP applications.\n";

Key Takeaways

  • A well-structured PHP application separates concerns: database, authentication, business logic, templates, and API
  • The repository pattern encapsulates database operations behind a clean interface
  • Input validation should happen at every boundary: forms, API endpoints, and database operations
  • Security is woven throughout: hashed passwords, prepared statements, escaped output, CSRF protection
  • A JSON API can coexist alongside HTML pages, sharing the same data layer
  • PHP 8 features (match expressions, named arguments, typed properties, union types) make code cleaner and safer

Where to Go Next

Congratulations on completing the PHP course! You have learned everything from basic syntax to building a full-stack application. Here are your next steps:

  • Build your own project: Pick an idea (task manager, recipe site, portfolio) and build it from scratch
  • Learn a framework: Dive deeper into Laravel or Symfony to accelerate your development
  • Study testing: Learn PHPUnit to write automated tests for your code
  • Explore DevOps: Set up CI/CD pipelines with GitHub Actions for automated testing and deployment
  • Join the community: Follow PHP-FIG for standards, read PHP Weekly, and contribute to open-source PHP projects

Pro Tip: The best way to learn is by building. Take this capstone project and extend it: add comments on posts, image uploads, tags and categories, a search feature, or an admin dashboard. Each feature you add will deepen your understanding of PHP and web development. Keep your code organized, write tests, and do not be afraid to refactor as you learn better patterns.

Next Steps

You have built a complete blog application combining authentication, database operations, template rendering, a REST API, and security hardening. Every core PHP skill has been applied in a realistic project.

With the fundamentals solid, it is time to deepen your expertise. String manipulation is a skill you will reach for constantly -- parsing URLs, formatting user input, generating slugs, processing CSV data, building templates. PHP has one of the richest string function libraries of any language, and mastering it will make you noticeably faster and more confident in day-to-day development.

Continue to String Manipulation -->