Projeto: Sistema de Blog – Parte 1

Ao longo das 36 aulas até agora, você dominou PHP, MySQL, sessões, programação orientada a objetos (OOP) e manipulação de arquivos — todas as habilidades básicas. Nas próximas duas aulas, vamos unir esses conhecimentos dispersos para criar um aplicativo completo: um sistema de blog de verdade.

1. Arquitetura do projeto

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. Arquivos principais

(1) config.php

PHP
<?php
return [
    'db' => [
        'host'    => 'localhost',
        'dbname'  => 'myblog',
        'username' => 'root',
        'password' => '',
        'charset'  => 'utf8mb4',
    ],
    'app' => [
        'name' => 'MyBlog',
        'url'  => 'http://localhost/myblog',
    ],
];

(2) Conexão com o banco de dados (Database.php)

▶ Exemplo: Conexão com banco de dados do tipo singleton

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;
    }
}
▶ Experimente

(3) Router (Router.php)

▶ Exemplo: Correspondência dinâmica de rotas

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>';
    }
}
▶ Experimente

(4) Ponto de entrada (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. Modelo de postagem (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. Controlador (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. Visualizar arquivos

(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. Configuração do banco de dados

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. Criação da estrutura de diretórios

No diretório myphp/public/, execute:

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]

❓ Perguntas Frequentes

P: Por que processar todas as solicitações por meio de um único ponto de entrada, o index.php? R: Isso é conhecido como padrão Front Controller e é a arquitetura padrão dos frameworks modernos de PHP (Laravel/Symfony). Benefícios: autenticação unificada, tratamento unificado de erros e roteamento de URLs configurado em um único local.

P: Por que usar o buffer de saída (ob_start/ob_get_clean) para retornar HTML? R: A responsabilidade de um controlador é “retornar um valor”, não “exibir HTML”. O mecanismo de buffer de saída permite que o controlador retorne uma string, que Router::dispatch() então exibe. Isso facilita a adição de middleware, a configuração de cabeçalhos de resposta e outras tarefas no futuro.

❓ Perguntas Frequentes

P: Como devo lidar com falhas no upload de imagens no blog? R: Sempre valide o tipo e o tamanho do arquivo no servidor antes de mover o arquivo enviado. Verifique se $_FILES["error"] contém UPLOAD_ERR_OK, confirme se a extensão está na sua lista de permissões (jpg/png/gif/webp) e mantenha os tamanhos dos arquivos dentro de um limite razoável, como 2 MB. Nunca confie apenas na validação do lado do cliente.

📖 Resumo

📝 Exercícios

  1. Configure o sistema de blog localmente seguindo o código acima: cadastre uma conta → faça login → publique uma postagem → visualize a postagem.
  2. Adicionar uma página “Minhas publicações” ao sistema do blog (exibindo apenas os artigos do usuário que está conectado no momento).
  3. Adicionar um recurso de exclusão de postagens (somente o autor da postagem pode excluir suas próprias postagens).
Web-Tutorial.com

Equipe Técnica Web-Tutorial

Uma plataforma de tutoriais mantida por diversos desenvolvedores. Cada tutorial é escrito e revisado por profissionais da área correspondente. Trabalhamos para manter nosso conteúdo preciso e confiável — se encontrar algum problema, avise-nos.

100%