المشروع: نظام المدونة - الجزء الأول

من خلال 36 درسًا حتى الآن، أصبحتَ تتقن لغات البرمجة PHP وMySQL والجلسات (Sessions) والبرمجة الكائنية (OOP) ومعالجة الملفات — أي جميع المهارات الأساسية. وفي الدرسين التاليين، سنقوم بربط هذه المعارف المتفرقة معًا لتشكيل تطبيق كامل — نظام مدونة حقيقي.

1. بنية المشروع

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. الملفات الأساسية

(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.php)

▶ مثال: اتصال قاعدة البيانات من نوع «سينجلتون»

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.php)

▶ مثال: مطابقة المسار الديناميكي

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) نقطة الدخول (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.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. وحدة التحكم (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. عرض الملفات

(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. إعداد قاعدة البيانات

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. إنشاء بنية المجلدات

في الدليل myphp/public/ الخاص بك، قم بتشغيل:

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]

❓ أسئلة شائعة

س لماذا يتم معالجة جميع الطلبات من خلال نقطة دخول واحدة هي index.php؟
ج يُعرف هذا بنمط «المحرك الأمامي» (Front Controller)، وهو البنية القياسية لأطر عمل PHP الحديثة (Laravel/Symfony). المزايا: مصادقة موحدة، ومعالجة أخطاء موحدة، وتوجيه عناوين URL يتم تكوينها في مكان واحد.
س لماذا نستخدم التخزين المؤقت للمخرجات (ob_start/ob_get_clean) لإرجاع HTML؟
ج تتمثل مسؤولية وحدة التحكم في «إرجاع قيمة»، وليس «عرض HTML». تتيح آلية ob لوحدة التحكم إرجاع سلسلة نصية، والتي تقوم Router::dispatch() بعد ذلك بعرضها. وهذا يسهل إضافة برامج وسيطة، وتعيين رؤوس الاستجابة، وغير ذلك من الأمور في المستقبل.

❓ أسئلة شائعة

س كيف يمكنني التعامل مع حالات فشل تحميل الصور في المدونة؟
ج احرص دائمًا على التحقق من نوع الملف وحجمه من جانب الخادم قبل نقل الملف الذي تم تحميله. تحقق من وجود القيمة UPLOAD_ERR_OK في $_FILES["error"]، وتأكد من أن الامتداد مدرج في قائمة الملفات المسموح بها (jpg/png/gif/webp)، وحافظ على أحجام الملفات ضمن حد معقول مثل 2 ميغابايت. لا تعتمد أبدًا على التحقق من صحة الملفات من جانب العميل وحده.

📖 ملخص

📝 تمارين

  1. قم بإعداد نظام المدونة محليًّا باتباع التعليمات الواردة في الكود أعلاه: قم بتسجيل حساب → قم بتسجيل الدخول → انشر منشورًا → اعرض المنشور.
  2. إضافة صفحة «منشوراتي» إلى نظام المدونة (تعرض فقط مقالات المستخدم الذي قام بتسجيل الدخول حاليًا).
  3. إضافة ميزة حذف المنشورات (لا يمكن إلا لمؤلف المنشور حذف منشوراته الخاصة).
Web-Tutorial.com

فريق Web-Tutorial التقني

منصة دروس برمجية يديرها عدة مطورين. كل درس يتم كتابته ومراجعته بواسطة مطورين متخصصين في المجال. نعمل على ضمان دقة وموثوقية المحتوى — إذا لاحظت أي مشكلة، فيرجى إخبارنا.

100%