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
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
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
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)
▶ Exemplo: Correspondência dinâmica de rotas
<?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) Ponto de entrada (public/index.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
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
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
<!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 $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 $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 $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
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:
# 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)
# 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, queRouter::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
- Arquitetura do projeto: Entrada → Router → Controlador → Modelo → Visualização
- Padrão singleton de banco de dados: uma conexão PDO compartilhada globalmente
- Rotas dinâmicas do roteador:
/posts/{id}por meio de correspondência com expressões regulares - Estrutura em camadas MVC: o Controlador coordena, o Modelo opera o banco de dados, a Visualização renderiza o HTML
- As sessões mantêm o estado de login; usuários não autenticados são redirecionados para a página de login
- O buffer de saída permite que os controladores retornem strings HTML
📝 Exercícios
- Configure o sistema de blog localmente seguindo o código acima: cadastre uma conta → faça login → publique uma postagem → visualize a postagem.
- Adicionar uma página “Minhas publicações” ao sistema do blog (exibindo apenas os artigos do usuário que está conectado no momento).
- Adicionar um recurso de exclusão de postagens (somente o autor da postagem pode excluir suas próprias postagens).



