Mini-blog em PHP
Um blog mínimo com o fluxo completo: listar posts, ler um post inteiro e publicar novos. Um arquivo só (index.php), com PDO + prepared statements e escape anti-XSS.
? + execute(), nunca concatenar) contra SQL injection; h() em toda saída contra XSS; e PRG (Post-Redirect-Get) pra o F5 não republicar.
Criar a tabela
Só uma tabela: posts. O criado_em usa DEFAULT CURRENT_TIMESTAMP — o banco preenche a data/hora sozinho ao inserir, então o PHP nem manda esse campo. Rode no phpMyAdmin.
CREATE TABLE posts (
id INT AUTO_INCREMENT PRIMARY KEY,
titulo VARCHAR(160) NOT NULL,
conteudo TEXT NOT NULL,
-- o banco preenche a data sozinho ao inserir:
criado_em DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
Conectar ao banco (PDO)
A conexão fica num arquivo separado pra reaproveitar com require. Sempre em try/catch e com o modo de exceção ligado.
<?php
$dsn = "mysql:host=localhost;dbname=blog;charset=utf8mb4";
try {
$pdo = new PDO($dsn, "root", "");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
} catch (PDOException $e) {
exit("Não foi possível conectar ao banco.");
}
Listar posts (SELECT)
Como não há valor do usuário na query, pode usar query() direto. ORDER BY criado_em DESC mostra os mais novos primeiro. Toda saída passa por h().
<?php
require "conexao.php";
// Helper anti-XSS: escapa tudo que vai pro HTML.
function h(string $s): string {
return htmlspecialchars($s, ENT_QUOTES, "UTF-8");
}
$posts = $pdo->query("SELECT * FROM posts ORDER BY criado_em DESC")->fetchAll();
foreach ($posts as $p) {
echo h($p["titulo"]) . "\n";
}
Ver um post (SELECT com id)
Aqui o id vem da URL (valor de fora!), então obrigatoriamente prepared statement. O cast (int) é uma defesa extra. Pra exibir o conteúdo, nl2br(h(...)) nesta ordem.
$id = (int) ($_GET["post"] ?? 0); // cast protege contra lixo na URL
$stmt = $pdo->prepare("SELECT * FROM posts WHERE id = ?");
$stmt->execute([$id]);
$post = $stmt->fetch(); // false se não existir
echo nl2br(h($post["conteudo"])); // h() ANTES: escapa; nl2br DEPOIS: vira <br>
nl2br(h($txt)). Se invertesse — h(nl2br($txt)) — o <br> gerado seria escapado e apareceria como texto; pior, abriria brecha pra HTML malicioso passar.
Criar post (validar + INSERT + PRG)
No POST: ler com trim(), validar no backend, inserir com prepared statement e redirecionar (PRG). O criado_em não vai aqui — é o DEFAULT do banco.
$erros = [];
if ($_SERVER["REQUEST_METHOD"] === "POST") {
$titulo = trim($_POST["titulo"] ?? "");
$conteudo = trim($_POST["conteudo"] ?? "");
// Backend é obrigatório; o frontend é só UX.
if ($titulo === "") $erros[] = "O título é obrigatório.";
if ($conteudo === "") $erros[] = "O conteúdo é obrigatório.";
if ($erros === []) {
$stmt = $pdo->prepare("INSERT INTO posts (titulo, conteudo) VALUES (?, ?)");
$stmt->execute([$titulo, $conteudo]);
header("Location: index.php?msg=publicado"); // PRG: F5 não republica
exit;
}
}
Tudo junto: index.php
Os passos num arquivo só. A mesma página tem 3 estados decididos pela requisição: ?post=ID mostra o post inteiro; senão mostra a lista + formulário; o POST publica e redireciona.
<?php
require "conexao.php"; // traz o $pdo
function h(string $s): string {
return htmlspecialchars($s, ENT_QUOTES, "UTF-8");
}
// DATETIME (YYYY-MM-DD HH:MM:SS) -> BR (DD/MM/AAAA HH:MM).
function dataBr(string $dt): string {
return (new DateTime($dt))->format("d/m/Y H:i");
}
// ---- CRIAR POST (POST) ----
$erros = [];
$titulo = $conteudo = ""; // sticky form
if ($_SERVER["REQUEST_METHOD"] === "POST") {
$titulo = trim($_POST["titulo"] ?? "");
$conteudo = trim($_POST["conteudo"] ?? "");
if ($titulo === "") $erros[] = "O título é obrigatório.";
elseif (mb_strlen($titulo) > 160) $erros[] = "Título muito longo (máx. 160).";
if ($conteudo === "") $erros[] = "O conteúdo é obrigatório.";
if ($erros === []) {
$stmt = $pdo->prepare("INSERT INTO posts (titulo, conteudo) VALUES (?, ?)");
$stmt->execute([$titulo, $conteudo]);
header("Location: index.php?msg=publicado");
exit;
}
}
// ---- VER UM POST (GET ?post=ID) ----
$post = null;
if (isset($_GET["post"])) {
$stmt = $pdo->prepare("SELECT * FROM posts WHERE id = ?");
$stmt->execute([(int) $_GET["post"]]);
$post = $stmt->fetch();
}
// ---- LISTAR (só na home) ----
$posts = $post ? [] : $pdo->query("SELECT * FROM posts ORDER BY criado_em DESC")->fetchAll();
$aviso = ($_GET["msg"] ?? "") === "publicado" ? "Post publicado!" : "";
?>
<!DOCTYPE html>
<html lang="pt-BR">
<head><meta charset="utf-8"><title>Mini-blog</title>
<style> /* ...CSS da página (resumido)... */ </style>
</head>
<body>
<?php if ($post): ?>
<!-- ESTADO 1: um post inteiro -->
<p><a href="index.php">← voltar</a></p>
<h1><?= h($post["titulo"]) ?></h1>
<p class="meta"><?= dataBr($post["criado_em"]) ?></p>
<div class="conteudo"><?= nl2br(h($post["conteudo"])) ?></div>
<?php else: ?>
<!-- ESTADO 2: lista + formulário -->
<h1>Mini-blog</h1>
<?php if ($aviso !== ""): ?>
<p class="aviso"><?= h($aviso) ?></p>
<?php endif; ?>
<?php if ($erros !== []): ?>
<div class="erros"><ul>
<?php foreach ($erros as $e): ?><li><?= h($e) ?></li><?php endforeach; ?>
</ul></div>
<?php endif; ?>
<form method="post">
<input type="text" name="titulo" value="<?= h($titulo) ?>" placeholder="Título">
<textarea name="conteudo" rows="5"><?= h($conteudo) ?></textarea>
<button type="submit">Publicar</button>
</form>
<?php foreach ($posts as $p): ?>
<div class="post-item">
<h2><a href="index.php?post=<?= (int) $p["id"] ?>"><?= h($p["titulo"]) ?></a></h2>
<span class="meta"><?= dataBr($p["criado_em"]) ?></span>
</div>
<?php endforeach; ?>
<?php endif; ?>
</body>
</html>
conexao.php e index.php numa pasta em htdocs/ e acesse pelo Apache (ex.: http://localhost/blog/). Como usa header(), tem que rodar no servidor — não abra como file://.