Projeto Mini Blog
A Ana quer um blog para contar o que está aprendendo (e guardar a receita do pão de queijo da vó). Neste projeto você vai construí-lo do zero: os posts ficam num banco SQLite, a página inicial lista os mais recentes, cada post tem a sua página, um formulário publica posts novos, e todas as páginas compartilham o mesmo cabeçalho e rodapé. São várias páginas PHP trabalhando juntas, como num site de verdade.
- Guardar e ler dados com PDO e prepared statements no SQLite.
- Repartir o site em arquivos com include e require (cabeçalho, rodapé, funções).
- Mostrar dados com segurança com
htmlspecialchars(XSS). - Receber um formulário, validar e redirecionar.
- Ler parâmetros da URL com $_GET e dividir a lista em páginas (
LIMIT/OFFSET).
O que vamos construir
Sete arquivos pequenos, cada um com uma tarefa, na pasta ~/blog:
| Arquivo | Papel | Etapa |
|---|---|---|
banco.php | Conecta ao SQLite e cria a tabela com 5 posts de exemplo na primeira vez | 1 |
index.php | Página inicial: os posts mais recentes, 3 por página | 1, 2 e 5 |
funcoes.php | Funções usadas em todas as páginas (e(), dataBr(), resumo()) | 2 |
cabecalho.php e rodape.php | O começo e o fim do HTML, iguais em todas as páginas | 2 |
post.php | Um post inteiro: post.php?id=3 | 3 |
novo.php | Formulário para publicar um post | 4 |
O blog pronto, visto pelo curl (o sed mostra só o miolo da página, entre <main> e </main>):
$ curl -s localhost:8000/ | sed -n '/<main>/,/<\/main>/p'
<main>
<article>
<h2><a href="post.php?id=5">Meu blog agora tem banco de dados</a></h2>
<p class="data">12/10/2026</p>
<p>Os posts agora ficam num arquivo SQLite.
O PHP lê com PDO e…</p>
</article>
<article>
<h2><a href="post.php?id=4">Arrays são listas</a></h2>
<p class="data">09/10/2026</p>
<p>Um array guarda vários valores numa variável só.
Com foreac…</p>
</article>
<article>
<h2><a href="post.php?id=3">Aprendendo PHP: variáveis</a></h2>
<p class="data">06/10/2026</p>
<p>Variáveis começam com cifrão, como $nome.
O PHP descobre o …</p>
</article>
<nav>
Página 1 de 2 <a href="index.php?pagina=2">Mais antigos →</a>
</nav>
</main>
- O banco e a primeira lista (SQLite,
foreach,htmlspecialchars). - Cabeçalho e rodapé com include (
include,require, funções comuns). - A página do post (
$_GET["id"], prepared statement, 404). - Escrever um post (formulário, validação,
INSERT, redirecionamento). - Paginação (
COUNT,LIMIT,OFFSET).
Antes de começar
Você vai precisar de PHP dentro do HTML, foreach, funções e do básico de banco de dados (o projeto Tarefas com SQLite é um bom aquecimento). Crie a pasta e deixe o servidor rodando num terminal; os curl vão em outro:
$ mkdir blog $ cd blog $ php -S localhost:8000
Com o servidor ligado, abra http://localhost:8000/ no navegador para ver cada etapa de verdade.
Etapa 1 · O banco e a primeira lista
O banco.php abre (ou cria) o arquivo blog.sqlite. Na primeira vez, cria a tabela posts e grava 5 posts de exemplo, para o blog não começar vazio:
<?php
$arquivo = __DIR__ . "/blog.sqlite";
$novo = !file_exists($arquivo);
$pdo = new PDO("sqlite:$arquivo");
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
if ($novo) {
$pdo->exec("CREATE TABLE posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
titulo TEXT NOT NULL,
conteudo TEXT NOT NULL,
criado_em TEXT NOT NULL
)");
$exemplos = [
["Olá, mundo!", "Este é o primeiro post do meu blog.\nAqui vou contar o que estou aprendendo.", "2026-10-01 09:00:00"],
["Pão de queijo da vó", "Ingredientes: polvilho, queijo, ovos, leite e óleo.\nAsse por 25 minutos a 180 graus.", "2026-10-03 18:30:00"],
["Aprendendo PHP: variáveis", "Variáveis começam com cifrão, como \$nome.\nO PHP descobre o tipo sozinho.", "2026-10-06 20:15:00"],
["Arrays são listas", "Um array guarda vários valores numa variável só.\nCom foreach dá para percorrer todos.", "2026-10-09 21:00:00"],
["Meu blog agora tem banco de dados", "Os posts agora ficam num arquivo SQLite.\nO PHP lê com PDO e prepared statements.", "2026-10-12 10:45:00"],
];
$st = $pdo->prepare("INSERT INTO posts (titulo, conteudo, criado_em) VALUES (?, ?, ?)");
foreach ($exemplos as $post) {
$st->execute($post);
}
}
return $pdo;
| Trecho | O que faz |
|---|---|
$novo = !file_exists($arquivo); | Guarda se o banco ainda não existia (ao conectar, o SQLite cria o arquivo vazio). |
PDO::FETCH_ASSOC | Cada linha vem como array associativo: $post["titulo"]. |
id INTEGER PRIMARY KEY AUTOINCREMENT | Um número único para cada post, escolhido pelo banco. |
criado_em TEXT | A data no formato 2026-10-12 10:45:00 (ano primeiro). Nesse formato, ordem alfabética = ordem de data, e o ORDER BY funciona. |
$st = $pdo->prepare(…); foreach (…) $st->execute($post); | Um prepared statement preparado uma vez e executado 5 vezes, uma por post. Os ? recebem os valores do array, na ordem. |
"…\n…" e \$nome | Entre aspas duplas, \n é uma quebra de linha e \$ é um cifrão de verdade (sem \, o PHP procuraria a variável $nome; veja Interpolação). |
return $pdo; | Quem fizer $pdo = require __DIR__ . "/banco.php"; recebe a conexão. |
A primeira página só lista tudo:
<?php
$pdo = require __DIR__ . "/banco.php";
$posts = $pdo->query("SELECT * FROM posts ORDER BY criado_em DESC")->fetchAll();
?>
<!doctype html>
<html lang="pt-BR">
<head><meta charset="utf-8"><title>Blog da Ana</title></head>
<body>
<h1>Blog da Ana</h1>
<?php foreach ($posts as $post): ?>
<h2><?= htmlspecialchars($post["titulo"]) ?></h2>
<p><?= htmlspecialchars($post["conteudo"]) ?></p>
<?php endforeach; ?>
</body>
</html>
query(…)->fetchAll(): roda o SQL e traz todas as linhas num array. Aqui não há nada vindo do visitante no SQL, entãoquerybasta; com dados do visitante, sempreprepare.ORDER BY criado_em DESC: do mais novo para o mais antigo (DESC= decrescente).foreach (…): … endforeach;é a sintaxe alternativa, mais fácil de ler no meio do HTML (PHP dentro do HTML).<?= htmlspecialchars(…) ?>: escreve o texto do banco escapado. Quem escreveu o post pode ter posto HTML ou um<script>; a etapa 4 mostra o que acontece.
$ ls banco.php index.php $ curl -s localhost:8000/ <!doctype html> <html lang="pt-BR"> <head><meta charset="utf-8"><title>Blog da Ana</title></head> <body> <h1>Blog da Ana</h1> <h2>Meu blog agora tem banco de dados</h2> <p>Os posts agora ficam num arquivo SQLite. O PHP lê com PDO e prepared statements.</p> <h2>Arrays são listas</h2> <p>Um array guarda vários valores numa variável só. Com foreach dá para percorrer todos.</p> <h2>Aprendendo PHP: variáveis</h2> <p>Variáveis começam com cifrão, como $nome. O PHP descobre o tipo sozinho.</p> <h2>Pão de queijo da vó</h2> <p>Ingredientes: polvilho, queijo, ovos, leite e óleo. Asse por 25 minutos a 180 graus.</p> <h2>Olá, mundo!</h2> <p>Este é o primeiro post do meu blog. Aqui vou contar o que estou aprendendo.</p> </body> </html> $ ls banco.php blog.sqlite index.php $ sqlite3 blog.sqlite "SELECT id, titulo, criado_em FROM posts" 1|Olá, mundo!|2026-10-01 09:00:00 2|Pão de queijo da vó|2026-10-03 18:30:00 3|Aprendendo PHP: variáveis|2026-10-06 20:15:00 4|Arrays são listas|2026-10-09 21:00:00 5|Meu blog agora tem banco de dados|2026-10-12 10:45:00
O blog.sqlite nasceu na primeira visita. O programa sqlite3 confirma o que está gravado (a ordem do banco é a de inserção; a página inverte com DESC). Repare também que a quebra de linha dentro do texto não aparece no navegador: em HTML, uma quebra de linha vale um espaço. Na etapa 3 o nl2br resolve isso.
Missão (etapa 1): mostre a data de cada post logo abaixo do título, no formato brasileiro (12/10/2026). Dica: date("d/m/Y", strtotime($post["criado_em"])). O strtotime transforma o texto da data num número (segundos desde 1970) e o date o formata (Datas e Horas).
<?php
$pdo = require __DIR__ . "/banco.php";
$posts = $pdo->query("SELECT * FROM posts ORDER BY criado_em DESC")->fetchAll();
?>
<!doctype html>
<html lang="pt-BR">
<head><meta charset="utf-8"><title>Blog da Ana</title></head>
<body>
<h1>Blog da Ana</h1>
<?php foreach ($posts as $post): ?>
<h2><?= htmlspecialchars($post["titulo"]) ?></h2>
<p><?= htmlspecialchars($post["conteudo"]) ?></p>
<?php endforeach; ?>
</body>
</html>
<?php
$arquivo = __DIR__ . "/blog.sqlite";
$novo = !file_exists($arquivo);
$pdo = new PDO("sqlite:$arquivo");
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
if ($novo) {
$pdo->exec("CREATE TABLE posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
titulo TEXT NOT NULL,
conteudo TEXT NOT NULL,
criado_em TEXT NOT NULL
)");
$exemplos = [
["Olá, mundo!", "Este é o primeiro post do meu blog.\nAqui vou contar o que estou aprendendo.", "2026-10-01 09:00:00"],
["Pão de queijo da vó", "Ingredientes: polvilho, queijo, ovos, leite e óleo.\nAsse por 25 minutos a 180 graus.", "2026-10-03 18:30:00"],
["Aprendendo PHP: variáveis", "Variáveis começam com cifrão, como \$nome.\nO PHP descobre o tipo sozinho.", "2026-10-06 20:15:00"],
["Arrays são listas", "Um array guarda vários valores numa variável só.\nCom foreach dá para percorrer todos.", "2026-10-09 21:00:00"],
["Meu blog agora tem banco de dados", "Os posts agora ficam num arquivo SQLite.\nO PHP lê com PDO e prepared statements.", "2026-10-12 10:45:00"],
];
$st = $pdo->prepare("INSERT INTO posts (titulo, conteudo, criado_em) VALUES (?, ?, ?)");
foreach ($exemplos as $post) {
$st->execute($post);
}
}
return $pdo;
<?php
$pdo = require __DIR__ . "/banco.php";
$posts = $pdo->query("SELECT * FROM posts ORDER BY criado_em DESC")->fetchAll();
?>
<!doctype html>
<html lang="pt-BR">
<head><meta charset="utf-8"><title>Blog da Ana</title></head>
<body>
<h1>Blog da Ana</h1>
<?php foreach ($posts as $post): ?>
<h2><?= htmlspecialchars($post["titulo"]) ?></h2>
<p><?= date("d/m/Y", strtotime($post["criado_em"])) ?></p>
<p><?= htmlspecialchars($post["conteudo"]) ?></p>
<?php endforeach; ?>
</body>
</html>
Etapa 2 · Cabeçalho e rodapé com include
Um blog tem várias páginas (início, post, novo post) com o mesmo topo e o mesmo rodapé. Copiar esse HTML em cada arquivo seria um convite ao erro: mudou o menu, tem de mudar em todos. A solução é pôr cada pedaço repetido num arquivo e incluí-lo. Primeiro, as funções que todas as páginas usam:
<?php
date_default_timezone_set("America/Sao_Paulo"); // horário de Brasília em date()
// Escapa um texto para mostrar no HTML (contra XSS)
function e(?string $texto): string
{
return htmlspecialchars($texto ?? "", ENT_QUOTES);
}
// "2026-10-12 10:45:00" -> "12/10/2026"
function dataBr(string $iso): string
{
return date("d/m/Y", strtotime($iso));
}
// Os primeiros caracteres do texto, com "…" se foi cortado
function resumo(string $texto, int $tamanho = 60): string
{
return mb_strimwidth($texto, 0, $tamanho, "…");
}
| Função | Para quê |
|---|---|
e(?string $texto) | Um apelido curto para htmlspecialchars, que vamos escrever dezenas de vezes. ENT_QUOTES escapa também as aspas (importante dentro de value="…"). O ?string aceita null, tratado como texto vazio. |
dataBr(string $iso) | "2026-10-12 10:45:00" → "12/10/2026". |
resumo(string $texto, int $tamanho = 60) | Os primeiros 60 caracteres, com … no fim, para a lista. mb_strimwidth conta letras, não bytes, e não corta um ç no meio (referência). |
date_default_timezone_set("America/Sao_Paulo") | Sem isso, o date() usa o fuso UTC (3 horas à frente de Brasília) e um post das 22h sairia com a data do dia seguinte. |
O cabeçalho usa uma variável $titulo, que cada página define antes de incluí-lo:
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<title><?= e($titulo) ?> | Blog da Ana</title>
<style>
body { font-family: sans-serif; max-width: 40rem; margin: auto; padding: 0 1rem; }
.data { color: #666; font-size: .9em; }
.erros { color: #b00; }
</style>
</head>
<body>
<header>
<h1><a href="index.php">Blog da Ana</a></h1>
<nav><a href="index.php">Início</a> · <a href="novo.php">Novo post</a></nav>
</header>
<main>
</main>
<footer>
<p>Feito com PHP pela Ana · <?= date("Y") ?></p>
</footer>
</body>
</html>
E a página inicial agora só cuida do que é dela:
<?php
$pdo = require __DIR__ . "/banco.php";
require __DIR__ . "/funcoes.php";
$posts = $pdo->query("SELECT * FROM posts ORDER BY criado_em DESC")->fetchAll();
$titulo = "Início";
include __DIR__ . "/cabecalho.php";
?>
<?php foreach ($posts as $post): ?>
<article>
<h2><a href="post.php?id=<?= $post["id"] ?>"><?= e($post["titulo"]) ?></a></h2>
<p class="data"><?= dataBr($post["criado_em"]) ?></p>
<p><?= e(resumo($post["conteudo"])) ?></p>
</article>
<?php endforeach; ?>
<?php include __DIR__ . "/rodape.php"; ?>
| Linha | O que faz |
|---|---|
$pdo = require __DIR__ . "/banco.php"; | require: se o arquivo faltar, o programa para com erro fatal. Use para o que é indispensável (sem banco, não há blog). |
require __DIR__ . "/funcoes.php"; | As funções e, dataBr, resumo passam a existir nesta página. |
$titulo = "Início"; | Define a variável que o cabecalho.php vai usar. O arquivo incluído enxerga as variáveis de quem o incluiu, como se o código dele estivesse colado ali. |
include __DIR__ . "/cabecalho.php"; | include: se o arquivo faltar, só um Warning, e o resto da página continua. Para pedaços de HTML, é a escolha comum. |
__DIR__ | A pasta deste arquivo. Com ela o caminho funciona não importa de onde o PHP foi chamado (include e require). |
<a href="post.php?id=<?= $post["id"] ?>"> | Cada título vira um link para a página do post (etapa 3). O id é um número que veio do banco, não do visitante. |
$ curl -s localhost:8000/
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<title>Início | Blog da Ana</title>
<style>
body { font-family: sans-serif; max-width: 40rem; margin: auto; padding: 0 1rem; }
.data { color: #666; font-size: .9em; }
.erros { color: #b00; }
</style>
</head>
<body>
<header>
<h1><a href="index.php">Blog da Ana</a></h1>
<nav><a href="index.php">Início</a> · <a href="novo.php">Novo post</a></nav>
</header>
<main>
<article>
<h2><a href="post.php?id=5">Meu blog agora tem banco de dados</a></h2>
<p class="data">12/10/2026</p>
<p>Os posts agora ficam num arquivo SQLite.
O PHP lê com PDO e…</p>
</article>
<article>
<h2><a href="post.php?id=4">Arrays são listas</a></h2>
<p class="data">09/10/2026</p>
<p>Um array guarda vários valores numa variável só.
Com foreac…</p>
</article>
<article>
<h2><a href="post.php?id=3">Aprendendo PHP: variáveis</a></h2>
<p class="data">06/10/2026</p>
<p>Variáveis começam com cifrão, como $nome.
O PHP descobre o …</p>
</article>
<article>
<h2><a href="post.php?id=2">Pão de queijo da vó</a></h2>
<p class="data">03/10/2026</p>
<p>Ingredientes: polvilho, queijo, ovos, leite e óleo.
Asse po…</p>
</article>
<article>
<h2><a href="post.php?id=1">Olá, mundo!</a></h2>
<p class="data">01/10/2026</p>
<p>Este é o primeiro post do meu blog.
Aqui vou contar o que e…</p>
</article>
</main>
<footer>
<p>Feito com PHP pela Ana · 2026</p>
</footer>
</body>
</html>
O navegador recebe uma página só: ele nem sabe que ela foi montada com três arquivos. O <title> traz "Início", que veio da variável $titulo.
Missão (etapa 2): a página está sem rodapé (e sem o </body> e o </html> que ficam nele). Inclua o rodape.php no fim do index.php. Olhe as outras abas: são os arquivos que a página usa. Depois, experimente mudar o menu no cabecalho.php.
<?php
$pdo = require __DIR__ . "/banco.php";
require __DIR__ . "/funcoes.php";
$posts = $pdo->query("SELECT * FROM posts ORDER BY criado_em DESC")->fetchAll();
$titulo = "Início";
include __DIR__ . "/cabecalho.php";
?>
<?php foreach ($posts as $post): ?>
<article>
<h2><a href="post.php?id=<?= $post["id"] ?>"><?= e($post["titulo"]) ?></a></h2>
<p class="data"><?= dataBr($post["criado_em"]) ?></p>
<p><?= e(resumo($post["conteudo"])) ?></p>
</article>
<?php endforeach; ?>
<?php
$arquivo = __DIR__ . "/blog.sqlite";
$novo = !file_exists($arquivo);
$pdo = new PDO("sqlite:$arquivo");
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
if ($novo) {
$pdo->exec("CREATE TABLE posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
titulo TEXT NOT NULL,
conteudo TEXT NOT NULL,
criado_em TEXT NOT NULL
)");
$exemplos = [
["Olá, mundo!", "Este é o primeiro post do meu blog.\nAqui vou contar o que estou aprendendo.", "2026-10-01 09:00:00"],
["Pão de queijo da vó", "Ingredientes: polvilho, queijo, ovos, leite e óleo.\nAsse por 25 minutos a 180 graus.", "2026-10-03 18:30:00"],
["Aprendendo PHP: variáveis", "Variáveis começam com cifrão, como \$nome.\nO PHP descobre o tipo sozinho.", "2026-10-06 20:15:00"],
["Arrays são listas", "Um array guarda vários valores numa variável só.\nCom foreach dá para percorrer todos.", "2026-10-09 21:00:00"],
["Meu blog agora tem banco de dados", "Os posts agora ficam num arquivo SQLite.\nO PHP lê com PDO e prepared statements.", "2026-10-12 10:45:00"],
];
$st = $pdo->prepare("INSERT INTO posts (titulo, conteudo, criado_em) VALUES (?, ?, ?)");
foreach ($exemplos as $post) {
$st->execute($post);
}
}
return $pdo;
<?php
date_default_timezone_set("America/Sao_Paulo"); // horário de Brasília em date()
// Escapa um texto para mostrar no HTML (contra XSS)
function e(?string $texto): string
{
return htmlspecialchars($texto ?? "", ENT_QUOTES);
}
// "2026-10-12 10:45:00" -> "12/10/2026"
function dataBr(string $iso): string
{
return date("d/m/Y", strtotime($iso));
}
// Os primeiros caracteres do texto, com "…" se foi cortado
function resumo(string $texto, int $tamanho = 60): string
{
return mb_strimwidth($texto, 0, $tamanho, "…");
}
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<title><?= e($titulo) ?> | Blog da Ana</title>
<style>
body { font-family: sans-serif; max-width: 40rem; margin: auto; padding: 0 1rem; }
.data { color: #666; font-size: .9em; }
.erros { color: #b00; }
</style>
</head>
<body>
<header>
<h1><a href="index.php">Blog da Ana</a></h1>
<nav><a href="index.php">Início</a> · <a href="novo.php">Novo post</a></nav>
</header>
<main>
</main>
<footer>
<p>Feito com PHP pela Ana · <?= date("Y") ?></p>
</footer>
</body>
</html>
<?php
$pdo = require __DIR__ . "/banco.php";
require __DIR__ . "/funcoes.php";
$posts = $pdo->query("SELECT * FROM posts ORDER BY criado_em DESC")->fetchAll();
$titulo = "Início";
include __DIR__ . "/cabecalho.php";
?>
<?php foreach ($posts as $post): ?>
<article>
<h2><a href="post.php?id=<?= $post["id"] ?>"><?= e($post["titulo"]) ?></a></h2>
<p class="data"><?= dataBr($post["criado_em"]) ?></p>
<p><?= e(resumo($post["conteudo"])) ?></p>
</article>
<?php endforeach; ?>
<?php include __DIR__ . "/rodape.php"; ?>
Etapa 3 · A página do post
Os links da lista apontam para post.php?id=3. O post.php lê o id da URL, busca o post e mostra o texto inteiro:
<?php
$pdo = require __DIR__ . "/banco.php";
require __DIR__ . "/funcoes.php";
$id = (int) ($_GET["id"] ?? 0);
$st = $pdo->prepare("SELECT * FROM posts WHERE id = ?");
$st->execute([$id]);
$post = $st->fetch();
if ($post === false) {
http_response_code(404);
$titulo = "Post não encontrado";
include __DIR__ . "/cabecalho.php";
echo '<p>Esse post não existe. <a href="index.php">Voltar ao início</a></p>';
include __DIR__ . "/rodape.php";
exit;
}
$titulo = $post["titulo"];
include __DIR__ . "/cabecalho.php";
?>
<article>
<h2><?= e($post["titulo"]) ?></h2>
<p class="data"><?= dataBr($post["criado_em"]) ?></p>
<p><?= nl2br(e($post["conteudo"])) ?></p>
</article>
<p><a href="index.php">← Voltar</a></p>
<?php include __DIR__ . "/rodape.php"; ?>
| Trecho | O que faz |
|---|---|
(int) ($_GET["id"] ?? 0) | Lê o id da URL; se não veio, 0. O (int) transforma "3" em 3 e qualquer lixo ("abc") em 0, que não existe no banco. |
prepare("… WHERE id = ?") + execute([$id]) | O valor vai separado do SQL: mesmo sem o (int), ninguém conseguiria injetar SQL pela URL (SQL Injection). |
$st->fetch() | Uma linha (array) ou false, se nenhum post tem esse id. |
http_response_code(404); | Avisa ao navegador (e ao Google) que a página não existe, mesmo mostrando uma mensagem amigável. |
echo '<p>Esse post …'; | Aspas simples por fora, para poder usar aspas duplas no HTML sem escapar. |
exit; | Termina aqui: o resto do arquivo (que usaria $post) não roda. |
$titulo = $post["titulo"]; | A aba do navegador mostra o título do post. |
nl2br(e($post["conteudo"])) | Primeiro escapa, depois põe <br /> nas quebras de linha. Na ordem contrária, o e() escaparia os próprios <br />. |
$ curl -s 'localhost:8000/post.php?id=3' | sed -n '/<main>/,/<\/main>/p' <main> <article> <h2>Aprendendo PHP: variáveis</h2> <p class="data">06/10/2026</p> <p>Variáveis começam com cifrão, como $nome.<br /> O PHP descobre o tipo sozinho.</p> </article> <p><a href="index.php">← Voltar</a></p> </main> $ curl -si 'localhost:8000/post.php?id=99' | sed -n '1p;/<main>/,/<\/main>/p' HTTP/1.1 404 Not Found <main> <p>Esse post não existe. <a href="index.php">Voltar ao início</a></p></main>
Sem o if ($post === false), um id que não existe faz o $post valer false, e cada $post["…"] vira um aviso, até o erro fatal no dataBr:
$ curl -s "localhost:8000/post.php?id=99"
<br />
<b>Warning</b>: Trying to access array offset on false in <b>/home/ana/blog/post.php</b> on line <b>10</b><br />
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<title> | Blog da Ana</title>
<style>
body { font-family: sans-serif; max-width: 40rem; margin: auto; padding: 0 1rem; }
.data { color: #666; font-size: .9em; }
.erros { color: #b00; }
</style>
</head>
<body>
<header>
<h1><a href="index.php">Blog da Ana</a></h1>
<nav><a href="index.php">Início</a> · <a href="novo.php">Novo post</a></nav>
</header>
<main>
<article>
<h2><br />
<b>Warning</b>: Trying to access array offset on false in <b>/home/ana/blog/post.php</b> on line <b>14</b><br />
</h2>
<p class="data"><br />
<b>Warning</b>: Trying to access array offset on false in <b>/home/ana/blog/post.php</b> on line <b>15</b><br />
<br />
<b>Fatal error</b>: Uncaught TypeError: dataBr(): Argument #1 ($iso) must be of type string, null given, called in /home/ana/blog/post.php on line 15 and defined in /home/ana/blog/funcoes.php:11
Stack trace:
#0 /home/ana/blog/post.php(15): dataBr(NULL)
#1 {main}
thrown in <b>/home/ana/blog/funcoes.php</b> on line <b>11</b><br />
O que significa: "tentando acessar uma posição de array em false" (referência de erros). $false["titulo"] vale null, e o dataBr, que exige string, recusou o null. Como consertar: todo fetch() pode dar false; confira logo depois e responda 404. Visitantes chegam a ids inexistentes o tempo todo: links antigos, posts apagados, gente mexendo na URL.
Missão (etapa 3): a caixa abre post.php?id=99 e a página quebra. Acrescente a conferência do false: código 404, o cabeçalho com o título "Post não encontrado", a mensagem Esse post não existe. com um link para o início, o rodapé e exit. Depois, mude o endereço na barra do resultado para /post.php?id=2.
<?php
$pdo = require __DIR__ . "/banco.php";
require __DIR__ . "/funcoes.php";
$id = (int) ($_GET["id"] ?? 0);
$st = $pdo->prepare("SELECT * FROM posts WHERE id = ?");
$st->execute([$id]);
$post = $st->fetch();
$titulo = $post["titulo"];
include __DIR__ . "/cabecalho.php";
?>
<article>
<h2><?= e($post["titulo"]) ?></h2>
<p class="data"><?= dataBr($post["criado_em"]) ?></p>
<p><?= nl2br(e($post["conteudo"])) ?></p>
</article>
<p><a href="index.php">← Voltar</a></p>
<?php include __DIR__ . "/rodape.php"; ?>
<?php
$arquivo = __DIR__ . "/blog.sqlite";
$novo = !file_exists($arquivo);
$pdo = new PDO("sqlite:$arquivo");
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
if ($novo) {
$pdo->exec("CREATE TABLE posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
titulo TEXT NOT NULL,
conteudo TEXT NOT NULL,
criado_em TEXT NOT NULL
)");
$exemplos = [
["Olá, mundo!", "Este é o primeiro post do meu blog.\nAqui vou contar o que estou aprendendo.", "2026-10-01 09:00:00"],
["Pão de queijo da vó", "Ingredientes: polvilho, queijo, ovos, leite e óleo.\nAsse por 25 minutos a 180 graus.", "2026-10-03 18:30:00"],
["Aprendendo PHP: variáveis", "Variáveis começam com cifrão, como \$nome.\nO PHP descobre o tipo sozinho.", "2026-10-06 20:15:00"],
["Arrays são listas", "Um array guarda vários valores numa variável só.\nCom foreach dá para percorrer todos.", "2026-10-09 21:00:00"],
["Meu blog agora tem banco de dados", "Os posts agora ficam num arquivo SQLite.\nO PHP lê com PDO e prepared statements.", "2026-10-12 10:45:00"],
];
$st = $pdo->prepare("INSERT INTO posts (titulo, conteudo, criado_em) VALUES (?, ?, ?)");
foreach ($exemplos as $post) {
$st->execute($post);
}
}
return $pdo;
<?php
date_default_timezone_set("America/Sao_Paulo"); // horário de Brasília em date()
// Escapa um texto para mostrar no HTML (contra XSS)
function e(?string $texto): string
{
return htmlspecialchars($texto ?? "", ENT_QUOTES);
}
// "2026-10-12 10:45:00" -> "12/10/2026"
function dataBr(string $iso): string
{
return date("d/m/Y", strtotime($iso));
}
// Os primeiros caracteres do texto, com "…" se foi cortado
function resumo(string $texto, int $tamanho = 60): string
{
return mb_strimwidth($texto, 0, $tamanho, "…");
}
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<title><?= e($titulo) ?> | Blog da Ana</title>
<style>
body { font-family: sans-serif; max-width: 40rem; margin: auto; padding: 0 1rem; }
.data { color: #666; font-size: .9em; }
.erros { color: #b00; }
</style>
</head>
<body>
<header>
<h1><a href="index.php">Blog da Ana</a></h1>
<nav><a href="index.php">Início</a> · <a href="novo.php">Novo post</a></nav>
</header>
<main>
</main>
<footer>
<p>Feito com PHP pela Ana · <?= date("Y") ?></p>
</footer>
</body>
</html>
<?php
$pdo = require __DIR__ . "/banco.php";
require __DIR__ . "/funcoes.php";
$id = (int) ($_GET["id"] ?? 0);
$st = $pdo->prepare("SELECT * FROM posts WHERE id = ?");
$st->execute([$id]);
$post = $st->fetch();
if ($post === false) {
http_response_code(404);
$titulo = "Post não encontrado";
include __DIR__ . "/cabecalho.php";
echo '<p>Esse post não existe. <a href="index.php">Voltar ao início</a></p>';
include __DIR__ . "/rodape.php";
exit;
}
$titulo = $post["titulo"];
include __DIR__ . "/cabecalho.php";
?>
<article>
<h2><?= e($post["titulo"]) ?></h2>
<p class="data"><?= dataBr($post["criado_em"]) ?></p>
<p><?= nl2br(e($post["conteudo"])) ?></p>
</article>
<p><a href="index.php">← Voltar</a></p>
<?php include __DIR__ . "/rodape.php"; ?>
Etapa 4 · Escrever um post
O novo.php faz duas coisas no mesmo arquivo: num GET (o visitante clicou em "Novo post") mostra o formulário; num POST (clicou em "Publicar") valida e grava. Se algo estiver errado, mostra o formulário de novo, com os erros e com o que a pessoa já tinha digitado.
<?php
$pdo = require __DIR__ . "/banco.php";
require __DIR__ . "/funcoes.php";
$erros = [];
$tituloPost = "";
$conteudo = "";
if ($_SERVER["REQUEST_METHOD"] === "POST") {
$tituloPost = trim($_POST["titulo"] ?? "");
$conteudo = trim($_POST["conteudo"] ?? "");
if ($tituloPost === "") {
$erros[] = "Escreva um título.";
} elseif (mb_strlen($tituloPost) > 120) {
$erros[] = "O título pode ter no máximo 120 letras.";
}
if (mb_strlen($conteudo) < 10) {
$erros[] = "O texto precisa de pelo menos 10 letras.";
}
if (!$erros) {
$st = $pdo->prepare("INSERT INTO posts (titulo, conteudo, criado_em) VALUES (?, ?, ?)");
$st->execute([$tituloPost, $conteudo, date("Y-m-d H:i:s")]);
header("Location: post.php?id=" . $pdo->lastInsertId());
exit;
}
}
$titulo = "Novo post";
include __DIR__ . "/cabecalho.php";
?>
<h2>Novo post</h2>
<?php if ($erros): ?>
<ul class="erros">
<?php foreach ($erros as $erro): ?>
<li><?= e($erro) ?></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<form method="post">
<p><label>Título<br><input name="titulo" value="<?= e($tituloPost) ?>" maxlength="120"></label></p>
<p><label>Texto<br><textarea name="conteudo" rows="8" cols="50"><?= e($conteudo) ?></textarea></label></p>
<p><button>Publicar</button></p>
</form>
<?php include __DIR__ . "/rodape.php"; ?>
| Trecho | O que faz |
|---|---|
$_SERVER["REQUEST_METHOD"] === "POST" | Só processa quando o formulário foi enviado ($_SERVER). |
trim($_POST["titulo"] ?? "") | Lê o campo (ou "" se não veio) e tira espaços das pontas: um título só de espaços conta como vazio. |
$erros[] = "…"; | Junta todos os problemas numa lista, para mostrar de uma vez. |
if (!$erros) | Array vazio conta como falso: "se não há erros". |
date("Y-m-d H:i:s") | Agora, no mesmo formato das datas de exemplo. |
header("Location: post.php?id=" . $pdo->lastInsertId()); exit; | Depois de gravar, manda o navegador para a página do post novo (código 302). Assim, um F5 na página seguinte não publica o post duas vezes. Esse padrão tem nome: POST → redirecionamento → GET (header e Redirecionamento). |
value="<?= e($tituloPost) ?>" e o texto dentro do <textarea> | Devolvem ao formulário o que a pessoa digitou (escapado: um título com aspas não quebra o value="…"). |
$tituloPost (e não $titulo) | O nome $titulo já é usado pelo cabeçalho. Veja o cenário abaixo. |
Publicando pelo curl (-d envia como um formulário). Primeiro, a resposta crua; depois, a página para onde ela manda:
$ curl -si localhost:8000/novo.php -d 'titulo=Testando o formulário&conteudo=Escrevi este post pelo curl.' | head -7 HTTP/1.1 302 Found Host: localhost:8000 Date: Sat, 17 Oct 2026 00:12:08 GMT Connection: close X-Powered-By: PHP/8.5.8 Location: post.php?id=6 Content-type: text/html; charset=UTF-8 $ curl -s 'localhost:8000/post.php?id=6' | sed -n '/<main>/,/<\/main>/p' <main> <article> <h2>Testando o formulário</h2> <p class="data">16/10/2026</p> <p>Escrevi este post pelo curl.</p> </article> <p><a href="index.php">← Voltar</a></p> </main>
Com dados inválidos, nada é gravado e os erros aparecem acima do formulário, que mantém o texto digitado:
$ curl -s localhost:8000/novo.php -d 'titulo=&conteudo=Oi' | sed -n '/<main>/,/<\/main>/p'
<main>
<h2>Novo post</h2>
<ul class="erros">
<li>Escreva um título.</li>
<li>O texto precisa de pelo menos 10 letras.</li>
</ul>
<form method="post">
<p><label>Título<br><input name="titulo" value="" maxlength="120"></label></p>
<p><label>Texto<br><textarea name="conteudo" rows="8" cols="50">Oi</textarea></label></p>
<p><button>Publicar</button></p>
</form>
</main>
E se alguém tentar publicar código JavaScript no título? O -L faz o curl seguir o redirecionamento, como um navegador:
$ curl -sL localhost:8000/novo.php -d 'titulo=<script>alert(1)</script>&conteudo=Um teste de segurança.' | sed -n '/<main>/,/<\/main>/p' <main> <article> <h2><script>alert(1)</script></h2> <p class="data">16/10/2026</p> <p>Um teste de segurança.</p> </article> <p><a href="index.php">← Voltar</a></p> </main> $ sqlite3 blog.sqlite 'SELECT id, titulo, criado_em FROM posts WHERE id > 5' 6|Testando o formulário|2026-10-16 21:12:08 7|<script>alert(1)</script>|2026-10-16 21:12:08
O banco guarda o texto exatamente como veio (o prepared statement cuida de o SQL não quebrar), e o e() o transforma em <script> na hora de mostrar: o navegador exibe o texto, sem executar nada. Essa é a divisão de tarefas: prepared statement na entrada do banco, htmlspecialchars na saída para o HTML.
Se o novo.php usasse $titulo para o título digitado (em vez de $tituloPost), a linha $titulo = "Novo post";, que prepara o cabeçalho, apagaria o que a pessoa escreveu. Ela digita "Minha horta", erra o texto e…
$ curl -s localhost:8000/novo.php -d 'titulo=Minha horta&conteudo=Oi' | sed -n '/<main>/,/<\/main>/p'
<main>
<h2>Novo post</h2>
<ul class="erros">
<li>O texto precisa de pelo menos 10 letras.</li>
</ul>
<form method="post">
<p><label>Título<br><input name="titulo" value="Novo post" maxlength="120"></label></p>
<p><label>Texto<br><textarea name="conteudo" rows="8" cols="50">Oi</textarea></label></p>
<p><button>Publicar</button></p>
</form>
</main>
…o campo volta com Novo post. Nenhuma mensagem de erro: o PHP fez exatamente o que foi mandado. Arquivos incluídos compartilham as variáveis com quem os inclui, então os nomes precisam ser combinados. (Em sistemas maiores, isso se resolve com funções que recebem parâmetros, ou com um sistema de templates.)
Missão (etapa 4): a caixa envia um post com título vazio e o texto "Oi", e o novo.php publica assim mesmo (veja o post sem título no resultado). Escreva a validação no lugar do comentário: título obrigatório (até 120 letras, com mb_strlen) e texto com pelo menos 10 letras. Depois, preencha o formulário no resultado e publique um post de verdade.
<?php
$pdo = require __DIR__ . "/banco.php";
require __DIR__ . "/funcoes.php";
$erros = [];
$tituloPost = "";
$conteudo = "";
if ($_SERVER["REQUEST_METHOD"] === "POST") {
$tituloPost = trim($_POST["titulo"] ?? "");
$conteudo = trim($_POST["conteudo"] ?? "");
// valide aqui: título obrigatório (até 120 letras) e texto com pelo menos 10 letras
if (!$erros) {
$st = $pdo->prepare("INSERT INTO posts (titulo, conteudo, criado_em) VALUES (?, ?, ?)");
$st->execute([$tituloPost, $conteudo, date("Y-m-d H:i:s")]);
header("Location: post.php?id=" . $pdo->lastInsertId());
exit;
}
}
$titulo = "Novo post";
include __DIR__ . "/cabecalho.php";
?>
<h2>Novo post</h2>
<?php if ($erros): ?>
<ul class="erros">
<?php foreach ($erros as $erro): ?>
<li><?= e($erro) ?></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<form method="post">
<p><label>Título<br><input name="titulo" value="<?= e($tituloPost) ?>" maxlength="120"></label></p>
<p><label>Texto<br><textarea name="conteudo" rows="8" cols="50"><?= e($conteudo) ?></textarea></label></p>
<p><button>Publicar</button></p>
</form>
<?php include __DIR__ . "/rodape.php"; ?>
<?php
$pdo = require __DIR__ . "/banco.php";
require __DIR__ . "/funcoes.php";
$id = (int) ($_GET["id"] ?? 0);
$st = $pdo->prepare("SELECT * FROM posts WHERE id = ?");
$st->execute([$id]);
$post = $st->fetch();
if ($post === false) {
http_response_code(404);
$titulo = "Post não encontrado";
include __DIR__ . "/cabecalho.php";
echo '<p>Esse post não existe. <a href="index.php">Voltar ao início</a></p>';
include __DIR__ . "/rodape.php";
exit;
}
$titulo = $post["titulo"];
include __DIR__ . "/cabecalho.php";
?>
<article>
<h2><?= e($post["titulo"]) ?></h2>
<p class="data"><?= dataBr($post["criado_em"]) ?></p>
<p><?= nl2br(e($post["conteudo"])) ?></p>
</article>
<p><a href="index.php">← Voltar</a></p>
<?php include __DIR__ . "/rodape.php"; ?>
<?php
$arquivo = __DIR__ . "/blog.sqlite";
$novo = !file_exists($arquivo);
$pdo = new PDO("sqlite:$arquivo");
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
if ($novo) {
$pdo->exec("CREATE TABLE posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
titulo TEXT NOT NULL,
conteudo TEXT NOT NULL,
criado_em TEXT NOT NULL
)");
$exemplos = [
["Olá, mundo!", "Este é o primeiro post do meu blog.\nAqui vou contar o que estou aprendendo.", "2026-10-01 09:00:00"],
["Pão de queijo da vó", "Ingredientes: polvilho, queijo, ovos, leite e óleo.\nAsse por 25 minutos a 180 graus.", "2026-10-03 18:30:00"],
["Aprendendo PHP: variáveis", "Variáveis começam com cifrão, como \$nome.\nO PHP descobre o tipo sozinho.", "2026-10-06 20:15:00"],
["Arrays são listas", "Um array guarda vários valores numa variável só.\nCom foreach dá para percorrer todos.", "2026-10-09 21:00:00"],
["Meu blog agora tem banco de dados", "Os posts agora ficam num arquivo SQLite.\nO PHP lê com PDO e prepared statements.", "2026-10-12 10:45:00"],
];
$st = $pdo->prepare("INSERT INTO posts (titulo, conteudo, criado_em) VALUES (?, ?, ?)");
foreach ($exemplos as $post) {
$st->execute($post);
}
}
return $pdo;
<?php
date_default_timezone_set("America/Sao_Paulo"); // horário de Brasília em date()
// Escapa um texto para mostrar no HTML (contra XSS)
function e(?string $texto): string
{
return htmlspecialchars($texto ?? "", ENT_QUOTES);
}
// "2026-10-12 10:45:00" -> "12/10/2026"
function dataBr(string $iso): string
{
return date("d/m/Y", strtotime($iso));
}
// Os primeiros caracteres do texto, com "…" se foi cortado
function resumo(string $texto, int $tamanho = 60): string
{
return mb_strimwidth($texto, 0, $tamanho, "…");
}
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<title><?= e($titulo) ?> | Blog da Ana</title>
<style>
body { font-family: sans-serif; max-width: 40rem; margin: auto; padding: 0 1rem; }
.data { color: #666; font-size: .9em; }
.erros { color: #b00; }
</style>
</head>
<body>
<header>
<h1><a href="index.php">Blog da Ana</a></h1>
<nav><a href="index.php">Início</a> · <a href="novo.php">Novo post</a></nav>
</header>
<main>
</main>
<footer>
<p>Feito com PHP pela Ana · <?= date("Y") ?></p>
</footer>
</body>
</html>
<?php
$pdo = require __DIR__ . "/banco.php";
require __DIR__ . "/funcoes.php";
$erros = [];
$tituloPost = "";
$conteudo = "";
if ($_SERVER["REQUEST_METHOD"] === "POST") {
$tituloPost = trim($_POST["titulo"] ?? "");
$conteudo = trim($_POST["conteudo"] ?? "");
if ($tituloPost === "") {
$erros[] = "Escreva um título.";
} elseif (mb_strlen($tituloPost) > 120) {
$erros[] = "O título pode ter no máximo 120 letras.";
}
if (mb_strlen($conteudo) < 10) {
$erros[] = "O texto precisa de pelo menos 10 letras.";
}
if (!$erros) {
$st = $pdo->prepare("INSERT INTO posts (titulo, conteudo, criado_em) VALUES (?, ?, ?)");
$st->execute([$tituloPost, $conteudo, date("Y-m-d H:i:s")]);
header("Location: post.php?id=" . $pdo->lastInsertId());
exit;
}
}
$titulo = "Novo post";
include __DIR__ . "/cabecalho.php";
?>
<h2>Novo post</h2>
<?php if ($erros): ?>
<ul class="erros">
<?php foreach ($erros as $erro): ?>
<li><?= e($erro) ?></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<form method="post">
<p><label>Título<br><input name="titulo" value="<?= e($tituloPost) ?>" maxlength="120"></label></p>
<p><label>Texto<br><textarea name="conteudo" rows="8" cols="50"><?= e($conteudo) ?></textarea></label></p>
<p><button>Publicar</button></p>
</form>
<?php include __DIR__ . "/rodape.php"; ?>
Qualquer pessoa que achar o novo.php pode publicar no blog da Ana, e outro site poderia até enviar o formulário em nome de quem estiver com o blog aberto. Num blog de verdade, o novo.php exige login (projeto Login com Sessão) e um token contra CSRF (CSRF e Segurança de Formulários). Os dois estão nos desafios.
Etapa 5 · Paginação
Com 100 posts, a página inicial ficaria enorme. A solução é mostrar alguns por vez (aqui, 3) e links para as próximas "páginas". O SQL faz o trabalho pesado: LIMIT 3 = "no máximo 3 linhas"; OFFSET 3 = "pule as 3 primeiras".
| Página | OFFSET = (página − 1) × 3 | Posts mostrados |
|---|---|---|
| 1 | 0 | 5, 4, 3 |
| 2 | 3 | 2, 1 |
O começo do index.php fica assim (o resto da página não muda, só ganha os links no fim):
$porPagina = 3;
$total = (int) $pdo->query("SELECT COUNT(*) FROM posts")->fetchColumn();
$paginas = max(1, (int) ceil($total / $porPagina));
$pagina = (int) ($_GET["pagina"] ?? 1);
$pagina = min(max($pagina, 1), $paginas);
$st = $pdo->prepare("SELECT * FROM posts ORDER BY criado_em DESC LIMIT ? OFFSET ?");
$st->bindValue(1, $porPagina, PDO::PARAM_INT);
$st->bindValue(2, ($pagina - 1) * $porPagina, PDO::PARAM_INT);
$st->execute();
$posts = $st->fetchAll();
| Linha | O que faz |
|---|---|
SELECT COUNT(*) … + fetchColumn() | Quantos posts existem (5). fetchColumn pega o primeiro valor da primeira linha. |
ceil($total / $porPagina) | 5 / 3 = 1,67 → arredonda para cima: 2 páginas. O max(1, …) garante 1 página mesmo com o blog vazio. |
min(max($pagina, 1), $paginas) | Prende o número entre 1 e o total: ?pagina=99 vira 2, ?pagina=-5 ou ?pagina=abc viram 1. Nunca confie num número vindo da URL. |
bindValue(1, $porPagina, PDO::PARAM_INT) | Liga o 1º ? a um valor, dizendo que é inteiro. Com execute([...]) os valores iriam como texto, e o MySQL recusa texto no LIMIT; com PARAM_INT funciona em qualquer banco. |
<nav>
<?php if ($pagina > 1): ?>
<a href="index.php?pagina=<?= $pagina - 1 ?>">← Mais novos</a>
<?php endif; ?>
Página <?= $pagina ?> de <?= $paginas ?>
<?php if ($pagina < $paginas): ?>
<a href="index.php?pagina=<?= $pagina + 1 ?>">Mais antigos →</a>
<?php endif; ?>
</nav>
$ curl -s 'localhost:8000/index.php?pagina=2' | sed -n '/<main>/,/<\/main>/p'
<main>
<article>
<h2><a href="post.php?id=2">Pão de queijo da vó</a></h2>
<p class="data">03/10/2026</p>
<p>Ingredientes: polvilho, queijo, ovos, leite e óleo.
Asse po…</p>
</article>
<article>
<h2><a href="post.php?id=1">Olá, mundo!</a></h2>
<p class="data">01/10/2026</p>
<p>Este é o primeiro post do meu blog.
Aqui vou contar o que e…</p>
</article>
<nav>
<a href="index.php?pagina=1">← Mais novos</a>
Página 2 de 2 </nav>
</main>
$ curl -s 'localhost:8000/index.php?pagina=99' | grep Página
Página 2 de 2 </nav>
$ curl -s 'localhost:8000/index.php?pagina=abc' | grep Página
Página 1 de 2 <a href="index.php?pagina=2">Mais antigos →</a>
Os espaços sobrando em volta de "Página 2 de 2" vêm das linhas com <?php if … ?>; o navegador os junta num espaço só, então não aparecem.
Missão (etapa 5): a caixa pede index.php?pagina=2, mas a página ainda mostra os 5 posts. Acrescente a paginação (3 por página) e os links. Na página 2 devem aparecer só "Pão de queijo da vó" e "Olá, mundo!", com Página 2 de 2 e o link ← Mais novos. Clique nos links do resultado para navegar.
<?php
$pdo = require __DIR__ . "/banco.php";
require __DIR__ . "/funcoes.php";
$posts = $pdo->query("SELECT * FROM posts ORDER BY criado_em DESC")->fetchAll();
$titulo = "Início";
include __DIR__ . "/cabecalho.php";
?>
<?php foreach ($posts as $post): ?>
<article>
<h2><a href="post.php?id=<?= $post["id"] ?>"><?= e($post["titulo"]) ?></a></h2>
<p class="data"><?= dataBr($post["criado_em"]) ?></p>
<p><?= e(resumo($post["conteudo"])) ?></p>
</article>
<?php endforeach; ?>
<?php include __DIR__ . "/rodape.php"; ?>
<?php
$arquivo = __DIR__ . "/blog.sqlite";
$novo = !file_exists($arquivo);
$pdo = new PDO("sqlite:$arquivo");
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
if ($novo) {
$pdo->exec("CREATE TABLE posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
titulo TEXT NOT NULL,
conteudo TEXT NOT NULL,
criado_em TEXT NOT NULL
)");
$exemplos = [
["Olá, mundo!", "Este é o primeiro post do meu blog.\nAqui vou contar o que estou aprendendo.", "2026-10-01 09:00:00"],
["Pão de queijo da vó", "Ingredientes: polvilho, queijo, ovos, leite e óleo.\nAsse por 25 minutos a 180 graus.", "2026-10-03 18:30:00"],
["Aprendendo PHP: variáveis", "Variáveis começam com cifrão, como \$nome.\nO PHP descobre o tipo sozinho.", "2026-10-06 20:15:00"],
["Arrays são listas", "Um array guarda vários valores numa variável só.\nCom foreach dá para percorrer todos.", "2026-10-09 21:00:00"],
["Meu blog agora tem banco de dados", "Os posts agora ficam num arquivo SQLite.\nO PHP lê com PDO e prepared statements.", "2026-10-12 10:45:00"],
];
$st = $pdo->prepare("INSERT INTO posts (titulo, conteudo, criado_em) VALUES (?, ?, ?)");
foreach ($exemplos as $post) {
$st->execute($post);
}
}
return $pdo;
<?php
date_default_timezone_set("America/Sao_Paulo"); // horário de Brasília em date()
// Escapa um texto para mostrar no HTML (contra XSS)
function e(?string $texto): string
{
return htmlspecialchars($texto ?? "", ENT_QUOTES);
}
// "2026-10-12 10:45:00" -> "12/10/2026"
function dataBr(string $iso): string
{
return date("d/m/Y", strtotime($iso));
}
// Os primeiros caracteres do texto, com "…" se foi cortado
function resumo(string $texto, int $tamanho = 60): string
{
return mb_strimwidth($texto, 0, $tamanho, "…");
}
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<title><?= e($titulo) ?> | Blog da Ana</title>
<style>
body { font-family: sans-serif; max-width: 40rem; margin: auto; padding: 0 1rem; }
.data { color: #666; font-size: .9em; }
.erros { color: #b00; }
</style>
</head>
<body>
<header>
<h1><a href="index.php">Blog da Ana</a></h1>
<nav><a href="index.php">Início</a> · <a href="novo.php">Novo post</a></nav>
</header>
<main>
</main>
<footer>
<p>Feito com PHP pela Ana · <?= date("Y") ?></p>
</footer>
</body>
</html>
<?php
$pdo = require __DIR__ . "/banco.php";
require __DIR__ . "/funcoes.php";
$porPagina = 3;
$total = (int) $pdo->query("SELECT COUNT(*) FROM posts")->fetchColumn();
$paginas = max(1, (int) ceil($total / $porPagina));
$pagina = (int) ($_GET["pagina"] ?? 1);
$pagina = min(max($pagina, 1), $paginas);
$st = $pdo->prepare("SELECT * FROM posts ORDER BY criado_em DESC LIMIT ? OFFSET ?");
$st->bindValue(1, $porPagina, PDO::PARAM_INT);
$st->bindValue(2, ($pagina - 1) * $porPagina, PDO::PARAM_INT);
$st->execute();
$posts = $st->fetchAll();
$titulo = "Início";
include __DIR__ . "/cabecalho.php";
?>
<?php foreach ($posts as $post): ?>
<article>
<h2><a href="post.php?id=<?= $post["id"] ?>"><?= e($post["titulo"]) ?></a></h2>
<p class="data"><?= dataBr($post["criado_em"]) ?></p>
<p><?= e(resumo($post["conteudo"])) ?></p>
</article>
<?php endforeach; ?>
<nav>
<?php if ($pagina > 1): ?>
<a href="index.php?pagina=<?= $pagina - 1 ?>">← Mais novos</a>
<?php endif; ?>
Página <?= $pagina ?> de <?= $paginas ?>
<?php if ($pagina < $paginas): ?>
<a href="index.php?pagina=<?= $pagina + 1 ?>">Mais antigos →</a>
<?php endif; ?>
</nav>
<?php include __DIR__ . "/rodape.php"; ?>
O código completo
Os sete arquivos na pasta ~/blog. Para rodar: php -S localhost:8000 nessa pasta e http://localhost:8000/ no navegador. Para recomeçar com os posts de exemplo, apague o blog.sqlite.
<?php
$arquivo = __DIR__ . "/blog.sqlite";
$novo = !file_exists($arquivo);
$pdo = new PDO("sqlite:$arquivo");
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
if ($novo) {
$pdo->exec("CREATE TABLE posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
titulo TEXT NOT NULL,
conteudo TEXT NOT NULL,
criado_em TEXT NOT NULL
)");
$exemplos = [
["Olá, mundo!", "Este é o primeiro post do meu blog.\nAqui vou contar o que estou aprendendo.", "2026-10-01 09:00:00"],
["Pão de queijo da vó", "Ingredientes: polvilho, queijo, ovos, leite e óleo.\nAsse por 25 minutos a 180 graus.", "2026-10-03 18:30:00"],
["Aprendendo PHP: variáveis", "Variáveis começam com cifrão, como \$nome.\nO PHP descobre o tipo sozinho.", "2026-10-06 20:15:00"],
["Arrays são listas", "Um array guarda vários valores numa variável só.\nCom foreach dá para percorrer todos.", "2026-10-09 21:00:00"],
["Meu blog agora tem banco de dados", "Os posts agora ficam num arquivo SQLite.\nO PHP lê com PDO e prepared statements.", "2026-10-12 10:45:00"],
];
$st = $pdo->prepare("INSERT INTO posts (titulo, conteudo, criado_em) VALUES (?, ?, ?)");
foreach ($exemplos as $post) {
$st->execute($post);
}
}
return $pdo;
<?php
date_default_timezone_set("America/Sao_Paulo"); // horário de Brasília em date()
// Escapa um texto para mostrar no HTML (contra XSS)
function e(?string $texto): string
{
return htmlspecialchars($texto ?? "", ENT_QUOTES);
}
// "2026-10-12 10:45:00" -> "12/10/2026"
function dataBr(string $iso): string
{
return date("d/m/Y", strtotime($iso));
}
// Os primeiros caracteres do texto, com "…" se foi cortado
function resumo(string $texto, int $tamanho = 60): string
{
return mb_strimwidth($texto, 0, $tamanho, "…");
}
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<title><?= e($titulo) ?> | Blog da Ana</title>
<style>
body { font-family: sans-serif; max-width: 40rem; margin: auto; padding: 0 1rem; }
.data { color: #666; font-size: .9em; }
.erros { color: #b00; }
</style>
</head>
<body>
<header>
<h1><a href="index.php">Blog da Ana</a></h1>
<nav><a href="index.php">Início</a> · <a href="novo.php">Novo post</a></nav>
</header>
<main>
</main>
<footer>
<p>Feito com PHP pela Ana · <?= date("Y") ?></p>
</footer>
</body>
</html>
<?php
$pdo = require __DIR__ . "/banco.php";
require __DIR__ . "/funcoes.php";
$porPagina = 3;
$total = (int) $pdo->query("SELECT COUNT(*) FROM posts")->fetchColumn();
$paginas = max(1, (int) ceil($total / $porPagina));
$pagina = (int) ($_GET["pagina"] ?? 1);
$pagina = min(max($pagina, 1), $paginas);
$st = $pdo->prepare("SELECT * FROM posts ORDER BY criado_em DESC LIMIT ? OFFSET ?");
$st->bindValue(1, $porPagina, PDO::PARAM_INT);
$st->bindValue(2, ($pagina - 1) * $porPagina, PDO::PARAM_INT);
$st->execute();
$posts = $st->fetchAll();
$titulo = "Início";
include __DIR__ . "/cabecalho.php";
?>
<?php foreach ($posts as $post): ?>
<article>
<h2><a href="post.php?id=<?= $post["id"] ?>"><?= e($post["titulo"]) ?></a></h2>
<p class="data"><?= dataBr($post["criado_em"]) ?></p>
<p><?= e(resumo($post["conteudo"])) ?></p>
</article>
<?php endforeach; ?>
<nav>
<?php if ($pagina > 1): ?>
<a href="index.php?pagina=<?= $pagina - 1 ?>">← Mais novos</a>
<?php endif; ?>
Página <?= $pagina ?> de <?= $paginas ?>
<?php if ($pagina < $paginas): ?>
<a href="index.php?pagina=<?= $pagina + 1 ?>">Mais antigos →</a>
<?php endif; ?>
</nav>
<?php include __DIR__ . "/rodape.php"; ?>
<?php
$pdo = require __DIR__ . "/banco.php";
require __DIR__ . "/funcoes.php";
$id = (int) ($_GET["id"] ?? 0);
$st = $pdo->prepare("SELECT * FROM posts WHERE id = ?");
$st->execute([$id]);
$post = $st->fetch();
if ($post === false) {
http_response_code(404);
$titulo = "Post não encontrado";
include __DIR__ . "/cabecalho.php";
echo '<p>Esse post não existe. <a href="index.php">Voltar ao início</a></p>';
include __DIR__ . "/rodape.php";
exit;
}
$titulo = $post["titulo"];
include __DIR__ . "/cabecalho.php";
?>
<article>
<h2><?= e($post["titulo"]) ?></h2>
<p class="data"><?= dataBr($post["criado_em"]) ?></p>
<p><?= nl2br(e($post["conteudo"])) ?></p>
</article>
<p><a href="index.php">← Voltar</a></p>
<?php include __DIR__ . "/rodape.php"; ?>
<?php
$pdo = require __DIR__ . "/banco.php";
require __DIR__ . "/funcoes.php";
$erros = [];
$tituloPost = "";
$conteudo = "";
if ($_SERVER["REQUEST_METHOD"] === "POST") {
$tituloPost = trim($_POST["titulo"] ?? "");
$conteudo = trim($_POST["conteudo"] ?? "");
if ($tituloPost === "") {
$erros[] = "Escreva um título.";
} elseif (mb_strlen($tituloPost) > 120) {
$erros[] = "O título pode ter no máximo 120 letras.";
}
if (mb_strlen($conteudo) < 10) {
$erros[] = "O texto precisa de pelo menos 10 letras.";
}
if (!$erros) {
$st = $pdo->prepare("INSERT INTO posts (titulo, conteudo, criado_em) VALUES (?, ?, ?)");
$st->execute([$tituloPost, $conteudo, date("Y-m-d H:i:s")]);
header("Location: post.php?id=" . $pdo->lastInsertId());
exit;
}
}
$titulo = "Novo post";
include __DIR__ . "/cabecalho.php";
?>
<h2>Novo post</h2>
<?php if ($erros): ?>
<ul class="erros">
<?php foreach ($erros as $erro): ?>
<li><?= e($erro) ?></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<form method="post">
<p><label>Título<br><input name="titulo" value="<?= e($tituloPost) ?>" maxlength="120"></label></p>
<p><label>Texto<br><textarea name="conteudo" rows="8" cols="50"><?= e($conteudo) ?></textarea></label></p>
<p><button>Publicar</button></p>
</form>
<?php include __DIR__ . "/rodape.php"; ?>
Desafios extras
- Busca. Uma página
busca.php?busca=PHPque procura no título e no texto (missão abaixo). - Só a Ana publica. Proteja o
novo.phpcom login, como no projeto Login com Sessão: sem$_SESSION["usuario"], redirecione para o login. - Token CSRF. Um campo oculto com um código aleatório guardado na sessão e conferido com
hash_equalsno POST (CSRF). - Editar e apagar.
editar.php?id=3(o mesmo formulário, preenchido, comUPDATE) e um botão "Apagar" que envia um POST comDELETE FROM posts WHERE id = ?. - Comentários. Uma tabela
comentarios(post_id,nome,texto,criado_em) e um formulário no fim dopost.php. - Endereços bonitos. Uma coluna
slug(pao-de-queijo-da-vo; veja a missão da referência de String) epost.php?slug=…. - Uma API.
api.phpdevolvendo os posts em JSON, como no projeto da API REST.
Missão final: complete o busca.php. Se $busca não estiver vazia, procure os posts que têm o texto no título ou no conteúdo, com um prepared statement: WHERE titulo LIKE ? OR conteudo LIKE ? e o valor "%$busca%" (no LIKE, % quer dizer "qualquer coisa"). Mostre Busca por "PHP": 2 post(s). antes da lista (com e() e count()).
<?php $pdo = require __DIR__ . "/banco.php"; require __DIR__ . "/funcoes.php"; $busca = trim($_GET["busca"] ?? ""); $posts = []; // se $busca não estiver vazia, procure os posts com o texto no título ou no conteúdo $titulo = "Busca"; include __DIR__ . "/cabecalho.php"; ?> <form method="get" action="busca.php"> <input name="busca" value="<?= e($busca) ?>"> <button>Buscar</button> </form> <?php foreach ($posts as $post): ?> <h2><a href="post.php?id=<?= $post["id"] ?>"><?= e($post["titulo"]) ?></a></h2> <?php endforeach; ?> <?php include __DIR__ . "/rodape.php"; ?>
<?php
$arquivo = __DIR__ . "/blog.sqlite";
$novo = !file_exists($arquivo);
$pdo = new PDO("sqlite:$arquivo");
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
if ($novo) {
$pdo->exec("CREATE TABLE posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
titulo TEXT NOT NULL,
conteudo TEXT NOT NULL,
criado_em TEXT NOT NULL
)");
$exemplos = [
["Olá, mundo!", "Este é o primeiro post do meu blog.\nAqui vou contar o que estou aprendendo.", "2026-10-01 09:00:00"],
["Pão de queijo da vó", "Ingredientes: polvilho, queijo, ovos, leite e óleo.\nAsse por 25 minutos a 180 graus.", "2026-10-03 18:30:00"],
["Aprendendo PHP: variáveis", "Variáveis começam com cifrão, como \$nome.\nO PHP descobre o tipo sozinho.", "2026-10-06 20:15:00"],
["Arrays são listas", "Um array guarda vários valores numa variável só.\nCom foreach dá para percorrer todos.", "2026-10-09 21:00:00"],
["Meu blog agora tem banco de dados", "Os posts agora ficam num arquivo SQLite.\nO PHP lê com PDO e prepared statements.", "2026-10-12 10:45:00"],
];
$st = $pdo->prepare("INSERT INTO posts (titulo, conteudo, criado_em) VALUES (?, ?, ?)");
foreach ($exemplos as $post) {
$st->execute($post);
}
}
return $pdo;
<?php
date_default_timezone_set("America/Sao_Paulo"); // horário de Brasília em date()
// Escapa um texto para mostrar no HTML (contra XSS)
function e(?string $texto): string
{
return htmlspecialchars($texto ?? "", ENT_QUOTES);
}
// "2026-10-12 10:45:00" -> "12/10/2026"
function dataBr(string $iso): string
{
return date("d/m/Y", strtotime($iso));
}
// Os primeiros caracteres do texto, com "…" se foi cortado
function resumo(string $texto, int $tamanho = 60): string
{
return mb_strimwidth($texto, 0, $tamanho, "…");
}
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<title><?= e($titulo) ?> | Blog da Ana</title>
<style>
body { font-family: sans-serif; max-width: 40rem; margin: auto; padding: 0 1rem; }
.data { color: #666; font-size: .9em; }
.erros { color: #b00; }
</style>
</head>
<body>
<header>
<h1><a href="index.php">Blog da Ana</a></h1>
<nav><a href="index.php">Início</a> · <a href="novo.php">Novo post</a></nav>
</header>
<main>
</main>
<footer>
<p>Feito com PHP pela Ana · <?= date("Y") ?></p>
</footer>
</body>
</html>
<?php
$pdo = require __DIR__ . "/banco.php";
require __DIR__ . "/funcoes.php";
$busca = trim($_GET["busca"] ?? "");
$posts = [];
if ($busca !== "") {
$st = $pdo->prepare("SELECT * FROM posts WHERE titulo LIKE ? OR conteudo LIKE ? ORDER BY criado_em DESC");
$st->execute(["%$busca%", "%$busca%"]);
$posts = $st->fetchAll();
}
$titulo = "Busca";
include __DIR__ . "/cabecalho.php";
?>
<form method="get" action="busca.php">
<input name="busca" value="<?= e($busca) ?>"> <button>Buscar</button>
</form>
<?php if ($busca !== ""): ?>
<p>Busca por "<?= e($busca) ?>": <?= count($posts) ?> post(s).</p>
<?php endif; ?>
<?php foreach ($posts as $post): ?>
<h2><a href="post.php?id=<?= $post["id"] ?>"><?= e($post["titulo"]) ?></a></h2>
<?php endforeach; ?>
<?php include __DIR__ . "/rodape.php"; ?>
$ curl -s 'localhost:8000/busca.php?busca=PHP' | sed -n '/<main>/,/<\/main>/p' <main> <form method="get" action="busca.php"> <input name="busca" value="PHP"> <button>Buscar</button> </form> <p>Busca por "PHP": 2 post(s).</p> <h2><a href="post.php?id=5">Meu blog agora tem banco de dados</a></h2> <h2><a href="post.php?id=3">Aprendendo PHP: variáveis</a></h2> </main>
Exercícios
1. Qual a diferença entre include e require quando o arquivo não existe?
2. Com 3 posts por página, qual o OFFSET da página 4?
3. Por que o novo.php redireciona para o post depois de gravar, em vez de mostrar a página ali mesmo?
4. Onde se usa o htmlspecialchars (a nossa e())?
Complete para pegar só o número de posts de SELECT COUNT(*) FROM posts:
Complete para mostrar as quebras de linha do texto no HTML, com segurança:
Resumo
- Um site é um conjunto de páginas PHP que compartilham arquivos:
requirepara o indispensável (banco, funções),includepara pedaços de HTML (cabeçalho, rodapé), sempre com__DIR__. - Arquivos incluídos enxergam as variáveis de quem os inclui (
$titulo): combine os nomes. - Banco:
querysem dados do visitante;prepare+execute/bindValuecom eles.fetch()pode darfalse: responda 404. - Saída:
htmlspecialcharsem tudo que veio do banco ou do visitante;nl2brdepois dele. - Formulário: o mesmo arquivo mostra (GET) e processa (POST); junte os erros num array; depois de gravar,
header("Location: …")+exit. - Paginação:
COUNT(*),ceil, prender a página entre 1 e o total,LIMIT ? OFFSET ?comPDO::PARAM_INT.
Próximo projeto: a API REST, em que o PHP devolve dados em JSON em vez de páginas.