PHP Projeto Tarefas com SQLite

A Ana vive esquecendo coisas. Neste projeto você faz para ela uma lista de tarefas que fica guardada num banco de dados: ela adiciona, marca como feita, corrige e apaga. Essas quatro operações têm um nome famoso, CRUD (Create, Read, Update, Delete: criar, ler, atualizar, apagar), e estão por trás de quase todo sistema: loja, blog, agenda, rede social. Quem faz um CRUD bem feito sabe fazer os outros.

Você vai aprender
  • Criar o banco SQLite e a tabela a partir do PHP, com PDO.
  • R: ler as linhas com query + fetchAll e mostrar numa lista.
  • C: inserir com prepare + execute e voltar para a lista (Post/Redirect/Get).
  • U: marcar como feita e editar o título com UPDATE … WHERE id = ?.
  • D: excluir com DELETE … WHERE id = ?.
  • Ver uma injeção de SQL apagar a tabela inteira, e o prepared statement impedir o mesmo ataque.

Antes de começar

O que usamosAula
Tabelas, linhas, SELECT, INSERT, UPDATE, DELETEBanco de Dados: o Básico
new PDO('sqlite:…'), query, fetchAllConectar com PDO
O CRUD em PDOCRUD com PDO
prepare + execute, o perigo da concatenaçãoPrepared Statements e SQL Injection
Post/Redirect/Get, htmlspecialcharsProjeto Formulário de Contato

O projeto tem dois arquivos: banco.php (a conexão) e index.php (a página, que faz tudo). Uma página só, com um campo escondido acao em cada formulário dizendo o que fazer, mantém o projeto pequeno e fácil de seguir.

$ mkdir tarefas
$ cd tarefas
$ php -S localhost:8000
[Fri Oct 16 21:04:16 2026] PHP 8.5.8 Development Server (http://localhost:8000) started

O sqlite3 que aparece em alguns comandos é o programa de terminal do SQLite, para espiar o banco por fora do PHP (no Debian/Ubuntu: sudo apt install sqlite3). Ele é opcional: tudo funciona sem ele.


As etapas

  1. Etapa 1 · O banco e a tabela

    O banco.php abre o arquivo do banco, cria a tabela tarefas se ela não existir e, só na primeira vez, põe três tarefas de exemplo para a lista não começar vazia.

    <?php
    // Conexão com o banco SQLite e criação da tabela
    date_default_timezone_set('America/Sao_Paulo');
    
    $arquivo = __DIR__ . '/tarefas.sqlite';
    $primeiraVez = !file_exists($arquivo);
    
    $pdo = new PDO('sqlite:' . $arquivo);
    $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
    
    $pdo->exec('CREATE TABLE IF NOT EXISTS tarefas (
        id        INTEGER PRIMARY KEY AUTOINCREMENT,
        titulo    TEXT    NOT NULL,
        feita     INTEGER NOT NULL DEFAULT 0,
        criada_em TEXT    NOT NULL
    )');
    
    // Só para testar: quando o banco acaba de ser criado, três tarefas de exemplo.
    if ($primeiraVez) {
        $exemplo = $pdo->prepare('INSERT INTO tarefas (titulo, feita, criada_em) VALUES (?, ?, ?)');
        $exemplo->execute(['Comprar pão', 0, date('Y-m-d H:i:s')]);
        $exemplo->execute(['Pagar a conta de luz', 0, date('Y-m-d H:i:s')]);
        $exemplo->execute(['Estudar PHP', 1, date('Y-m-d H:i:s')]);
    }
    
    TrechoO que faz
    $primeiraVez = !file_exists($arquivo)Antes de conectar, olha se o arquivo do banco já existe. O new PDO cria o arquivo, então a pergunta tem que vir antes.
    new PDO('sqlite:' . $arquivo)Conecta ao banco. Desde o PHP 8, o PDO já avisa os erros com exceções (PDOException), o que é o que queremos.
    PDO::FETCH_ASSOCCada linha lida vira um array associativo: $t['titulo'].
    id INTEGER PRIMARY KEY AUTOINCREMENTUm número único para cada tarefa, que o banco escolhe sozinho (1, 2, 3…) e nunca reutiliza.
    feita INTEGER NOT NULL DEFAULT 0O SQLite não tem tipo "booleano": usamos 0 (pendente) e 1 (feita). Se ninguém disser nada, é 0.
    criada_em TEXTA data como texto 2026-10-16 21:04:16: nesse formato, ordenar o texto é o mesmo que ordenar a data.
    $exemplo->execute([…]) três vezesUm prepare pode ser executado várias vezes com valores diferentes.

    Para ver o banco funcionando antes de existir uma página, um script de teste no terminal:

    <?php
    require __DIR__ . '/banco.php';
    
    $tarefas = $pdo->query('SELECT id, titulo, feita FROM tarefas')->fetchAll();
    
    foreach ($tarefas as $t) {
        echo $t['id'], '. ', $t['titulo'], $t['feita'] ? ' (feita)' : '', "\n";
    }
    
    $ php teste.php
    1. Comprar pão
    2. Pagar a conta de luz
    3. Estudar PHP (feita)
    $ php teste.php
    1. Comprar pão
    2. Pagar a conta de luz
    3. Estudar PHP (feita)          « as de exemplo não se repetem: o banco já existia
    $ sqlite3 tarefas.sqlite .schema
    CREATE TABLE tarefas (
        id        INTEGER PRIMARY KEY AUTOINCREMENT,
        titulo    TEXT    NOT NULL,
        feita     INTEGER NOT NULL DEFAULT 0,
        criada_em TEXT    NOT NULL
    );
    CREATE TABLE sqlite_sequence(name,seq);

    A tabela sqlite_sequence foi criada pelo próprio SQLite: é onde ele anota o último id usado, por causa do AUTOINCREMENT.

    Missão: no fim do teste.php, mostre quantas tarefas existem: $pdo->query('SELECT COUNT(*) FROM tarefas')->fetchColumn() devolve o número direto (fetchColumn = "pegue a primeira coluna"). A saída deve terminar com Total: 3 tarefa(s).

    <?php
    require __DIR__ . '/banco.php';
    
    $tarefas = $pdo->query('SELECT id, titulo, feita FROM tarefas')->fetchAll();
    
    foreach ($tarefas as $t) {
        echo $t['id'], '. ', $t['titulo'], $t['feita'] ? ' (feita)' : '', "\n";
    }
    
    <?php
    // Conexão com o banco SQLite e criação da tabela
    date_default_timezone_set('America/Sao_Paulo');
    
    $arquivo = __DIR__ . '/tarefas.sqlite';
    $primeiraVez = !file_exists($arquivo);
    
    $pdo = new PDO('sqlite:' . $arquivo);
    $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
    
    $pdo->exec('CREATE TABLE IF NOT EXISTS tarefas (
        id        INTEGER PRIMARY KEY AUTOINCREMENT,
        titulo    TEXT    NOT NULL,
        feita     INTEGER NOT NULL DEFAULT 0,
        criada_em TEXT    NOT NULL
    )');
    
    // Só para testar: quando o banco acaba de ser criado, três tarefas de exemplo.
    if ($primeiraVez) {
        $exemplo = $pdo->prepare('INSERT INTO tarefas (titulo, feita, criada_em) VALUES (?, ?, ?)');
        $exemplo->execute(['Comprar pão', 0, date('Y-m-d H:i:s')]);
        $exemplo->execute(['Pagar a conta de luz', 0, date('Y-m-d H:i:s')]);
        $exemplo->execute(['Estudar PHP', 1, date('Y-m-d H:i:s')]);
    }
    
    <?php
    require __DIR__ . '/banco.php';
    
    $tarefas = $pdo->query('SELECT id, titulo, feita FROM tarefas')->fetchAll();
    
    foreach ($tarefas as $t) {
        echo $t['id'], '. ', $t['titulo'], $t['feita'] ? ' (feita)' : '', "\n";
    }
    
    $total = $pdo->query('SELECT COUNT(*) FROM tarefas')->fetchColumn();
    echo "Total: $total tarefa(s)\n";
    
  2. Etapa 2 · R de Read: mostrar a lista

    A primeira versão do index.php só lê e mostra:

    <?php
    require __DIR__ . '/banco.php';
    
    function e(string $texto): string
    {
        return htmlspecialchars($texto);
    }
    
    $tarefas = $pdo->query('SELECT id, titulo, feita FROM tarefas ORDER BY feita, id')->fetchAll();
    $pendentes = count(array_filter($tarefas, fn($t) => $t['feita'] == 0));
    ?>
    <!DOCTYPE html>
    <html lang="pt-BR">
    <head>
    <meta charset="utf-8">
    <title>Tarefas</title>
    </head>
    <body>
    <h1>Tarefas da Ana</h1>
    <p><?= $pendentes ?> pendente(s) de <?= count($tarefas) ?> tarefa(s).</p>
    <ul>
    <?php foreach ($tarefas as $t): ?>
      <li>
        <span class="tarefa"><?= $t['feita'] ? '<s>' . e($t['titulo']) . '</s>' : e($t['titulo']) ?></span>
      </li>
    <?php endforeach; ?>
    </ul>
    </body>
    </html>
    
    $pdo->query('SELECT … ORDER BY feita, id')
    Roda um SQL fixo (sem nada vindo do usuário, por isso pode ser query direto). ORDER BY feita, id: primeiro as pendentes (0), depois as feitas (1); dentro de cada grupo, na ordem de criação.
    ->fetchAll()
    Busca todas as linhas de uma vez, num array de arrays.
    array_filter($tarefas, fn($t) => $t['feita'] == 0)
    Separa as pendentes, para contar (veja Arrow Functions). No PHP 8.1+, o SQLite devolve feita como número (int(0) ou int(1)); versões antigas devolviam texto ("0"). O == funciona nos dois casos.
    '<s>' . e($t['titulo']) . '</s>'
    Tarefa feita aparece riscada. Repare: o <s> é nosso e não é escapado; o título, que veio do usuário, é.
    $ curl -s localhost:8000/ | grep -E 'pendente|class="tarefa"'
    <p>2 pendente(s) de 3 tarefa(s).</p>
        <span class="tarefa">Comprar pão</span>
        <span class="tarefa">Pagar a conta de luz</span>
        <span class="tarefa"><s>Estudar PHP</s></span>

    Missão: faça a página aceitar ?ver=pendentes no endereço: nesse caso, o SQL ganha WHERE feita = 0 e só as pendentes aparecem. Use um if com ($_GET['ver'] ?? '') === 'pendentes' e duas consultas fixas. (Não cole o valor do $_GET no SQL: a etapa 7 mostra por quê.)

    <?php
    require __DIR__ . '/banco.php';
    
    function e(string $texto): string
    {
        return htmlspecialchars($texto);
    }
    
    $tarefas = $pdo->query('SELECT id, titulo, feita FROM tarefas ORDER BY feita, id')->fetchAll();
    $pendentes = count(array_filter($tarefas, fn($t) => $t['feita'] == 0));
    ?>
    <!DOCTYPE html>
    <html lang="pt-BR">
    <head>
    <meta charset="utf-8">
    <title>Tarefas</title>
    </head>
    <body>
    <h1>Tarefas da Ana</h1>
    <p><?= $pendentes ?> pendente(s) de <?= count($tarefas) ?> tarefa(s).</p>
    <ul>
    <?php foreach ($tarefas as $t): ?>
      <li>
        <span class="tarefa"><?= $t['feita'] ? '<s>' . e($t['titulo']) . '</s>' : e($t['titulo']) ?></span>
      </li>
    <?php endforeach; ?>
    </ul>
    </body>
    </html>
    
    <?php
    // Conexão com o banco SQLite e criação da tabela
    date_default_timezone_set('America/Sao_Paulo');
    
    $arquivo = __DIR__ . '/tarefas.sqlite';
    $primeiraVez = !file_exists($arquivo);
    
    $pdo = new PDO('sqlite:' . $arquivo);
    $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
    
    $pdo->exec('CREATE TABLE IF NOT EXISTS tarefas (
        id        INTEGER PRIMARY KEY AUTOINCREMENT,
        titulo    TEXT    NOT NULL,
        feita     INTEGER NOT NULL DEFAULT 0,
        criada_em TEXT    NOT NULL
    )');
    
    // Só para testar: quando o banco acaba de ser criado, três tarefas de exemplo.
    if ($primeiraVez) {
        $exemplo = $pdo->prepare('INSERT INTO tarefas (titulo, feita, criada_em) VALUES (?, ?, ?)');
        $exemplo->execute(['Comprar pão', 0, date('Y-m-d H:i:s')]);
        $exemplo->execute(['Pagar a conta de luz', 0, date('Y-m-d H:i:s')]);
        $exemplo->execute(['Estudar PHP', 1, date('Y-m-d H:i:s')]);
    }
    
    <?php
    require __DIR__ . '/banco.php';
    
    function e(string $texto): string
    {
        return htmlspecialchars($texto);
    }
    
    if (($_GET['ver'] ?? '') === 'pendentes') {
        $tarefas = $pdo->query('SELECT id, titulo, feita FROM tarefas WHERE feita = 0 ORDER BY id')->fetchAll();
    } else {
        $tarefas = $pdo->query('SELECT id, titulo, feita FROM tarefas ORDER BY feita, id')->fetchAll();
    }
    $pendentes = count(array_filter($tarefas, fn($t) => $t['feita'] == 0));
    ?>
    <!DOCTYPE html>
    <html lang="pt-BR">
    <head>
    <meta charset="utf-8">
    <title>Tarefas</title>
    </head>
    <body>
    <h1>Tarefas da Ana</h1>
    <p><?= $pendentes ?> pendente(s) de <?= count($tarefas) ?> tarefa(s).</p>
    <ul>
    <?php foreach ($tarefas as $t): ?>
      <li>
        <span class="tarefa"><?= $t['feita'] ? '<s>' . e($t['titulo']) . '</s>' : e($t['titulo']) ?></span>
      </li>
    <?php endforeach; ?>
    </ul>
    </body>
    </html>
    
  3. Etapa 3 · C de Create: adicionar tarefas

    Um formulário no topo da lista. O campo escondido acao diz ao PHP o que o formulário quer:

    <form method="post" action="index.php">
      <input type="hidden" name="acao" value="criar">
      <input name="titulo" value="<?= e($novo) ?>" placeholder="Nova tarefa" maxlength="100">
      <button>Adicionar</button>
    </form>

    E, no topo do arquivo, o tratamento do envio:

    function voltar(): never
    {
        header('Location: index.php', true, 303);
        exit;
    }
    
    $erro = '';
    $novo = '';
    
    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        $acao = $_POST['acao'] ?? '';
    
        if ($acao === 'criar') {
            $novo = trim($_POST['titulo'] ?? '');
            if ($novo === '' || mb_strlen($novo) > 100) {
                $erro = 'Escreva a tarefa (até 100 caracteres).';
            } else {
                $sql = $pdo->prepare('INSERT INTO tarefas (titulo, criada_em) VALUES (?, ?)');
                $sql->execute([$novo, date('Y-m-d H:i:s')]);
                voltar();
            }
        }
    }
    $pdo->prepare('INSERT … VALUES (?, ?)')
    Prepara o SQL com dois "buracos". O texto do SQL é sempre o mesmo, não importa o que a Ana digite.
    $sql->execute([$novo, date(…)])
    Envia os valores separados, na ordem dos ?. O banco os trata como dados, nunca como comandos.
    feita
    Não aparece no INSERT: vale o DEFAULT 0 da tabela.
    voltar()
    Post/Redirect/Get: depois de gravar, volta para a lista com um GET. O F5 não duplica a tarefa.
    $ curl -si -d 'acao=criar&titulo=Ligar para a Carla' localhost:8000/ | grep -E 'HTTP|Location'
    HTTP/1.1 303 See Other
    Location: index.php
    $ curl -s localhost:8000/ | grep -E 'pendente|class="tarefa"'
    <p>3 pendente(s) de 4 tarefa(s).</p>
        <span class="tarefa">Comprar pão</span>
        <span class="tarefa">Pagar a conta de luz</span>
        <span class="tarefa">Ligar para a Carla</span>
        <span class="tarefa"><s>Estudar PHP</s></span>
    $ curl -s -d 'acao=criar&titulo=   ' localhost:8000/ | grep erro
      <p class="erro">Escreva a tarefa (até 100 caracteres).</p>
    $ curl -sL -d 'acao=criar&titulo=<b>Urgente</b>' localhost:8000/ | grep 'class="tarefa"'
        <span class="tarefa">Comprar pão</span>
        <span class="tarefa">Pagar a conta de luz</span>
        <span class="tarefa">Ligar para a Carla</span>
        <span class="tarefa">&lt;b&gt;Urgente&lt;/b&gt;</span>
        <span class="tarefa"><s>Estudar PHP</s></span>
    $ sqlite3 tarefas.sqlite 'SELECT id, titulo FROM tarefas'
    1|Comprar pão
    2|Pagar a conta de luz
    3|Estudar PHP
    4|Ligar para a Carla
    5|<b>Urgente</b>

    Repare nos dois últimos comandos: o banco guarda exatamente o que foi digitado (<b>Urgente</b>), e a página escapa na hora de mostrar. Essa é a divisão certa: o prepared statement protege o banco; o htmlspecialchars protege a página. Uma proteção não substitui a outra.

    Missão: não deixe cadastrar uma tarefa repetida. Antes do INSERT, conte quantas tarefas têm o mesmo título (SELECT COUNT(*) FROM tarefas WHERE titulo = ?, com prepare/execute/fetchColumn); se for mais que zero, o erro é Essa tarefa já está na lista. A caixa tenta cadastrar "Comprar pão" de novo.

    <?php
    require __DIR__ . '/banco.php';
    
    function e(string $texto): string
    {
        return htmlspecialchars($texto);
    }
    
    function voltar(): never
    {
        header('Location: index.php', true, 303);
        exit;
    }
    
    $erro = '';
    $novo = '';
    
    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        $acao = $_POST['acao'] ?? '';
    
        if ($acao === 'criar') {
            $novo = trim($_POST['titulo'] ?? '');
            if ($novo === '' || mb_strlen($novo) > 100) {
                $erro = 'Escreva a tarefa (até 100 caracteres).';
            } else {
                $sql = $pdo->prepare('INSERT INTO tarefas (titulo, criada_em) VALUES (?, ?)');
                $sql->execute([$novo, date('Y-m-d H:i:s')]);
                voltar();
            }
        }
    }
    
    $tarefas = $pdo->query('SELECT id, titulo, feita FROM tarefas ORDER BY feita, id')->fetchAll();
    $pendentes = count(array_filter($tarefas, fn($t) => $t['feita'] == 0));
    ?>
    <!DOCTYPE html>
    <html lang="pt-BR">
    <head>
    <meta charset="utf-8">
    <title>Tarefas</title>
    </head>
    <body>
    <h1>Tarefas da Ana</h1>
    <p><?= $pendentes ?> pendente(s) de <?= count($tarefas) ?> tarefa(s).</p>
    <?php if ($erro): ?>
      <p class="erro"><?= e($erro) ?></p>
    <?php endif; ?>
    
    <form method="post" action="index.php">
      <input type="hidden" name="acao" value="criar">
      <input name="titulo" value="<?= e($novo) ?>" placeholder="Nova tarefa" maxlength="100">
      <button>Adicionar</button>
    </form>
    
    <ul>
    <?php foreach ($tarefas as $t): ?>
      <li>
        <span class="tarefa"><?= $t['feita'] ? '<s>' . e($t['titulo']) . '</s>' : e($t['titulo']) ?></span>
      </li>
    <?php endforeach; ?>
    </ul>
    </body>
    </html>
    
    <?php
    // Conexão com o banco SQLite e criação da tabela
    date_default_timezone_set('America/Sao_Paulo');
    
    $arquivo = __DIR__ . '/tarefas.sqlite';
    $primeiraVez = !file_exists($arquivo);
    
    $pdo = new PDO('sqlite:' . $arquivo);
    $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
    
    $pdo->exec('CREATE TABLE IF NOT EXISTS tarefas (
        id        INTEGER PRIMARY KEY AUTOINCREMENT,
        titulo    TEXT    NOT NULL,
        feita     INTEGER NOT NULL DEFAULT 0,
        criada_em TEXT    NOT NULL
    )');
    
    // Só para testar: quando o banco acaba de ser criado, três tarefas de exemplo.
    if ($primeiraVez) {
        $exemplo = $pdo->prepare('INSERT INTO tarefas (titulo, feita, criada_em) VALUES (?, ?, ?)');
        $exemplo->execute(['Comprar pão', 0, date('Y-m-d H:i:s')]);
        $exemplo->execute(['Pagar a conta de luz', 0, date('Y-m-d H:i:s')]);
        $exemplo->execute(['Estudar PHP', 1, date('Y-m-d H:i:s')]);
    }
    
    <?php
    require __DIR__ . '/banco.php';
    
    function e(string $texto): string
    {
        return htmlspecialchars($texto);
    }
    
    function voltar(): never
    {
        header('Location: index.php', true, 303);
        exit;
    }
    
    $erro = '';
    $novo = '';
    
    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        $acao = $_POST['acao'] ?? '';
    
        if ($acao === 'criar') {
            $novo = trim($_POST['titulo'] ?? '');
            if ($novo === '' || mb_strlen($novo) > 100) {
                $erro = 'Escreva a tarefa (até 100 caracteres).';
            } else {
                $busca = $pdo->prepare('SELECT COUNT(*) FROM tarefas WHERE titulo = ?');
                $busca->execute([$novo]);
                if ($busca->fetchColumn() > 0) {
                    $erro = 'Essa tarefa já está na lista.';
                }
            }
            if (!$erro) {
                $sql = $pdo->prepare('INSERT INTO tarefas (titulo, criada_em) VALUES (?, ?)');
                $sql->execute([$novo, date('Y-m-d H:i:s')]);
                voltar();
            }
        }
    }
    
    $tarefas = $pdo->query('SELECT id, titulo, feita FROM tarefas ORDER BY feita, id')->fetchAll();
    $pendentes = count(array_filter($tarefas, fn($t) => $t['feita'] == 0));
    ?>
    <!DOCTYPE html>
    <html lang="pt-BR">
    <head>
    <meta charset="utf-8">
    <title>Tarefas</title>
    </head>
    <body>
    <h1>Tarefas da Ana</h1>
    <p><?= $pendentes ?> pendente(s) de <?= count($tarefas) ?> tarefa(s).</p>
    <?php if ($erro): ?>
      <p class="erro"><?= e($erro) ?></p>
    <?php endif; ?>
    
    <form method="post" action="index.php">
      <input type="hidden" name="acao" value="criar">
      <input name="titulo" value="<?= e($novo) ?>" placeholder="Nova tarefa" maxlength="100">
      <button>Adicionar</button>
    </form>
    
    <ul>
    <?php foreach ($tarefas as $t): ?>
      <li>
        <span class="tarefa"><?= $t['feita'] ? '<s>' . e($t['titulo']) . '</s>' : e($t['titulo']) ?></span>
      </li>
    <?php endforeach; ?>
    </ul>
    </body>
    </html>
    
  4. Etapa 4 · U de Update: marcar como feita

    Cada tarefa ganha um botão. Ele é um pequeno formulário POST que leva a ação e o id da tarefa:

    <form method="post" action="index.php" style="display:inline">
      <input type="hidden" name="acao" value="alternar">
      <input type="hidden" name="id" value="<?= $t['id'] ?>">
      <button><?= $t['feita'] ? 'Desfazer' : 'Feito!' ?></button>
    </form>
        $id = (int) ($_POST['id'] ?? 0);
    
        // ...
    
        if ($acao === 'alternar') {
            $sql = $pdo->prepare('UPDATE tarefas SET feita = 1 - feita WHERE id = ?');
            $sql->execute([$id]);
            voltar();
        }
    TrechoO que faz
    Botão dentro de <form method="post">Mudar dados é sempre POST, nunca um link (GET): links são seguidos por robôs, pré-carregados por navegadores e podem ser disparados por outros sites (veja a etapa 4 do projeto de login).
    (int) ($_POST['id'] ?? 0)O id chega como texto; o (int) garante um número inteiro ("abc" vira 0, que não é id de ninguém).
    SET feita = 1 - feitaUm truque de conta: se era 0, vira 1; se era 1, vira 0. O mesmo botão marca e desmarca.
    WHERE id = ?Só a tarefa daquele id. Um UPDATE sem WHERE muda todas as linhas da tabela.
    $ curl -s localhost:8000/ | grep -A4 'value="alternar"' | head -5
          <input type="hidden" name="acao" value="alternar">
          <input type="hidden" name="id" value="1">
          <button>Feito!</button>
        </form>
        <span class="tarefa">Comprar pão</span>
    $ curl -si -d 'acao=alternar&id=1' localhost:8000/ | grep -E 'HTTP|Location'
    HTTP/1.1 303 See Other
    Location: index.php
    $ curl -s localhost:8000/ | grep -E 'pendente|class="tarefa"'
    <p>1 pendente(s) de 3 tarefa(s).</p>
        <span class="tarefa">Pagar a conta de luz</span>
        <span class="tarefa"><s>Comprar pão</s></span>         « feita: foi para baixo, riscada
        <span class="tarefa"><s>Estudar PHP</s></span>
    $ curl -sL -d 'acao=alternar&id=1' localhost:8000/ | grep -E 'pendente|class="tarefa"'
    <p>2 pendente(s) de 3 tarefa(s).</p>
        <span class="tarefa">Comprar pão</span>              « desfeita: voltou
        <span class="tarefa">Pagar a conta de luz</span>
        <span class="tarefa"><s>Estudar PHP</s></span>

    Missão: um botão Marcar todas como feitas (formulário com acao = todas) e a ação correspondente: $pdo->exec('UPDATE tarefas SET feita = 1'); seguido de voltar();. Aqui o UPDATE sem WHERE é de propósito! A caixa já envia acao=todas.

    <?php
    require __DIR__ . '/banco.php';
    
    function e(string $texto): string
    {
        return htmlspecialchars($texto);
    }
    
    function voltar(): never
    {
        header('Location: index.php', true, 303);
        exit;
    }
    
    $erro = '';
    $novo = '';
    
    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        $acao = $_POST['acao'] ?? '';
        $id = (int) ($_POST['id'] ?? 0);
    
        if ($acao === 'criar') {
            $novo = trim($_POST['titulo'] ?? '');
            if ($novo === '' || mb_strlen($novo) > 100) {
                $erro = 'Escreva a tarefa (até 100 caracteres).';
            } else {
                $sql = $pdo->prepare('INSERT INTO tarefas (titulo, criada_em) VALUES (?, ?)');
                $sql->execute([$novo, date('Y-m-d H:i:s')]);
                voltar();
            }
        }
    
        if ($acao === 'alternar') {
            $sql = $pdo->prepare('UPDATE tarefas SET feita = 1 - feita WHERE id = ?');
            $sql->execute([$id]);
            voltar();
        }
    }
    
    $tarefas = $pdo->query('SELECT id, titulo, feita FROM tarefas ORDER BY feita, id')->fetchAll();
    $pendentes = count(array_filter($tarefas, fn($t) => $t['feita'] == 0));
    ?>
    <!DOCTYPE html>
    <html lang="pt-BR">
    <head>
    <meta charset="utf-8">
    <title>Tarefas</title>
    </head>
    <body>
    <h1>Tarefas da Ana</h1>
    <p><?= $pendentes ?> pendente(s) de <?= count($tarefas) ?> tarefa(s).</p>
    <?php if ($erro): ?>
      <p class="erro"><?= e($erro) ?></p>
    <?php endif; ?>
    
    <form method="post" action="index.php">
      <input type="hidden" name="acao" value="criar">
      <input name="titulo" value="<?= e($novo) ?>" placeholder="Nova tarefa" maxlength="100">
      <button>Adicionar</button>
    </form>
    
    <ul>
    <?php foreach ($tarefas as $t): ?>
      <li>
        <form method="post" action="index.php" style="display:inline">
          <input type="hidden" name="acao" value="alternar">
          <input type="hidden" name="id" value="<?= $t['id'] ?>">
          <button><?= $t['feita'] ? 'Desfazer' : 'Feito!' ?></button>
        </form>
        <span class="tarefa"><?= $t['feita'] ? '<s>' . e($t['titulo']) . '</s>' : e($t['titulo']) ?></span>
      </li>
    <?php endforeach; ?>
    </ul>
    </body>
    </html>
    
    <?php
    // Conexão com o banco SQLite e criação da tabela
    date_default_timezone_set('America/Sao_Paulo');
    
    $arquivo = __DIR__ . '/tarefas.sqlite';
    $primeiraVez = !file_exists($arquivo);
    
    $pdo = new PDO('sqlite:' . $arquivo);
    $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
    
    $pdo->exec('CREATE TABLE IF NOT EXISTS tarefas (
        id        INTEGER PRIMARY KEY AUTOINCREMENT,
        titulo    TEXT    NOT NULL,
        feita     INTEGER NOT NULL DEFAULT 0,
        criada_em TEXT    NOT NULL
    )');
    
    // Só para testar: quando o banco acaba de ser criado, três tarefas de exemplo.
    if ($primeiraVez) {
        $exemplo = $pdo->prepare('INSERT INTO tarefas (titulo, feita, criada_em) VALUES (?, ?, ?)');
        $exemplo->execute(['Comprar pão', 0, date('Y-m-d H:i:s')]);
        $exemplo->execute(['Pagar a conta de luz', 0, date('Y-m-d H:i:s')]);
        $exemplo->execute(['Estudar PHP', 1, date('Y-m-d H:i:s')]);
    }
    
    <?php
    require __DIR__ . '/banco.php';
    
    function e(string $texto): string
    {
        return htmlspecialchars($texto);
    }
    
    function voltar(): never
    {
        header('Location: index.php', true, 303);
        exit;
    }
    
    $erro = '';
    $novo = '';
    
    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        $acao = $_POST['acao'] ?? '';
        $id = (int) ($_POST['id'] ?? 0);
    
        if ($acao === 'criar') {
            $novo = trim($_POST['titulo'] ?? '');
            if ($novo === '' || mb_strlen($novo) > 100) {
                $erro = 'Escreva a tarefa (até 100 caracteres).';
            } else {
                $sql = $pdo->prepare('INSERT INTO tarefas (titulo, criada_em) VALUES (?, ?)');
                $sql->execute([$novo, date('Y-m-d H:i:s')]);
                voltar();
            }
        }
    
        if ($acao === 'todas') {
            $pdo->exec('UPDATE tarefas SET feita = 1');
            voltar();
        }
    
        if ($acao === 'alternar') {
            $sql = $pdo->prepare('UPDATE tarefas SET feita = 1 - feita WHERE id = ?');
            $sql->execute([$id]);
            voltar();
        }
    }
    
    $tarefas = $pdo->query('SELECT id, titulo, feita FROM tarefas ORDER BY feita, id')->fetchAll();
    $pendentes = count(array_filter($tarefas, fn($t) => $t['feita'] == 0));
    ?>
    <!DOCTYPE html>
    <html lang="pt-BR">
    <head>
    <meta charset="utf-8">
    <title>Tarefas</title>
    </head>
    <body>
    <h1>Tarefas da Ana</h1>
    <p><?= $pendentes ?> pendente(s) de <?= count($tarefas) ?> tarefa(s).</p>
    <?php if ($erro): ?>
      <p class="erro"><?= e($erro) ?></p>
    <?php endif; ?>
    
    <form method="post" action="index.php">
      <input type="hidden" name="acao" value="criar">
      <input name="titulo" value="<?= e($novo) ?>" placeholder="Nova tarefa" maxlength="100">
      <button>Adicionar</button>
    </form>
    
    <form method="post" action="index.php">
      <input type="hidden" name="acao" value="todas">
      <button>Marcar todas como feitas</button>
    </form>
    
    <ul>
    <?php foreach ($tarefas as $t): ?>
      <li>
        <form method="post" action="index.php" style="display:inline">
          <input type="hidden" name="acao" value="alternar">
          <input type="hidden" name="id" value="<?= $t['id'] ?>">
          <button><?= $t['feita'] ? 'Desfazer' : 'Feito!' ?></button>
        </form>
        <span class="tarefa"><?= $t['feita'] ? '<s>' . e($t['titulo']) . '</s>' : e($t['titulo']) ?></span>
      </li>
    <?php endforeach; ?>
    </ul>
    </body>
    </html>
    
  5. Etapa 5 · U de novo: editar o título

    Para corrigir um título, cada tarefa ganha um link editar que abre index.php?editar=2. Nessa visita, a linha da tarefa 2 aparece como um formulário já preenchido. Abrir o formulário é só ler (GET); salvar é mudar (POST).

    // Tarefa aberta para edição: index.php?editar=3
    if ($editando === null && isset($_GET['editar'])) {
        $sql = $pdo->prepare('SELECT id, titulo FROM tarefas WHERE id = ?');
        $sql->execute([(int) $_GET['editar']]);
        $editando = $sql->fetch() ?: null;
    }
        if ($acao === 'editar') {
            $titulo = trim($_POST['titulo'] ?? '');
            if ($titulo === '' || mb_strlen($titulo) > 100) {
                $erro = 'Escreva a tarefa (até 100 caracteres).';
                $editando = ['id' => $id, 'titulo' => $titulo];
            } else {
                $sql = $pdo->prepare('UPDATE tarefas SET titulo = ? WHERE id = ?');
                $sql->execute([$titulo, $id]);
                voltar();
            }
        }

    Na lista, um if escolhe entre mostrar a linha normal ou o formulário de edição:

    <?php if ($editando && $editando['id'] == $t['id']): ?>
        <form method="post" action="index.php">
          <input type="hidden" name="acao" value="editar">
          <input type="hidden" name="id" value="<?= $t['id'] ?>">
          <input name="titulo" value="<?= e($editando['titulo']) ?>" maxlength="100">
          <button>Salvar</button> <a href="index.php">Cancelar</a>
        </form>
    <?php else: ?>
        ... a linha normal, agora com o link:
        <a href="index.php?editar=<?= $t['id'] ?>">editar</a>
    <?php endif; ?>
    $sql->fetch() ?: null
    fetch devolve a linha ou false se o id não existe. O ?: troca o false por null: "nenhuma tarefa em edição".
    $editando = ['id' => $id, 'titulo' => $titulo]
    Se o título novo for inválido, o formulário de edição reaparece com o erro e com o que foi digitado, em vez de sumir.
    UPDATE … SET titulo = ? WHERE id = ?
    Dois ?, dois valores no execute, na mesma ordem.
    $ curl -s 'localhost:8000/?editar=2' | grep -B1 -A4 'value="editar"'
        <form method="post" action="index.php">
          <input type="hidden" name="acao" value="editar">
          <input type="hidden" name="id" value="2">
          <input name="titulo" value="Pagar a conta de luz" maxlength="100">
          <button>Salvar</button> <a href="index.php">Cancelar</a>
        </form>
    $ curl -s 'localhost:8000/?editar=99' | grep -c 'value="editar"'
    0                                              « id inexistente: nenhum formulário de edição
    $ curl -s -d 'acao=editar&id=2&titulo=' localhost:8000/ | grep -E 'erro|name="titulo"'
      <p class="erro">Escreva a tarefa (até 100 caracteres).</p>
      <input name="titulo" value="" placeholder="Nova tarefa" maxlength="100">
          <input name="titulo" value="" maxlength="100">      « o formulário de edição continua aberto
    $ curl -sL -d 'acao=editar&id=2&titulo=Pagar a luz e a água' localhost:8000/ | grep 'class="tarefa"'
        <span class="tarefa">Comprar pão</span>
        <span class="tarefa">Pagar a luz e a água</span>
        <span class="tarefa"><s>Estudar PHP</s></span>

    Missão: com ?editar=99 (uma tarefa que não existe), a página hoje fica calada. Depois do fetch, se $editando ficou null, ponha em $erro a mensagem Tarefa não encontrada.

    <?php
    require __DIR__ . '/banco.php';
    
    function e(string $texto): string
    {
        return htmlspecialchars($texto);
    }
    
    function voltar(): never
    {
        header('Location: index.php', true, 303);
        exit;
    }
    
    $erro = '';
    $novo = '';
    $editando = null;
    
    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        $acao = $_POST['acao'] ?? '';
        $id = (int) ($_POST['id'] ?? 0);
    
        if ($acao === 'criar') {
            $novo = trim($_POST['titulo'] ?? '');
            if ($novo === '' || mb_strlen($novo) > 100) {
                $erro = 'Escreva a tarefa (até 100 caracteres).';
            } else {
                $sql = $pdo->prepare('INSERT INTO tarefas (titulo, criada_em) VALUES (?, ?)');
                $sql->execute([$novo, date('Y-m-d H:i:s')]);
                voltar();
            }
        }
    
        if ($acao === 'alternar') {
            $sql = $pdo->prepare('UPDATE tarefas SET feita = 1 - feita WHERE id = ?');
            $sql->execute([$id]);
            voltar();
        }
    
        if ($acao === 'editar') {
            $titulo = trim($_POST['titulo'] ?? '');
            if ($titulo === '' || mb_strlen($titulo) > 100) {
                $erro = 'Escreva a tarefa (até 100 caracteres).';
                $editando = ['id' => $id, 'titulo' => $titulo];
            } else {
                $sql = $pdo->prepare('UPDATE tarefas SET titulo = ? WHERE id = ?');
                $sql->execute([$titulo, $id]);
                voltar();
            }
        }
    }
    
    // Tarefa aberta para edição: index.php?editar=3
    if ($editando === null && isset($_GET['editar'])) {
        $sql = $pdo->prepare('SELECT id, titulo FROM tarefas WHERE id = ?');
        $sql->execute([(int) $_GET['editar']]);
        $editando = $sql->fetch() ?: null;
    }
    
    $tarefas = $pdo->query('SELECT id, titulo, feita FROM tarefas ORDER BY feita, id')->fetchAll();
    $pendentes = count(array_filter($tarefas, fn($t) => $t['feita'] == 0));
    ?>
    <!DOCTYPE html>
    <html lang="pt-BR">
    <head>
    <meta charset="utf-8">
    <title>Tarefas</title>
    </head>
    <body>
    <h1>Tarefas da Ana</h1>
    <p><?= $pendentes ?> pendente(s) de <?= count($tarefas) ?> tarefa(s).</p>
    <?php if ($erro): ?>
      <p class="erro"><?= e($erro) ?></p>
    <?php endif; ?>
    
    <form method="post" action="index.php">
      <input type="hidden" name="acao" value="criar">
      <input name="titulo" value="<?= e($novo) ?>" placeholder="Nova tarefa" maxlength="100">
      <button>Adicionar</button>
    </form>
    
    <ul>
    <?php foreach ($tarefas as $t): ?>
      <li>
    <?php if ($editando && $editando['id'] == $t['id']): ?>
        <form method="post" action="index.php">
          <input type="hidden" name="acao" value="editar">
          <input type="hidden" name="id" value="<?= $t['id'] ?>">
          <input name="titulo" value="<?= e($editando['titulo']) ?>" maxlength="100">
          <button>Salvar</button> <a href="index.php">Cancelar</a>
        </form>
    <?php else: ?>
        <form method="post" action="index.php" style="display:inline">
          <input type="hidden" name="acao" value="alternar">
          <input type="hidden" name="id" value="<?= $t['id'] ?>">
          <button><?= $t['feita'] ? 'Desfazer' : 'Feito!' ?></button>
        </form>
        <span class="tarefa"><?= $t['feita'] ? '<s>' . e($t['titulo']) . '</s>' : e($t['titulo']) ?></span>
        <a href="index.php?editar=<?= $t['id'] ?>">editar</a>
    <?php endif; ?>
      </li>
    <?php endforeach; ?>
    </ul>
    </body>
    </html>
    
    <?php
    // Conexão com o banco SQLite e criação da tabela
    date_default_timezone_set('America/Sao_Paulo');
    
    $arquivo = __DIR__ . '/tarefas.sqlite';
    $primeiraVez = !file_exists($arquivo);
    
    $pdo = new PDO('sqlite:' . $arquivo);
    $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
    
    $pdo->exec('CREATE TABLE IF NOT EXISTS tarefas (
        id        INTEGER PRIMARY KEY AUTOINCREMENT,
        titulo    TEXT    NOT NULL,
        feita     INTEGER NOT NULL DEFAULT 0,
        criada_em TEXT    NOT NULL
    )');
    
    // Só para testar: quando o banco acaba de ser criado, três tarefas de exemplo.
    if ($primeiraVez) {
        $exemplo = $pdo->prepare('INSERT INTO tarefas (titulo, feita, criada_em) VALUES (?, ?, ?)');
        $exemplo->execute(['Comprar pão', 0, date('Y-m-d H:i:s')]);
        $exemplo->execute(['Pagar a conta de luz', 0, date('Y-m-d H:i:s')]);
        $exemplo->execute(['Estudar PHP', 1, date('Y-m-d H:i:s')]);
    }
    
    <?php
    require __DIR__ . '/banco.php';
    
    function e(string $texto): string
    {
        return htmlspecialchars($texto);
    }
    
    function voltar(): never
    {
        header('Location: index.php', true, 303);
        exit;
    }
    
    $erro = '';
    $novo = '';
    $editando = null;
    
    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        $acao = $_POST['acao'] ?? '';
        $id = (int) ($_POST['id'] ?? 0);
    
        if ($acao === 'criar') {
            $novo = trim($_POST['titulo'] ?? '');
            if ($novo === '' || mb_strlen($novo) > 100) {
                $erro = 'Escreva a tarefa (até 100 caracteres).';
            } else {
                $sql = $pdo->prepare('INSERT INTO tarefas (titulo, criada_em) VALUES (?, ?)');
                $sql->execute([$novo, date('Y-m-d H:i:s')]);
                voltar();
            }
        }
    
        if ($acao === 'alternar') {
            $sql = $pdo->prepare('UPDATE tarefas SET feita = 1 - feita WHERE id = ?');
            $sql->execute([$id]);
            voltar();
        }
    
        if ($acao === 'editar') {
            $titulo = trim($_POST['titulo'] ?? '');
            if ($titulo === '' || mb_strlen($titulo) > 100) {
                $erro = 'Escreva a tarefa (até 100 caracteres).';
                $editando = ['id' => $id, 'titulo' => $titulo];
            } else {
                $sql = $pdo->prepare('UPDATE tarefas SET titulo = ? WHERE id = ?');
                $sql->execute([$titulo, $id]);
                voltar();
            }
        }
    }
    
    // Tarefa aberta para edição: index.php?editar=3
    if ($editando === null && isset($_GET['editar'])) {
        $sql = $pdo->prepare('SELECT id, titulo FROM tarefas WHERE id = ?');
        $sql->execute([(int) $_GET['editar']]);
        $editando = $sql->fetch() ?: null;
        if ($editando === null) {
            $erro = 'Tarefa não encontrada.';
        }
    }
    
    $tarefas = $pdo->query('SELECT id, titulo, feita FROM tarefas ORDER BY feita, id')->fetchAll();
    $pendentes = count(array_filter($tarefas, fn($t) => $t['feita'] == 0));
    ?>
    <!DOCTYPE html>
    <html lang="pt-BR">
    <head>
    <meta charset="utf-8">
    <title>Tarefas</title>
    </head>
    <body>
    <h1>Tarefas da Ana</h1>
    <p><?= $pendentes ?> pendente(s) de <?= count($tarefas) ?> tarefa(s).</p>
    <?php if ($erro): ?>
      <p class="erro"><?= e($erro) ?></p>
    <?php endif; ?>
    
    <form method="post" action="index.php">
      <input type="hidden" name="acao" value="criar">
      <input name="titulo" value="<?= e($novo) ?>" placeholder="Nova tarefa" maxlength="100">
      <button>Adicionar</button>
    </form>
    
    <ul>
    <?php foreach ($tarefas as $t): ?>
      <li>
    <?php if ($editando && $editando['id'] == $t['id']): ?>
        <form method="post" action="index.php">
          <input type="hidden" name="acao" value="editar">
          <input type="hidden" name="id" value="<?= $t['id'] ?>">
          <input name="titulo" value="<?= e($editando['titulo']) ?>" maxlength="100">
          <button>Salvar</button> <a href="index.php">Cancelar</a>
        </form>
    <?php else: ?>
        <form method="post" action="index.php" style="display:inline">
          <input type="hidden" name="acao" value="alternar">
          <input type="hidden" name="id" value="<?= $t['id'] ?>">
          <button><?= $t['feita'] ? 'Desfazer' : 'Feito!' ?></button>
        </form>
        <span class="tarefa"><?= $t['feita'] ? '<s>' . e($t['titulo']) . '</s>' : e($t['titulo']) ?></span>
        <a href="index.php?editar=<?= $t['id'] ?>">editar</a>
    <?php endif; ?>
      </li>
    <?php endforeach; ?>
    </ul>
    </body>
    </html>
    
  6. Etapa 6 · D de Delete: excluir

    O último botão de cada tarefa. Mesmo padrão do "Feito!": um formulário POST com a ação e o id.

    <form method="post" action="index.php" style="display:inline">
      <input type="hidden" name="acao" value="excluir">
      <input type="hidden" name="id" value="<?= $t['id'] ?>">
      <button>excluir</button>
    </form>
        if ($acao === 'excluir') {
            $sql = $pdo->prepare('DELETE FROM tarefas WHERE id = ?');
            $sql->execute([$id]);
            voltar();
        }
    $ curl -sL -d 'acao=excluir&id=3' localhost:8000/ | grep -E 'pendente|class="tarefa"'
    <p>2 pendente(s) de 2 tarefa(s).</p>
        <span class="tarefa">Comprar pão</span>
        <span class="tarefa">Pagar a conta de luz</span>
    $ sqlite3 tarefas.sqlite 'SELECT id, titulo, feita FROM tarefas'
    1|Comprar pão|0
    2|Pagar a conta de luz|0
    Cuidado com o WHERE

    DELETE FROM tarefas sem WHERE apaga a tabela inteira, sem perguntar e sem "desfazer". Sempre que escrever UPDATE ou DELETE, escreva o WHERE primeiro. Num sistema de verdade, muitas vezes nem se apaga: marca-se a linha como "excluída" (uma coluna excluida_em), para poder recuperar.

    Missão: um botão Limpar concluídas no fim da lista (acao = limpar) que apaga só as feitas: DELETE FROM tarefas WHERE feita = 1. A caixa já envia acao=limpar: sobram as 2 pendentes.

    <?php
    require __DIR__ . '/banco.php';
    
    function e(string $texto): string
    {
        return htmlspecialchars($texto);
    }
    
    function voltar(): never
    {
        header('Location: index.php', true, 303);
        exit;
    }
    
    $erro = '';
    $novo = '';
    $editando = null;
    
    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        $acao = $_POST['acao'] ?? '';
        $id = (int) ($_POST['id'] ?? 0);
    
        if ($acao === 'criar') {
            $novo = trim($_POST['titulo'] ?? '');
            if ($novo === '' || mb_strlen($novo) > 100) {
                $erro = 'Escreva a tarefa (até 100 caracteres).';
            } else {
                $sql = $pdo->prepare('INSERT INTO tarefas (titulo, criada_em) VALUES (?, ?)');
                $sql->execute([$novo, date('Y-m-d H:i:s')]);
                voltar();
            }
        }
    
        if ($acao === 'alternar') {
            $sql = $pdo->prepare('UPDATE tarefas SET feita = 1 - feita WHERE id = ?');
            $sql->execute([$id]);
            voltar();
        }
    
        if ($acao === 'editar') {
            $titulo = trim($_POST['titulo'] ?? '');
            if ($titulo === '' || mb_strlen($titulo) > 100) {
                $erro = 'Escreva a tarefa (até 100 caracteres).';
                $editando = ['id' => $id, 'titulo' => $titulo];
            } else {
                $sql = $pdo->prepare('UPDATE tarefas SET titulo = ? WHERE id = ?');
                $sql->execute([$titulo, $id]);
                voltar();
            }
        }
    
        if ($acao === 'excluir') {
            $sql = $pdo->prepare('DELETE FROM tarefas WHERE id = ?');
            $sql->execute([$id]);
            voltar();
        }
    }
    
    // Tarefa aberta para edição: index.php?editar=3
    if ($editando === null && isset($_GET['editar'])) {
        $sql = $pdo->prepare('SELECT id, titulo FROM tarefas WHERE id = ?');
        $sql->execute([(int) $_GET['editar']]);
        $editando = $sql->fetch() ?: null;
    }
    
    $tarefas = $pdo->query('SELECT id, titulo, feita FROM tarefas ORDER BY feita, id')->fetchAll();
    $pendentes = count(array_filter($tarefas, fn($t) => $t['feita'] == 0));
    ?>
    <!DOCTYPE html>
    <html lang="pt-BR">
    <head>
    <meta charset="utf-8">
    <title>Tarefas</title>
    </head>
    <body>
    <h1>Tarefas da Ana</h1>
    <p><?= $pendentes ?> pendente(s) de <?= count($tarefas) ?> tarefa(s).</p>
    <?php if ($erro): ?>
      <p class="erro"><?= e($erro) ?></p>
    <?php endif; ?>
    
    <form method="post" action="index.php">
      <input type="hidden" name="acao" value="criar">
      <input name="titulo" value="<?= e($novo) ?>" placeholder="Nova tarefa" maxlength="100">
      <button>Adicionar</button>
    </form>
    
    <ul>
    <?php foreach ($tarefas as $t): ?>
      <li>
    <?php if ($editando && $editando['id'] == $t['id']): ?>
        <form method="post" action="index.php">
          <input type="hidden" name="acao" value="editar">
          <input type="hidden" name="id" value="<?= $t['id'] ?>">
          <input name="titulo" value="<?= e($editando['titulo']) ?>" maxlength="100">
          <button>Salvar</button> <a href="index.php">Cancelar</a>
        </form>
    <?php else: ?>
        <form method="post" action="index.php" style="display:inline">
          <input type="hidden" name="acao" value="alternar">
          <input type="hidden" name="id" value="<?= $t['id'] ?>">
          <button><?= $t['feita'] ? 'Desfazer' : 'Feito!' ?></button>
        </form>
        <span class="tarefa"><?= $t['feita'] ? '<s>' . e($t['titulo']) . '</s>' : e($t['titulo']) ?></span>
        <a href="index.php?editar=<?= $t['id'] ?>">editar</a>
        <form method="post" action="index.php" style="display:inline">
          <input type="hidden" name="acao" value="excluir">
          <input type="hidden" name="id" value="<?= $t['id'] ?>">
          <button>excluir</button>
        </form>
    <?php endif; ?>
      </li>
    <?php endforeach; ?>
    </ul>
    </body>
    </html>
    
    <?php
    // Conexão com o banco SQLite e criação da tabela
    date_default_timezone_set('America/Sao_Paulo');
    
    $arquivo = __DIR__ . '/tarefas.sqlite';
    $primeiraVez = !file_exists($arquivo);
    
    $pdo = new PDO('sqlite:' . $arquivo);
    $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
    
    $pdo->exec('CREATE TABLE IF NOT EXISTS tarefas (
        id        INTEGER PRIMARY KEY AUTOINCREMENT,
        titulo    TEXT    NOT NULL,
        feita     INTEGER NOT NULL DEFAULT 0,
        criada_em TEXT    NOT NULL
    )');
    
    // Só para testar: quando o banco acaba de ser criado, três tarefas de exemplo.
    if ($primeiraVez) {
        $exemplo = $pdo->prepare('INSERT INTO tarefas (titulo, feita, criada_em) VALUES (?, ?, ?)');
        $exemplo->execute(['Comprar pão', 0, date('Y-m-d H:i:s')]);
        $exemplo->execute(['Pagar a conta de luz', 0, date('Y-m-d H:i:s')]);
        $exemplo->execute(['Estudar PHP', 1, date('Y-m-d H:i:s')]);
    }
    
    <?php
    require __DIR__ . '/banco.php';
    
    function e(string $texto): string
    {
        return htmlspecialchars($texto);
    }
    
    function voltar(): never
    {
        header('Location: index.php', true, 303);
        exit;
    }
    
    $erro = '';
    $novo = '';
    $editando = null;
    
    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        $acao = $_POST['acao'] ?? '';
        $id = (int) ($_POST['id'] ?? 0);
    
        if ($acao === 'criar') {
            $novo = trim($_POST['titulo'] ?? '');
            if ($novo === '' || mb_strlen($novo) > 100) {
                $erro = 'Escreva a tarefa (até 100 caracteres).';
            } else {
                $sql = $pdo->prepare('INSERT INTO tarefas (titulo, criada_em) VALUES (?, ?)');
                $sql->execute([$novo, date('Y-m-d H:i:s')]);
                voltar();
            }
        }
    
        if ($acao === 'alternar') {
            $sql = $pdo->prepare('UPDATE tarefas SET feita = 1 - feita WHERE id = ?');
            $sql->execute([$id]);
            voltar();
        }
    
        if ($acao === 'editar') {
            $titulo = trim($_POST['titulo'] ?? '');
            if ($titulo === '' || mb_strlen($titulo) > 100) {
                $erro = 'Escreva a tarefa (até 100 caracteres).';
                $editando = ['id' => $id, 'titulo' => $titulo];
            } else {
                $sql = $pdo->prepare('UPDATE tarefas SET titulo = ? WHERE id = ?');
                $sql->execute([$titulo, $id]);
                voltar();
            }
        }
    
        if ($acao === 'limpar') {
            $pdo->exec('DELETE FROM tarefas WHERE feita = 1');
            voltar();
        }
    
        if ($acao === 'excluir') {
            $sql = $pdo->prepare('DELETE FROM tarefas WHERE id = ?');
            $sql->execute([$id]);
            voltar();
        }
    }
    
    // Tarefa aberta para edição: index.php?editar=3
    if ($editando === null && isset($_GET['editar'])) {
        $sql = $pdo->prepare('SELECT id, titulo FROM tarefas WHERE id = ?');
        $sql->execute([(int) $_GET['editar']]);
        $editando = $sql->fetch() ?: null;
    }
    
    $tarefas = $pdo->query('SELECT id, titulo, feita FROM tarefas ORDER BY feita, id')->fetchAll();
    $pendentes = count(array_filter($tarefas, fn($t) => $t['feita'] == 0));
    ?>
    <!DOCTYPE html>
    <html lang="pt-BR">
    <head>
    <meta charset="utf-8">
    <title>Tarefas</title>
    </head>
    <body>
    <h1>Tarefas da Ana</h1>
    <p><?= $pendentes ?> pendente(s) de <?= count($tarefas) ?> tarefa(s).</p>
    <?php if ($erro): ?>
      <p class="erro"><?= e($erro) ?></p>
    <?php endif; ?>
    
    <form method="post" action="index.php">
      <input type="hidden" name="acao" value="criar">
      <input name="titulo" value="<?= e($novo) ?>" placeholder="Nova tarefa" maxlength="100">
      <button>Adicionar</button>
    </form>
    
    <ul>
    <?php foreach ($tarefas as $t): ?>
      <li>
    <?php if ($editando && $editando['id'] == $t['id']): ?>
        <form method="post" action="index.php">
          <input type="hidden" name="acao" value="editar">
          <input type="hidden" name="id" value="<?= $t['id'] ?>">
          <input name="titulo" value="<?= e($editando['titulo']) ?>" maxlength="100">
          <button>Salvar</button> <a href="index.php">Cancelar</a>
        </form>
    <?php else: ?>
        <form method="post" action="index.php" style="display:inline">
          <input type="hidden" name="acao" value="alternar">
          <input type="hidden" name="id" value="<?= $t['id'] ?>">
          <button><?= $t['feita'] ? 'Desfazer' : 'Feito!' ?></button>
        </form>
        <span class="tarefa"><?= $t['feita'] ? '<s>' . e($t['titulo']) . '</s>' : e($t['titulo']) ?></span>
        <a href="index.php?editar=<?= $t['id'] ?>">editar</a>
        <form method="post" action="index.php" style="display:inline">
          <input type="hidden" name="acao" value="excluir">
          <input type="hidden" name="id" value="<?= $t['id'] ?>">
          <button>excluir</button>
        </form>
    <?php endif; ?>
      </li>
    <?php endforeach; ?>
    </ul>
    <form method="post" action="index.php">
      <input type="hidden" name="acao" value="limpar">
      <button>Limpar concluídas</button>
    </form>
    </body>
    </html>
    
  7. Etapa 7 · A prova: injeção de SQL

    Todos os SQL com valores de fora usaram prepare + execute. Para ver o que isso evita, troque (só para testar!) o excluir pela versão "ingênua", que cola o valor no texto do SQL:

        if ($acao === 'excluir') {
            $pdo->exec('DELETE FROM tarefas WHERE id = ' . $_POST['id']); // ERRADO!
            voltar();
        }

    Com um id normal, funciona igual. Agora, com o banco recém-criado (3 tarefas), dois envios "estranhos":

    $ curl -s localhost:8000/ | grep pendente
    <p>2 pendente(s) de 3 tarefa(s).</p>
    $ curl -s -d 'acao=excluir&id=abc' localhost:8000/ | head -8
    <br />
    <b>Fatal error</b>:  Uncaught PDOException: SQLSTATE[HY000]: General error: 1 no such column: abc in /home/ana/tarefas/index.php:53
    Stack trace:
    #0 /home/ana/tarefas/index.php(53): PDO-&gt;exec('DELETE FROM tar...')
    #1 {main}
      thrown in <b>/home/ana/tarefas/index.php</b> on line <b>53</b><br />
    $ curl -sL -d 'acao=excluir&id=0 OR 1=1' localhost:8000/ | grep -E 'pendente|class="tarefa"'
    <p>0 pendente(s) de 0 tarefa(s).</p>          « TODAS as tarefas sumiram
    $ sqlite3 tarefas.sqlite 'SELECT COUNT(*) FROM tarefas'
    0

    O que aconteceu: o SQL montado foi DELETE FROM tarefas WHERE id = 0 OR 1=1. "id é 0 ou 1 é igual a 1" é verdade para toda linha, e o banco apagou tudo. No primeiro envio, abc virou o nome de uma coluna que não existe (no such column: abc): o texto do usuário foi lido como parte do comando. Isso é a injeção de SQL, um dos ataques mais antigos e mais comuns da web.

    A mesma tentativa contra a versão correta, também com o banco recém-criado:

    $ curl -sL -d 'acao=excluir&id=0 OR 1=1' localhost:8000/ | grep -E 'pendente|class="tarefa"'
    <p>2 pendente(s) de 3 tarefa(s).</p>
        <span class="tarefa">Comprar pão</span>
        <span class="tarefa">Pagar a conta de luz</span>
        <span class="tarefa"><s>Estudar PHP</s></span>
    $ sqlite3 tarefas.sqlite 'SELECT COUNT(*) FROM tarefas'
    3

    Nada apagado: com o prepared statement, 0 OR 1=1 é só um valor procurado na coluna id (e o (int) ainda o reduz a 0), nunca um pedaço de SQL. A regra, sem exceção: valor que vem de fora entra no SQL só por ?. Mais em Prepared Statements e SQL Injection.


Quando dá errado

Fatal error: Uncaught PDOException: SQLSTATE[HY000]: General error: 1 no such column: …

O SQL fala de uma coluna que a tabela não tem. Pode ser um erro de digitação (titlo), uma coluna que você acrescentou ao CREATE TABLE depois de o banco já existir (o IF NOT EXISTS não altera tabelas existentes: apague o tarefas.sqlite para recomeçar, ou use ALTER TABLE), ou, como na etapa 7, um valor colado no SQL.

SQLSTATE[HY000]: General error: 1 no such table: tarefas

O PHP abriu um arquivo de banco diferente do que você imagina, um arquivo novo e vazio. Acontece com caminhos relativos (new PDO('sqlite:tarefas.sqlite')), que dependem da pasta de onde o PHP foi chamado. Use sempre __DIR__ . '/tarefas.sqlite'.

SQLSTATE[HY000]: General error: 8 attempt to write a readonly database

O PHP consegue ler o arquivo, mas não escrever. Num servidor, o usuário do PHP (www-data) precisa de permissão de escrita no arquivo e na pasta (o SQLite cria arquivos temporários ao lado).

A lista não muda depois de clicar, ou muda duas vezes com F5

Faltou o voltar() (Post/Redirect/Get) depois de gravar, ou o exit dentro dele. Confira no terminal do servidor: cada clique deve aparecer como [303]: POST / seguido de [200]: GET /index.php.


O código completo

<?php
// Conexão com o banco SQLite e criação da tabela
date_default_timezone_set('America/Sao_Paulo');

$arquivo = __DIR__ . '/tarefas.sqlite';
$primeiraVez = !file_exists($arquivo);

$pdo = new PDO('sqlite:' . $arquivo);
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);

$pdo->exec('CREATE TABLE IF NOT EXISTS tarefas (
    id        INTEGER PRIMARY KEY AUTOINCREMENT,
    titulo    TEXT    NOT NULL,
    feita     INTEGER NOT NULL DEFAULT 0,
    criada_em TEXT    NOT NULL
)');

// Só para testar: quando o banco acaba de ser criado, três tarefas de exemplo.
if ($primeiraVez) {
    $exemplo = $pdo->prepare('INSERT INTO tarefas (titulo, feita, criada_em) VALUES (?, ?, ?)');
    $exemplo->execute(['Comprar pão', 0, date('Y-m-d H:i:s')]);
    $exemplo->execute(['Pagar a conta de luz', 0, date('Y-m-d H:i:s')]);
    $exemplo->execute(['Estudar PHP', 1, date('Y-m-d H:i:s')]);
}
<?php
require __DIR__ . '/banco.php';

function e(string $texto): string
{
    return htmlspecialchars($texto);
}

function voltar(): never
{
    header('Location: index.php', true, 303);
    exit;
}

$erro = '';
$novo = '';
$editando = null;

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $acao = $_POST['acao'] ?? '';
    $id = (int) ($_POST['id'] ?? 0);

    if ($acao === 'criar') {
        $novo = trim($_POST['titulo'] ?? '');
        if ($novo === '' || mb_strlen($novo) > 100) {
            $erro = 'Escreva a tarefa (até 100 caracteres).';
        } else {
            $sql = $pdo->prepare('INSERT INTO tarefas (titulo, criada_em) VALUES (?, ?)');
            $sql->execute([$novo, date('Y-m-d H:i:s')]);
            voltar();
        }
    }

    if ($acao === 'alternar') {
        $sql = $pdo->prepare('UPDATE tarefas SET feita = 1 - feita WHERE id = ?');
        $sql->execute([$id]);
        voltar();
    }

    if ($acao === 'editar') {
        $titulo = trim($_POST['titulo'] ?? '');
        if ($titulo === '' || mb_strlen($titulo) > 100) {
            $erro = 'Escreva a tarefa (até 100 caracteres).';
            $editando = ['id' => $id, 'titulo' => $titulo];
        } else {
            $sql = $pdo->prepare('UPDATE tarefas SET titulo = ? WHERE id = ?');
            $sql->execute([$titulo, $id]);
            voltar();
        }
    }

    if ($acao === 'excluir') {
        $sql = $pdo->prepare('DELETE FROM tarefas WHERE id = ?');
        $sql->execute([$id]);
        voltar();
    }
}

// Tarefa aberta para edição: index.php?editar=3
if ($editando === null && isset($_GET['editar'])) {
    $sql = $pdo->prepare('SELECT id, titulo FROM tarefas WHERE id = ?');
    $sql->execute([(int) $_GET['editar']]);
    $editando = $sql->fetch() ?: null;
}

$tarefas = $pdo->query('SELECT id, titulo, feita FROM tarefas ORDER BY feita, id')->fetchAll();
$pendentes = count(array_filter($tarefas, fn($t) => $t['feita'] == 0));
?>
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<title>Tarefas</title>
</head>
<body>
<h1>Tarefas da Ana</h1>
<p><?= $pendentes ?> pendente(s) de <?= count($tarefas) ?> tarefa(s).</p>
<?php if ($erro): ?>
  <p class="erro"><?= e($erro) ?></p>
<?php endif; ?>

<form method="post" action="index.php">
  <input type="hidden" name="acao" value="criar">
  <input name="titulo" value="<?= e($novo) ?>" placeholder="Nova tarefa" maxlength="100">
  <button>Adicionar</button>
</form>

<ul>
<?php foreach ($tarefas as $t): ?>
  <li>
<?php if ($editando && $editando['id'] == $t['id']): ?>
    <form method="post" action="index.php">
      <input type="hidden" name="acao" value="editar">
      <input type="hidden" name="id" value="<?= $t['id'] ?>">
      <input name="titulo" value="<?= e($editando['titulo']) ?>" maxlength="100">
      <button>Salvar</button> <a href="index.php">Cancelar</a>
    </form>
<?php else: ?>
    <form method="post" action="index.php" style="display:inline">
      <input type="hidden" name="acao" value="alternar">
      <input type="hidden" name="id" value="<?= $t['id'] ?>">
      <button><?= $t['feita'] ? 'Desfazer' : 'Feito!' ?></button>
    </form>
    <span class="tarefa"><?= $t['feita'] ? '<s>' . e($t['titulo']) . '</s>' : e($t['titulo']) ?></span>
    <a href="index.php?editar=<?= $t['id'] ?>">editar</a>
    <form method="post" action="index.php" style="display:inline">
      <input type="hidden" name="acao" value="excluir">
      <input type="hidden" name="id" value="<?= $t['id'] ?>">
      <button>excluir</button>
    </form>
<?php endif; ?>
  </li>
<?php endforeach; ?>
</ul>
</body>
</html>

Desafios extras

  1. Data de entrega: uma coluna prazo TEXT (formato 2026-10-20), um <input type="date"> no formulário e as tarefas atrasadas em vermelho. Lembre que mudar o CREATE TABLE não altera um banco que já existe.
  2. Token CSRF: traga as funções token_csrf() e conferir_csrf() do projeto de login e proteja todos os formulários.
  3. Uma lista por pessoa: junte com o projeto de login: coluna usuario_id na tabela e WHERE usuario_id = ? em todos os SQL (inclusive no UPDATE e no DELETE, senão um usuário apaga a tarefa do outro mandando outro id).
  4. Confirmar antes de excluir: <form … onsubmit="return confirm('Excluir esta tarefa?')"> (um toque de JavaScript).
  5. API: a mesma tabela servida como JSON; é o assunto de Criar uma API JSON e do projeto API REST.

Missão final: uma busca. Acrescente um formulário GET com o campo busca e, quando ele vier preenchido, troque a consulta da lista por SELECT … WHERE titulo LIKE ? ORDER BY feita, id, executada com ['%' . $busca . '%'] (o % do LIKE quer dizer "qualquer coisa": %conta% acha "conta" em qualquer lugar do título). A caixa abre com ?busca=conta.

<?php
require __DIR__ . '/banco.php';

function e(string $texto): string
{
    return htmlspecialchars($texto);
}

function voltar(): never
{
    header('Location: index.php', true, 303);
    exit;
}

$erro = '';
$novo = '';
$editando = null;

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $acao = $_POST['acao'] ?? '';
    $id = (int) ($_POST['id'] ?? 0);

    if ($acao === 'criar') {
        $novo = trim($_POST['titulo'] ?? '');
        if ($novo === '' || mb_strlen($novo) > 100) {
            $erro = 'Escreva a tarefa (até 100 caracteres).';
        } else {
            $sql = $pdo->prepare('INSERT INTO tarefas (titulo, criada_em) VALUES (?, ?)');
            $sql->execute([$novo, date('Y-m-d H:i:s')]);
            voltar();
        }
    }

    if ($acao === 'alternar') {
        $sql = $pdo->prepare('UPDATE tarefas SET feita = 1 - feita WHERE id = ?');
        $sql->execute([$id]);
        voltar();
    }

    if ($acao === 'editar') {
        $titulo = trim($_POST['titulo'] ?? '');
        if ($titulo === '' || mb_strlen($titulo) > 100) {
            $erro = 'Escreva a tarefa (até 100 caracteres).';
            $editando = ['id' => $id, 'titulo' => $titulo];
        } else {
            $sql = $pdo->prepare('UPDATE tarefas SET titulo = ? WHERE id = ?');
            $sql->execute([$titulo, $id]);
            voltar();
        }
    }

    if ($acao === 'excluir') {
        $sql = $pdo->prepare('DELETE FROM tarefas WHERE id = ?');
        $sql->execute([$id]);
        voltar();
    }
}

// Tarefa aberta para edição: index.php?editar=3
if ($editando === null && isset($_GET['editar'])) {
    $sql = $pdo->prepare('SELECT id, titulo FROM tarefas WHERE id = ?');
    $sql->execute([(int) $_GET['editar']]);
    $editando = $sql->fetch() ?: null;
}

$tarefas = $pdo->query('SELECT id, titulo, feita FROM tarefas ORDER BY feita, id')->fetchAll();
$pendentes = count(array_filter($tarefas, fn($t) => $t['feita'] == 0));
?>
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<title>Tarefas</title>
</head>
<body>
<h1>Tarefas da Ana</h1>
<p><?= $pendentes ?> pendente(s) de <?= count($tarefas) ?> tarefa(s).</p>
<?php if ($erro): ?>
  <p class="erro"><?= e($erro) ?></p>
<?php endif; ?>

<form method="post" action="index.php">
  <input type="hidden" name="acao" value="criar">
  <input name="titulo" value="<?= e($novo) ?>" placeholder="Nova tarefa" maxlength="100">
  <button>Adicionar</button>
</form>

<ul>
<?php foreach ($tarefas as $t): ?>
  <li>
<?php if ($editando && $editando['id'] == $t['id']): ?>
    <form method="post" action="index.php">
      <input type="hidden" name="acao" value="editar">
      <input type="hidden" name="id" value="<?= $t['id'] ?>">
      <input name="titulo" value="<?= e($editando['titulo']) ?>" maxlength="100">
      <button>Salvar</button> <a href="index.php">Cancelar</a>
    </form>
<?php else: ?>
    <form method="post" action="index.php" style="display:inline">
      <input type="hidden" name="acao" value="alternar">
      <input type="hidden" name="id" value="<?= $t['id'] ?>">
      <button><?= $t['feita'] ? 'Desfazer' : 'Feito!' ?></button>
    </form>
    <span class="tarefa"><?= $t['feita'] ? '<s>' . e($t['titulo']) . '</s>' : e($t['titulo']) ?></span>
    <a href="index.php?editar=<?= $t['id'] ?>">editar</a>
    <form method="post" action="index.php" style="display:inline">
      <input type="hidden" name="acao" value="excluir">
      <input type="hidden" name="id" value="<?= $t['id'] ?>">
      <button>excluir</button>
    </form>
<?php endif; ?>
  </li>
<?php endforeach; ?>
</ul>
</body>
</html>
<?php
// Conexão com o banco SQLite e criação da tabela
date_default_timezone_set('America/Sao_Paulo');

$arquivo = __DIR__ . '/tarefas.sqlite';
$primeiraVez = !file_exists($arquivo);

$pdo = new PDO('sqlite:' . $arquivo);
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);

$pdo->exec('CREATE TABLE IF NOT EXISTS tarefas (
    id        INTEGER PRIMARY KEY AUTOINCREMENT,
    titulo    TEXT    NOT NULL,
    feita     INTEGER NOT NULL DEFAULT 0,
    criada_em TEXT    NOT NULL
)');

// Só para testar: quando o banco acaba de ser criado, três tarefas de exemplo.
if ($primeiraVez) {
    $exemplo = $pdo->prepare('INSERT INTO tarefas (titulo, feita, criada_em) VALUES (?, ?, ?)');
    $exemplo->execute(['Comprar pão', 0, date('Y-m-d H:i:s')]);
    $exemplo->execute(['Pagar a conta de luz', 0, date('Y-m-d H:i:s')]);
    $exemplo->execute(['Estudar PHP', 1, date('Y-m-d H:i:s')]);
}
<?php
require __DIR__ . '/banco.php';

function e(string $texto): string
{
    return htmlspecialchars($texto);
}

function voltar(): never
{
    header('Location: index.php', true, 303);
    exit;
}

$erro = '';
$novo = '';
$editando = null;

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $acao = $_POST['acao'] ?? '';
    $id = (int) ($_POST['id'] ?? 0);

    if ($acao === 'criar') {
        $novo = trim($_POST['titulo'] ?? '');
        if ($novo === '' || mb_strlen($novo) > 100) {
            $erro = 'Escreva a tarefa (até 100 caracteres).';
        } else {
            $sql = $pdo->prepare('INSERT INTO tarefas (titulo, criada_em) VALUES (?, ?)');
            $sql->execute([$novo, date('Y-m-d H:i:s')]);
            voltar();
        }
    }

    if ($acao === 'alternar') {
        $sql = $pdo->prepare('UPDATE tarefas SET feita = 1 - feita WHERE id = ?');
        $sql->execute([$id]);
        voltar();
    }

    if ($acao === 'editar') {
        $titulo = trim($_POST['titulo'] ?? '');
        if ($titulo === '' || mb_strlen($titulo) > 100) {
            $erro = 'Escreva a tarefa (até 100 caracteres).';
            $editando = ['id' => $id, 'titulo' => $titulo];
        } else {
            $sql = $pdo->prepare('UPDATE tarefas SET titulo = ? WHERE id = ?');
            $sql->execute([$titulo, $id]);
            voltar();
        }
    }

    if ($acao === 'excluir') {
        $sql = $pdo->prepare('DELETE FROM tarefas WHERE id = ?');
        $sql->execute([$id]);
        voltar();
    }
}

// Tarefa aberta para edição: index.php?editar=3
if ($editando === null && isset($_GET['editar'])) {
    $sql = $pdo->prepare('SELECT id, titulo FROM tarefas WHERE id = ?');
    $sql->execute([(int) $_GET['editar']]);
    $editando = $sql->fetch() ?: null;
}

$busca = trim($_GET['busca'] ?? '');
if ($busca !== '') {
    $sql = $pdo->prepare('SELECT id, titulo, feita FROM tarefas WHERE titulo LIKE ? ORDER BY feita, id');
    $sql->execute(['%' . $busca . '%']);
    $tarefas = $sql->fetchAll();
} else {
    $tarefas = $pdo->query('SELECT id, titulo, feita FROM tarefas ORDER BY feita, id')->fetchAll();
}
$pendentes = count(array_filter($tarefas, fn($t) => $t['feita'] == 0));
?>
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<title>Tarefas</title>
</head>
<body>
<h1>Tarefas da Ana</h1>
<p><?= $pendentes ?> pendente(s) de <?= count($tarefas) ?> tarefa(s).</p>
<?php if ($erro): ?>
  <p class="erro"><?= e($erro) ?></p>
<?php endif; ?>

<form method="post" action="index.php">
  <input type="hidden" name="acao" value="criar">
  <input name="titulo" value="<?= e($novo) ?>" placeholder="Nova tarefa" maxlength="100">
  <button>Adicionar</button>
</form>

<form method="get" action="index.php">
  <input name="busca" value="<?= e($busca) ?>" placeholder="Buscar">
  <button>Buscar</button>
</form>

<ul>
<?php foreach ($tarefas as $t): ?>
  <li>
<?php if ($editando && $editando['id'] == $t['id']): ?>
    <form method="post" action="index.php">
      <input type="hidden" name="acao" value="editar">
      <input type="hidden" name="id" value="<?= $t['id'] ?>">
      <input name="titulo" value="<?= e($editando['titulo']) ?>" maxlength="100">
      <button>Salvar</button> <a href="index.php">Cancelar</a>
    </form>
<?php else: ?>
    <form method="post" action="index.php" style="display:inline">
      <input type="hidden" name="acao" value="alternar">
      <input type="hidden" name="id" value="<?= $t['id'] ?>">
      <button><?= $t['feita'] ? 'Desfazer' : 'Feito!' ?></button>
    </form>
    <span class="tarefa"><?= $t['feita'] ? '<s>' . e($t['titulo']) . '</s>' : e($t['titulo']) ?></span>
    <a href="index.php?editar=<?= $t['id'] ?>">editar</a>
    <form method="post" action="index.php" style="display:inline">
      <input type="hidden" name="acao" value="excluir">
      <input type="hidden" name="id" value="<?= $t['id'] ?>">
      <button>excluir</button>
    </form>
<?php endif; ?>
  </li>
<?php endforeach; ?>
</ul>
</body>
</html>

Exercícios

Complete o UPDATE seguro que troca o título da tarefa:

$sql = $pdo->('UPDATE tarefas SET titulo = ? WHERE id = ?');
$sql->([$titulo, $id]);

Complete o SQL que marca/desmarca uma tarefa com o mesmo botão:

UPDATE tarefas SET feita = WHERE id = ?

O que significa a letra U de CRUD, e qual comando SQL faz isso?

Com $pdo->exec('DELETE FROM tarefas WHERE id = ' . $_POST['id']), alguém envia id=0 OR 1=1. O que acontece?

Por que o botão "excluir" é um formulário POST e não um link <a href="?excluir=3">?

Resumo

  • new PDO('sqlite:' . __DIR__ . '/arquivo.sqlite') + CREATE TABLE IF NOT EXISTS deixam o banco pronto, sem instalar servidor.
  • CRUD: INSERT (criar), SELECT (ler), UPDATE (atualizar), DELETE (apagar).
  • Valor que vem de fora: sempre prepare com ? + execute([...]). SQL fixo pode usar query/exec.
  • UPDATE e DELETE sempre com WHERE (a não ser que você queira mesmo mexer em todas as linhas).
  • Ações que mudam dados: formulário POST + Post/Redirect/Get. Ler (como abrir a edição) pode ser GET.
  • O banco guarda o texto como veio; a página escapa na hora de mostrar.

Próximo projeto: Mini Blog, com posts no SQLite, páginas separadas e paginação.