Projeto API REST

Até aqui, seus programas PHP devolviam páginas HTML para pessoas. Uma API devolve dados (em JSON) para outros programas: um aplicativo de celular, um site em JavaScript, um script. Neste projeto você vai construir a API das tarefas da Ana: listar, criar, marcar como feita e apagar, cada ação com o verbo HTTP certo e o código de status certo. Tudo testado com o curl, o "navegador de terminal".

Você vai praticar
  • Responder em JSON com header, json_encode e http_response_code.
  • Guardar as tarefas no SQLite com PDO e prepared statements.
  • Ler $_SERVER["REQUEST_METHOD"] e $_SERVER["PATH_INFO"] para escolher a ação (referência de $_SERVER).
  • Ler um corpo JSON com php://input e validar o que chegou.
  • Tratar erros: 404, 405, 422 e um 500 que não mostra as entranhas do servidor.

O que vamos construir

Uma API com um único arquivo de entrada, api.php, que entende estes pedidos:

PedidoO que fazResponde
GET /api.php/tarefasLista todas as tarefas200 + a lista
GET /api.php/tarefas/2Mostra a tarefa 2200 + a tarefa, ou 404
POST /api.php/tarefasCria uma tarefa (o título vai no corpo)201 + a tarefa criada, ou 422
PUT /api.php/tarefas/2Muda o título e/ou marca como feita200 + a tarefa, 404 ou 422
DELETE /api.php/tarefas/2Apaga a tarefa 2204 (sem corpo), ou 404

A API pronta, em uso (o servidor php -S roda em outro terminal):

$ curl -s localhost:8000/api.php/tarefas
[
    {
        "id": 1,
        "titulo": "Comprar pão",
        "feita": true
    },
    {
        "id": 2,
        "titulo": "Estudar PHP",
        "feita": false
    }
]
$ curl -s localhost:8000/api.php/tarefas -H 'Content-Type: application/json' -d '{"titulo": "Pagar a conta de luz"}'
{
    "id": 3,
    "titulo": "Pagar a conta de luz",
    "feita": false
}
$ curl -s -X PUT localhost:8000/api.php/tarefas/3 -H 'Content-Type: application/json' -d '{"feita": true}'
{
    "id": 3,
    "titulo": "Pagar a conta de luz",
    "feita": true
}
$ curl -si -X DELETE localhost:8000/api.php/tarefas/1 | head -1
HTTP/1.1 204 No Content
$ curl -s localhost:8000/api.php/tarefas/1
{
    "erro": "Tarefa 1 não encontrada"
}

E o terminal do servidor registra cada pedido com o seu código de status:

$ php -S localhost:8000
[Fri Oct 16 21:04:58 2026] PHP 8.5.8 Development Server (http://localhost:8000) started
[Fri Oct 16 21:04:59 2026] 127.0.0.1:41336 Accepted
[Fri Oct 16 21:04:59 2026] 127.0.0.1:41336 [200]: GET /api.php/tarefas
[Fri Oct 16 21:04:59 2026] 127.0.0.1:41336 Closing
[Fri Oct 16 21:04:59 2026] 127.0.0.1:41350 Accepted
[Fri Oct 16 21:04:59 2026] 127.0.0.1:41350 [201]: POST /api.php/tarefas
[Fri Oct 16 21:04:59 2026] 127.0.0.1:41350 Closing
[Fri Oct 16 21:04:59 2026] 127.0.0.1:41366 Accepted
[Fri Oct 16 21:04:59 2026] 127.0.0.1:41366 [200]: PUT /api.php/tarefas/3
[Fri Oct 16 21:04:59 2026] 127.0.0.1:41366 Closing
[Fri Oct 16 21:04:59 2026] 127.0.0.1:41370 Accepted
[Fri Oct 16 21:04:59 2026] 127.0.0.1:41370 [204]: DELETE /api.php/tarefas/1
[Fri Oct 16 21:04:59 2026] 127.0.0.1:41370 Closing
[Fri Oct 16 21:04:59 2026] 127.0.0.1:41376 Accepted
[Fri Oct 16 21:04:59 2026] 127.0.0.1:41376 [404]: GET /api.php/tarefas/1
[Fri Oct 16 21:04:59 2026] 127.0.0.1:41376 Closing
  1. Responder em JSON (header, json_encode, http_response_code).
  2. O banco e as rotas GET (SQLite, PATH_INFO, 404).
  3. Criar com POST (php://input, validação, 201, 422).
  4. Atualizar com PUT e apagar com DELETE (204, 405).
  5. Erros inesperados (500 em JSON com set_exception_handler).

Antes de começar: a ideia do REST

Pense num balcão de atendimento. Você diz o que quer fazer (consultar, cadastrar, alterar, cancelar) e com o quê (a ficha número 2). No HTTP, o "o que fazer" é o método (ou verbo) e o "com o quê" é o endereço (o recurso). REST é só essa combinação, usada de um jeito previsível:

MétodoSignificadoTem corpo?
GET"me mostre" (nunca muda nada)não
POST"crie um novo"sim
PUT"altere este"sim
DELETE"apague este"não

E a resposta sempre traz um código de status, um número de três dígitos que o programa do outro lado lê antes de tudo:

CódigoNomeNesta API, quando
200OKdeu certo e há dados na resposta
201Created (criado)um POST criou uma tarefa
204No Content (sem conteúdo)apagou; não há nada para mostrar
400Bad Request (pedido mal feito)o JSON enviado está quebrado
404Not Found (não encontrado)a tarefa ou o endereço não existe
405Method Not Allowedmétodo que o endereço não aceita (DELETE /tarefas)
422Unprocessable Content (não processável)o JSON está certo, mas os dados não (título vazio)
500Internal Server Errorum erro no nosso código

Regra para lembrar: 2xx = deu certo; 4xx = o cliente errou; 5xx = o servidor errou.

O curl

Navegadores só fazem GET pela barra de endereço. Para testar os outros métodos, usamos o curl no terminal (veja também O Servidor Embutido):

OpçãoO que faz
-ssilent: sem a barra de progresso
-imostra também os cabeçalhos da resposta (a linha HTTP/1.1 200 OK e as seguintes)
-X PUTescolhe o método (sem -X: GET, ou POST se houver -d)
-H 'Nome: valor'manda um cabeçalho, como Content-Type: application/json ("o corpo é JSON")
-d 'dados'manda um corpo (e, sozinho, faz um POST)

A estrutura do projeto, na pasta ~/api:

$ ls
api.php
banco.php
tarefas.sqlite        « criado sozinho na primeira requisição
As caixas "Experimente" e os métodos HTTP

O editor do guia roda o PHP de verdade em modo web, mas, como um formulário HTML, ele só sabe enviar GET (pelo endereço, data-url) e POST com campos de formulário. Por isso a API aceita também corpo de formulário, e na etapa 4 você vai ver o truque do campo _method para experimentar PUT e DELETE no editor. Todos os testes com JSON, PUT e DELETE desta página foram feitos com curl na máquina da Ana.


Etapa 1 · Responder em JSON

A primeira versão é ingênua: monta um array e o transforma em texto JSON com json_encode.

<?php
$tarefas = [
    ["id" => 1, "titulo" => "Comprar pão", "feita" => true],
    ["id" => 2, "titulo" => "Estudar PHP", "feita" => false],
];
echo json_encode($tarefas);

Com curl -si, olhe a resposta inteira, cabeçalhos incluídos:

$ curl -si localhost:8000/api.php
HTTP/1.1 200 OK
Host: localhost:8000
Date: Sat, 17 Oct 2026 00:08:25 GMT
Connection: close
X-Powered-By: PHP/8.5.8
Content-type: text/html; charset=UTF-8

[{"id":1,"titulo":"Comprar pão","feita":true},{"id":2,"titulo":"Estudar PHP","feita":false}]

Funciona, mas com três defeitos:

  1. Content-type: text/html: o PHP avisa ao cliente que mandou uma página HTML. Muitos programas confiam nesse cabeçalho para saber como ler a resposta.
  2. pão: por padrão, o json_encode troca letras acentuadas por códigos \u…. É JSON válido (qualquer programa entende), mas ilegível para nós.
  3. Tudo numa linha só e sem quebra no fim: difícil de ler no terminal.

A versão certa põe a resposta numa função que vai ser usada em todo o projeto:

<?php
function responder(int $status, mixed $dados = null): never
{
    http_response_code($status);
    if ($dados !== null) {
        header("Content-Type: application/json; charset=utf-8");
        echo json_encode($dados, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), "\n";
    }
    exit;
}

$tarefas = [
    ["id" => 1, "titulo" => "Comprar pão", "feita" => true],
    ["id" => 2, "titulo" => "Estudar PHP", "feita" => false],
];
responder(200, $tarefas);
LinhaO que faz
function responder(int $status, mixed $dados = null): neverRecebe o código de status e os dados (de qualquer tipo: mixed). O tipo de retorno never PHP 8.1+ diz "esta função nunca volta": ela sempre termina o programa. Veja Tipos e Retorno.
http_response_code($status);Define o código de status da resposta (sem isto, é sempre 200).
header("Content-Type: application/json; charset=utf-8");Avisa que o corpo é JSON, em UTF-8. Cabeçalhos têm de sair antes de qualquer echo (header).
JSON_PRETTY_PRINTUma informação por linha, com recuo de 4 espaços.
JSON_UNESCAPED_UNICODEMantém ã como ã.
JSON_UNESCAPED_SLASHESMantém / como / (sem ele, /tarefas sairia \/tarefas).
exit;Termina o programa: nada depois de responder(…) roda. Assim, cada rota pode simplesmente "responder e acabar".

As opções do json_encode se somam com | (o "ou" de bits: liga as três). Veja JSON.

$ curl -si localhost:8000/api.php
HTTP/1.1 200 OK
Host: localhost:8000
Date: Sat, 17 Oct 2026 00:04:02 GMT
Connection: close
X-Powered-By: PHP/8.5.8
Content-Type: application/json; charset=utf-8

[
    {
        "id": 1,
        "titulo": "Comprar pão",
        "feita": true
    },
    {
        "id": 2,
        "titulo": "Estudar PHP",
        "feita": false
    }
]

Repare nos tipos: 1 sem aspas é número, "Comprar pão" é texto, true/false são booleanos. O json_encode converte cada tipo do PHP no tipo equivalente do JSON, e uma lista PHP ([…, …]) vira uma lista JSON.

Missão (etapa 1): rode e veja "Comprar pão" no resultado. Acrescente as opções que faltam no json_encode para que apareça "Comprar pão".

<?php
function responder(int $status, mixed $dados = null): never
{
    http_response_code($status);
    if ($dados !== null) {
        header("Content-Type: application/json; charset=utf-8");
        echo json_encode($dados, JSON_PRETTY_PRINT), "\n";
    }
    exit;
}

$tarefas = [
    ["id" => 1, "titulo" => "Comprar pão", "feita" => true],
    ["id" => 2, "titulo" => "Estudar PHP", "feita" => false],
];
responder(200, $tarefas);
<?php
function responder(int $status, mixed $dados = null): never
{
    http_response_code($status);
    if ($dados !== null) {
        header("Content-Type: application/json; charset=utf-8");
        echo json_encode($dados, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), "\n";
    }
    exit;
}

$tarefas = [
    ["id" => 1, "titulo" => "Comprar pão", "feita" => true],
    ["id" => 2, "titulo" => "Estudar PHP", "feita" => false],
];
responder(200, $tarefas);

Etapa 2 · O banco e as rotas GET

As tarefas agora vão para um banco SQLite (um arquivo só, sem servidor; veja Banco de Dados). A conexão fica num arquivo separado, que o api.php inclui (include e require):

<?php
$arquivo = __DIR__ . "/tarefas.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 tarefas (
        id     INTEGER PRIMARY KEY AUTOINCREMENT,
        titulo TEXT    NOT NULL,
        feita  INTEGER NOT NULL DEFAULT 0
    )");
    $pdo->exec("INSERT INTO tarefas (titulo, feita) VALUES ('Comprar pão', 1), ('Estudar PHP', 0)");
}

return $pdo;
LinhaO que faz
__DIR__ . "/tarefas.sqlite"O arquivo do banco fica na mesma pasta do banco.php, não importa de onde o PHP foi chamado.
$novo = !file_exists($arquivo);Guarda se o banco ainda não existia (o SQLite cria o arquivo vazio ao conectar).
PDO::FETCH_ASSOCCada linha do banco vem como array associativo: ["id" => 1, "titulo" => "Comprar pão", "feita" => 1].
CREATE TABLE …Só na primeira vez: cria a tabela. AUTOINCREMENT = o banco escolhe o próximo número. O SQLite não tem tipo booleano: feita é 0 ou 1.
INSERT …Duas tarefas de exemplo, para a API não começar vazia.
return $pdo;Um arquivo incluído pode devolver um valor: $pdo = require __DIR__ . "/banco.php";.

Rotas: método + caminho

Como o api.php sabe se o pedido foi /tarefas ou /tarefas/2? Quando o endereço continua depois do nome do arquivo (/api.php/tarefas/2), o PHP põe esse pedaço em $_SERVER["PATH_INFO"]. Junto com $_SERVER["REQUEST_METHOD"], isso escolhe a rota:

$pdo = require __DIR__ . "/banco.php";
$metodo = $_SERVER["REQUEST_METHOD"];
$caminho = $_SERVER["PATH_INFO"] ?? "/";

if ($caminho === "/tarefas" && $metodo === "GET") {
    $tarefas = $pdo->query("SELECT * FROM tarefas ORDER BY id")->fetchAll();
    responder(200, array_map("formatar", $tarefas));
}

if (preg_match('#^/tarefas/(\d+)$#', $caminho, $partes) && $metodo === "GET") {
    $id = (int) $partes[1];
    responder(200, formatar(buscar($pdo, $id)));
}

responder(404, ["erro" => "Rota não encontrada: $metodo $caminho"]);
TrechoO que faz
$_SERVER["PATH_INFO"] ?? "/"Em /api.php/tarefas/2, vale "/tarefas/2". Em /api.php puro, não existe: usamos "/".
preg_match('#^/tarefas/(\d+)$#', $caminho, $partes)Confere se o caminho é /tarefas/ seguido de dígitos (\d+) e nada mais (^ = começo, $ = fim). O que está entre parênteses é guardado em $partes[1]: "2". O # é só o delimitador da expressão (no lugar da /, que aparece no caminho).
(int) $partes[1]Converte "2" em 2. Como a expressão só aceita dígitos, /tarefas/abc nem chega aqui.
responder(404, …) no fimSe nenhuma rota respondeu (lembre: responder termina o programa), o endereço não existe.

E duas funções novas:

function formatar(array $t): array
{
    return ["id" => $t["id"], "titulo" => $t["titulo"], "feita" => (bool) $t["feita"]];
}

function buscar(PDO $pdo, int $id): array
{
    $st = $pdo->prepare("SELECT * FROM tarefas WHERE id = ?");
    $st->execute([$id]);
    $tarefa = $st->fetch();
    if ($tarefa === false) {
        responder(404, ["erro" => "Tarefa $id não encontrada"]);
    }
    return $tarefa;
}
  • formatar converte a linha do banco no formato da API: feita sai como true/false em vez de 1/0. Com array_map("formatar", $tarefas), ela é aplicada a cada linha (array_map).
  • buscar usa um prepared statement: o ? é preenchido pelo execute, nunca colado no SQL. Mesmo que o id viesse do visitante sem conversão, não haveria SQL Injection (aula). fetch() devolve a linha ou false se não achou: aí respondemos 404.
$ ls
api.php
banco.php
$ curl -s localhost:8000/api.php/tarefas
[
    {
        "id": 1,
        "titulo": "Comprar pão",
        "feita": true
    },
    {
        "id": 2,
        "titulo": "Estudar PHP",
        "feita": false
    }
]
$ ls
api.php
banco.php
tarefas.sqlite
$ curl -s localhost:8000/api.php/tarefas/2
{
    "id": 2,
    "titulo": "Estudar PHP",
    "feita": false
}
$ curl -si localhost:8000/api.php/tarefas/99
HTTP/1.1 404 Not Found
Host: localhost:8000
Date: Sat, 17 Oct 2026 00:04:03 GMT
Connection: close
X-Powered-By: PHP/8.5.8
Content-Type: application/json; charset=utf-8

{
    "erro": "Tarefa 99 não encontrada"
}
$ curl -s localhost:8000/api.php/usuarios
{
    "erro": "Rota não encontrada: GET /usuarios"
}

Repare: o tarefas.sqlite apareceu depois da primeira requisição. E um erro também é respondido em JSON, com o código certo: quem usa a API lê o 404 e a mensagem em "erro".

Quando dá errado: esquecer o caso "não achei"

Sem o if ($tarefa === false), a função tenta devolver false onde prometeu um array:

$ curl -si localhost:8000/api.php/tarefas/99
HTTP/1.1 200 OK
Host: localhost:8000
Date: Sat, 17 Oct 2026 00:08:26 GMT
Connection: close
X-Powered-By: PHP/8.5.8
Content-type: text/html; charset=UTF-8

<br />
<b>Fatal error</b>:  Uncaught TypeError: buscar(): Return value must be of type array, false returned in /home/ana/api/api.php:23
Stack trace:
#0 /home/ana/api/api.php(37): buscar(Object(PDO), 99)
#1 {main}
  thrown in <b>/home/ana/api/api.php</b> on line <b>23</b><br />

O cliente esperava JSON e recebeu HTML com um erro, e ainda com status 200 (o php -S enviou a mensagem de erro como uma página normal). Como consertar: todo fetch() pode devolver false; trate esse caso. A mensagem está explicada na referência de erros.

Missão (etapa 2): a caixa pede GET /api.php/tarefas/99 e a função buscar estoura. Faça-a responder 404 com {"erro": "Tarefa 99 não encontrada"}. Depois troque o endereço na barra do resultado para /api.php/tarefas e /api.php/tarefas/2.

<?php
function responder(int $status, mixed $dados = null): never
{
    http_response_code($status);
    if ($dados !== null) {
        header("Content-Type: application/json; charset=utf-8");
        echo json_encode($dados, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), "\n";
    }
    exit;
}

function formatar(array $t): array
{
    return ["id" => $t["id"], "titulo" => $t["titulo"], "feita" => (bool) $t["feita"]];
}

function buscar(PDO $pdo, int $id): array
{
    $st = $pdo->prepare("SELECT * FROM tarefas WHERE id = ?");
    $st->execute([$id]);
    $tarefa = $st->fetch();
    // e se a tarefa não existir? $st->fetch() devolve false…
    return $tarefa;
}

$pdo = require __DIR__ . "/banco.php";
$metodo = $_SERVER["REQUEST_METHOD"];
$caminho = $_SERVER["PATH_INFO"] ?? "/";

if ($caminho === "/tarefas" && $metodo === "GET") {
    $tarefas = $pdo->query("SELECT * FROM tarefas ORDER BY id")->fetchAll();
    responder(200, array_map("formatar", $tarefas));
}

if (preg_match('#^/tarefas/(\d+)$#', $caminho, $partes) && $metodo === "GET") {
    $id = (int) $partes[1];
    responder(200, formatar(buscar($pdo, $id)));
}

responder(404, ["erro" => "Rota não encontrada: $metodo $caminho"]);
<?php
$arquivo = __DIR__ . "/tarefas.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 tarefas (
        id     INTEGER PRIMARY KEY AUTOINCREMENT,
        titulo TEXT    NOT NULL,
        feita  INTEGER NOT NULL DEFAULT 0
    )");
    $pdo->exec("INSERT INTO tarefas (titulo, feita) VALUES ('Comprar pão', 1), ('Estudar PHP', 0)");
}

return $pdo;
<?php
function responder(int $status, mixed $dados = null): never
{
    http_response_code($status);
    if ($dados !== null) {
        header("Content-Type: application/json; charset=utf-8");
        echo json_encode($dados, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), "\n";
    }
    exit;
}

function formatar(array $t): array
{
    return ["id" => $t["id"], "titulo" => $t["titulo"], "feita" => (bool) $t["feita"]];
}

function buscar(PDO $pdo, int $id): array
{
    $st = $pdo->prepare("SELECT * FROM tarefas WHERE id = ?");
    $st->execute([$id]);
    $tarefa = $st->fetch();
    if ($tarefa === false) {
        responder(404, ["erro" => "Tarefa $id não encontrada"]);
    }
    return $tarefa;
}

$pdo = require __DIR__ . "/banco.php";
$metodo = $_SERVER["REQUEST_METHOD"];
$caminho = $_SERVER["PATH_INFO"] ?? "/";

if ($caminho === "/tarefas" && $metodo === "GET") {
    $tarefas = $pdo->query("SELECT * FROM tarefas ORDER BY id")->fetchAll();
    responder(200, array_map("formatar", $tarefas));
}

if (preg_match('#^/tarefas/(\d+)$#', $caminho, $partes) && $metodo === "GET") {
    $id = (int) $partes[1];
    responder(200, formatar(buscar($pdo, $id)));
}

responder(404, ["erro" => "Rota não encontrada: $metodo $caminho"]);

Etapa 3 · Criar com POST

Para criar, o cliente manda um POST /tarefas com o título no corpo. APIs costumam receber o corpo em JSON ({"titulo": "Lavar a louça"}), e aí vem uma surpresa: o PHP só preenche $_POST para corpos de formulário. Um corpo JSON tem de ser lido "cru", de php://input (um arquivo especial com o corpo do pedido), e decodificado com json_decode:

function lerCorpo(): array
{
    $tipo = $_SERVER["CONTENT_TYPE"] ?? "";
    if (str_starts_with($tipo, "application/json")) {
        $dados = json_decode(file_get_contents("php://input"), true);
        if (!is_array($dados)) {
            responder(400, ["erro" => "JSON inválido"]);
        }
        return $dados;
    }
    return $_POST;
}

function validarTitulo(mixed $titulo): string
{
    if (!is_string($titulo) || trim($titulo) === "") {
        responder(422, ["erro" => "O título é obrigatório"]);
    }
    $titulo = trim($titulo);
    if (mb_strlen($titulo) > 100) {
        responder(422, ["erro" => "O título pode ter no máximo 100 caracteres"]);
    }
    return $titulo;
}
TrechoO que faz
$_SERVER["CONTENT_TYPE"]O cabeçalho Content-Type do pedido: diz em que formato está o corpo.
file_get_contents("php://input")O corpo inteiro, como texto.
json_decode(…, true)Texto JSON → array associativo (true = array, não objeto). JSON quebrado dá null; um JSON válido que não é objeto ("oi", 42) não é array. Os dois casos: 400.
return $_POST;Se não é JSON, aceitamos um corpo de formulário (é o que o editor do guia e os formulários HTML enviam).
!is_string($titulo) || trim($titulo) === ""O título tem de ser texto (o cliente pode mandar {"titulo": 42} ou {"titulo": ["a"]}) e não pode ser só espaços.
mb_strlen($titulo) > 100Um limite razoável, contando letras (não bytes; veja bytes × letras).

E a rota:

if ($caminho === "/tarefas" && $metodo === "POST") {
    $dados = lerCorpo();
    $titulo = validarTitulo($dados["titulo"] ?? null);
    $st = $pdo->prepare("INSERT INTO tarefas (titulo) VALUES (?)");
    $st->execute([$titulo]);
    $id = (int) $pdo->lastInsertId();
    header("Location: /api.php/tarefas/$id");
    responder(201, formatar(buscar($pdo, $id)));
}

lastInsertId() devolve o id que o banco acabou de escolher (como texto; por isso o (int)). A resposta de criação segue a convenção do HTTP: código 201 Created, o cabeçalho Location com o endereço da coisa nova, e a tarefa criada no corpo (o cliente fica sabendo o id sem perguntar de novo).

$ curl -si localhost:8000/api.php/tarefas -H 'Content-Type: application/json' -d '{"titulo": "Lavar a louça"}'
HTTP/1.1 201 Created
Host: localhost:8000
Date: Sat, 17 Oct 2026 00:04:06 GMT
Connection: close
X-Powered-By: PHP/8.5.8
Location: /api.php/tarefas/3
Content-Type: application/json; charset=utf-8

{
    "id": 3,
    "titulo": "Lavar a louça",
    "feita": false
}
$ curl -s localhost:8000/api.php/tarefas/3
{
    "id": 3,
    "titulo": "Lavar a louça",
    "feita": false
}
$ curl -si localhost:8000/api.php/tarefas -H 'Content-Type: application/json' -d '{"titulo": "   "}'
HTTP/1.1 422 Unknown Status Code
Host: localhost:8000
Date: Sat, 17 Oct 2026 00:04:06 GMT
Connection: close
X-Powered-By: PHP/8.5.8
Content-Type: application/json; charset=utf-8

{
    "erro": "O título é obrigatório"
}
$ curl -s localhost:8000/api.php/tarefas -H 'Content-Type: application/json' -d '{titulo: Lavar}'
{
    "erro": "JSON inválido"
}
$ curl -s localhost:8000/api.php/tarefas -d 'titulo=Regar as plantas'
{
    "id": 4,
    "titulo": "Regar as plantas",
    "feita": false
}
$ sqlite3 tarefas.sqlite "SELECT * FROM tarefas"
1|Comprar pão|1
2|Estudar PHP|0
3|Lavar a louça|0
4|Regar as plantas|0
  • 422 Unknown Status Code: o código chegou certo (422); só o nome ao lado é que o servidor de desenvolvimento do PHP não conhece. Programas olham o número, não o nome. Apache e nginx escrevem Unprocessable Content.
  • {titulo: Lavar} não é JSON (faltam as aspas duplas): 400.
  • O último POST mandou um corpo de formulário (-d sem Content-Type de JSON): caiu no $_POST e funcionou também.
  • O sqlite3 (o programa de terminal do SQLite) confirma o que está gravado: feita é 1/0 no banco e true/false na API.
Quando dá errado: confiar no corpo sem validar

Sem validarTitulo, usando $dados["titulo"] ?? "" direto, a API aceita qualquer coisa, inclusive um título vazio ou um campo com o nome errado:

$ curl -s localhost:8000/api.php/tarefas -H 'Content-Type: application/json' -d '{"titulo": ""}'
{
    "id": 3,
    "titulo": "",
    "feita": false
}
$ curl -s localhost:8000/api.php/tarefas -H 'Content-Type: application/json' -d '{"nome": "Lavar"}'
{
    "id": 4,
    "titulo": "",
    "feita": false
}

O banco agora tem tarefas sem título. Uma API nunca confia no cliente: valide tipo, presença e tamanho de tudo o que chega, e responda 422 explicando o problema.

Missão (etapa 3): a caixa faz um POST com o título vazio (titulo=) e a API o aceita. Escreva a função validarTitulo (texto, sem ser só espaços, até 100 letras) e use-a na rota, para responder 422 com {"erro": "O título é obrigatório"}.

<?php
function responder(int $status, mixed $dados = null): never
{
    http_response_code($status);
    if ($dados !== null) {
        header("Content-Type: application/json; charset=utf-8");
        echo json_encode($dados, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), "\n";
    }
    exit;
}

function lerCorpo(): array
{
    $tipo = $_SERVER["CONTENT_TYPE"] ?? "";
    if (str_starts_with($tipo, "application/json")) {
        $dados = json_decode(file_get_contents("php://input"), true);
        if (!is_array($dados)) {
            responder(400, ["erro" => "JSON inválido"]);
        }
        return $dados;
    }
    return $_POST;
}

function formatar(array $t): array
{
    return ["id" => $t["id"], "titulo" => $t["titulo"], "feita" => (bool) $t["feita"]];
}

function buscar(PDO $pdo, int $id): array
{
    $st = $pdo->prepare("SELECT * FROM tarefas WHERE id = ?");
    $st->execute([$id]);
    $tarefa = $st->fetch();
    if ($tarefa === false) {
        responder(404, ["erro" => "Tarefa $id não encontrada"]);
    }
    return $tarefa;
}

$pdo = require __DIR__ . "/banco.php";
$metodo = $_SERVER["REQUEST_METHOD"];
$caminho = $_SERVER["PATH_INFO"] ?? "/";

if ($caminho === "/tarefas" && $metodo === "GET") {
    $tarefas = $pdo->query("SELECT * FROM tarefas ORDER BY id")->fetchAll();
    responder(200, array_map("formatar", $tarefas));
}

if ($caminho === "/tarefas" && $metodo === "POST") {
    $dados = lerCorpo();
    $titulo = $dados["titulo"] ?? "";   // falta validar!
    $st = $pdo->prepare("INSERT INTO tarefas (titulo) VALUES (?)");
    $st->execute([$titulo]);
    $id = (int) $pdo->lastInsertId();
    header("Location: /api.php/tarefas/$id");
    responder(201, formatar(buscar($pdo, $id)));
}

if (preg_match('#^/tarefas/(\d+)$#', $caminho, $partes) && $metodo === "GET") {
    $id = (int) $partes[1];
    responder(200, formatar(buscar($pdo, $id)));
}

responder(404, ["erro" => "Rota não encontrada: $metodo $caminho"]);
<?php
$arquivo = __DIR__ . "/tarefas.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 tarefas (
        id     INTEGER PRIMARY KEY AUTOINCREMENT,
        titulo TEXT    NOT NULL,
        feita  INTEGER NOT NULL DEFAULT 0
    )");
    $pdo->exec("INSERT INTO tarefas (titulo, feita) VALUES ('Comprar pão', 1), ('Estudar PHP', 0)");
}

return $pdo;
<?php
function responder(int $status, mixed $dados = null): never
{
    http_response_code($status);
    if ($dados !== null) {
        header("Content-Type: application/json; charset=utf-8");
        echo json_encode($dados, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), "\n";
    }
    exit;
}

function lerCorpo(): array
{
    $tipo = $_SERVER["CONTENT_TYPE"] ?? "";
    if (str_starts_with($tipo, "application/json")) {
        $dados = json_decode(file_get_contents("php://input"), true);
        if (!is_array($dados)) {
            responder(400, ["erro" => "JSON inválido"]);
        }
        return $dados;
    }
    return $_POST;
}

function validarTitulo(mixed $titulo): string
{
    if (!is_string($titulo) || trim($titulo) === "") {
        responder(422, ["erro" => "O título é obrigatório"]);
    }
    $titulo = trim($titulo);
    if (mb_strlen($titulo) > 100) {
        responder(422, ["erro" => "O título pode ter no máximo 100 caracteres"]);
    }
    return $titulo;
}

function formatar(array $t): array
{
    return ["id" => $t["id"], "titulo" => $t["titulo"], "feita" => (bool) $t["feita"]];
}

function buscar(PDO $pdo, int $id): array
{
    $st = $pdo->prepare("SELECT * FROM tarefas WHERE id = ?");
    $st->execute([$id]);
    $tarefa = $st->fetch();
    if ($tarefa === false) {
        responder(404, ["erro" => "Tarefa $id não encontrada"]);
    }
    return $tarefa;
}

$pdo = require __DIR__ . "/banco.php";
$metodo = $_SERVER["REQUEST_METHOD"];
$caminho = $_SERVER["PATH_INFO"] ?? "/";

if ($caminho === "/tarefas" && $metodo === "GET") {
    $tarefas = $pdo->query("SELECT * FROM tarefas ORDER BY id")->fetchAll();
    responder(200, array_map("formatar", $tarefas));
}

if ($caminho === "/tarefas" && $metodo === "POST") {
    $dados = lerCorpo();
    $titulo = validarTitulo($dados["titulo"] ?? null);
    $st = $pdo->prepare("INSERT INTO tarefas (titulo) VALUES (?)");
    $st->execute([$titulo]);
    $id = (int) $pdo->lastInsertId();
    header("Location: /api.php/tarefas/$id");
    responder(201, formatar(buscar($pdo, $id)));
}

if (preg_match('#^/tarefas/(\d+)$#', $caminho, $partes) && $metodo === "GET") {
    $id = (int) $partes[1];
    responder(200, formatar(buscar($pdo, $id)));
}

responder(404, ["erro" => "Rota não encontrada: $metodo $caminho"]);

Etapa 4 · Atualizar com PUT e apagar com DELETE

As duas ações agem sobre uma tarefa, então ficam na rota /tarefas/{id}. Para organizar, cada caminho testa os métodos que aceita e, no fim, responde 405 para os outros:

if (preg_match('#^/tarefas/(\d+)$#', $caminho, $partes)) {
    $id = (int) $partes[1];
    if ($metodo === "GET") {
        responder(200, formatar(buscar($pdo, $id)));
    }
    if ($metodo === "PUT") {
        $tarefa = buscar($pdo, $id);
        $dados = lerCorpo();
        $titulo = array_key_exists("titulo", $dados) ? validarTitulo($dados["titulo"]) : $tarefa["titulo"];
        $feita = array_key_exists("feita", $dados) ? (bool) $dados["feita"] : (bool) $tarefa["feita"];
        $st = $pdo->prepare("UPDATE tarefas SET titulo = ?, feita = ? WHERE id = ?");
        $st->execute([$titulo, (int) $feita, $id]);
        responder(200, formatar(buscar($pdo, $id)));
    }
    if ($metodo === "DELETE") {
        buscar($pdo, $id);
        $st = $pdo->prepare("DELETE FROM tarefas WHERE id = ?");
        $st->execute([$id]);
        responder(204);
    }
    header("Allow: GET, PUT, DELETE");
    responder(405, ["erro" => "Método $metodo não permitido em $caminho"]);
}
TrechoO que faz
$tarefa = buscar($pdo, $id); (no PUT e no DELETE)Primeiro confere se a tarefa existe: se não, buscar já responde 404 e o programa para.
array_key_exists("titulo", $dados) ? … : $tarefa["titulo"]O cliente pode mandar só o que quer mudar. Campo ausente = mantém o valor atual (ternário).
(bool) $dados["feita"]Aceita true/false do JSON e "1"/"0" de formulário.
responder(204);Sem dados: 204 No Content, corpo vazio. (A nossa responder só escreve o JSON se $dados não for null.)
header("Allow: …"); responder(405, …)Método que este endereço não aceita. O cabeçalho Allow conta quais são aceitos. A rota /tarefas ganhou o mesmo final, com Allow: GET, POST.
PUT ou PATCH?

A rigor, PUT substitui o recurso inteiro e PATCH muda só uma parte, que é o que o nosso PUT faz. Muitas APIs aceitam os dois; aceitar PATCH também é um dos desafios.

$ curl -si -X PUT localhost:8000/api.php/tarefas/2 -H 'Content-Type: application/json' -d '{"feita": true}'
HTTP/1.1 200 OK
Host: localhost:8000
Date: Sat, 17 Oct 2026 00:04:18 GMT
Connection: close
X-Powered-By: PHP/8.5.8
Content-Type: application/json; charset=utf-8

{
    "id": 2,
    "titulo": "Estudar PHP",
    "feita": true
}
$ curl -s -X PUT localhost:8000/api.php/tarefas/2 -H 'Content-Type: application/json' -d '{"titulo": "Estudar PHP e SQL"}'
{
    "id": 2,
    "titulo": "Estudar PHP e SQL",
    "feita": true
}
$ curl -si -X DELETE localhost:8000/api.php/tarefas/1
HTTP/1.1 204 No Content
Host: localhost:8000
Date: Sat, 17 Oct 2026 00:04:18 GMT
Connection: close
X-Powered-By: PHP/8.5.8
Content-type: text/html; charset=UTF-8

$ curl -s -X DELETE localhost:8000/api.php/tarefas/1
{
    "erro": "Tarefa 1 não encontrada"
}
$ curl -si -X PATCH localhost:8000/api.php/tarefas/2
HTTP/1.1 405 Method Not Allowed
Host: localhost:8000
Date: Sat, 17 Oct 2026 00:04:18 GMT
Connection: close
X-Powered-By: PHP/8.5.8
Allow: GET, PUT, DELETE
Content-Type: application/json; charset=utf-8

{
    "erro": "Método PATCH não permitido em /tarefas/2"
}
$ curl -s -X DELETE localhost:8000/api.php/tarefas
{
    "erro": "Método DELETE não permitido em /tarefas"
}

Apagar duas vezes a mesma tarefa dá 404 na segunda: ela já não existe. (O Content-type: text/html do 204 é o padrão do PHP; como não há corpo, não faz diferença.)

O truque do _method (formulários e o editor do guia)

Formulários HTML só enviam GET e POST. Frameworks como Laravel resolvem isso com um campo oculto _method: um POST com _method=PUT é tratado como PUT. Três linhas, logo depois de ler o método:

$metodo = $_SERVER["REQUEST_METHOD"];
if ($metodo === "POST" && isset($_POST["_method"])) {
    $metodo = strtoupper($_POST["_method"]);   // formulários HTML só enviam GET e POST
}
$ curl -s localhost:8000/api.php/tarefas/2 -d '_method=PUT&feita=0'
{
    "id": 2,
    "titulo": "Estudar PHP e SQL",
    "feita": false
}
$ curl -s localhost:8000/api.php/tarefas
[
    {
        "id": 2,
        "titulo": "Estudar PHP e SQL",
        "feita": false
    }
]

Missão (etapa 4): a caixa envia _method=PUT&feita=1 para /api.php/tarefas/2, mas o PUT ainda não existe (a API responde 405). Escreva o bloco do PUT: busque a tarefa, leia o corpo, mantenha o que não veio, grave com UPDATE (prepared statement) e responda 200 com a tarefa atualizada ("feita": true).

<?php
// ---------- Funções de apoio ----------

function responder(int $status, mixed $dados = null): never
{
    http_response_code($status);
    if ($dados !== null) {
        header("Content-Type: application/json; charset=utf-8");
        echo json_encode($dados, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), "\n";
    }
    exit;
}

function lerCorpo(): array
{
    $tipo = $_SERVER["CONTENT_TYPE"] ?? "";
    if (str_starts_with($tipo, "application/json")) {
        $dados = json_decode(file_get_contents("php://input"), true);
        if (!is_array($dados)) {
            responder(400, ["erro" => "JSON inválido"]);
        }
        return $dados;
    }
    return $_POST;
}

function validarTitulo(mixed $titulo): string
{
    if (!is_string($titulo) || trim($titulo) === "") {
        responder(422, ["erro" => "O título é obrigatório"]);
    }
    $titulo = trim($titulo);
    if (mb_strlen($titulo) > 100) {
        responder(422, ["erro" => "O título pode ter no máximo 100 caracteres"]);
    }
    return $titulo;
}

function formatar(array $t): array
{
    return ["id" => $t["id"], "titulo" => $t["titulo"], "feita" => (bool) $t["feita"]];
}

function buscar(PDO $pdo, int $id): array
{
    $st = $pdo->prepare("SELECT * FROM tarefas WHERE id = ?");
    $st->execute([$id]);
    $tarefa = $st->fetch();
    if ($tarefa === false) {
        responder(404, ["erro" => "Tarefa $id não encontrada"]);
    }
    return $tarefa;
}

// ---------- A requisição ----------

$pdo = require __DIR__ . "/banco.php";

$metodo = $_SERVER["REQUEST_METHOD"];
if ($metodo === "POST" && isset($_POST["_method"])) {
    $metodo = strtoupper($_POST["_method"]);   // formulários HTML só enviam GET e POST
}
$caminho = $_SERVER["PATH_INFO"] ?? "/";

// ---------- Rotas ----------

if ($caminho === "/tarefas") {
    if ($metodo === "GET") {
        $tarefas = $pdo->query("SELECT * FROM tarefas ORDER BY id")->fetchAll();
        responder(200, array_map("formatar", $tarefas));
    }
    if ($metodo === "POST") {
        $dados = lerCorpo();
        $titulo = validarTitulo($dados["titulo"] ?? null);
        $st = $pdo->prepare("INSERT INTO tarefas (titulo) VALUES (?)");
        $st->execute([$titulo]);
        $id = (int) $pdo->lastInsertId();
        header("Location: /api.php/tarefas/$id");
        responder(201, formatar(buscar($pdo, $id)));
    }
    header("Allow: GET, POST");
    responder(405, ["erro" => "Método $metodo não permitido em $caminho"]);
}

if (preg_match('#^/tarefas/(\d+)$#', $caminho, $partes)) {
    $id = (int) $partes[1];
    if ($metodo === "GET") {
        responder(200, formatar(buscar($pdo, $id)));
    }
    // PUT: atualize "titulo" e/ou "feita" e responda 200 com a tarefa atualizada
    if ($metodo === "DELETE") {
        buscar($pdo, $id);
        $st = $pdo->prepare("DELETE FROM tarefas WHERE id = ?");
        $st->execute([$id]);
        responder(204);
    }
    header("Allow: GET, PUT, DELETE");
    responder(405, ["erro" => "Método $metodo não permitido em $caminho"]);
}

responder(404, ["erro" => "Rota não encontrada: $metodo $caminho"]);
<?php
$arquivo = __DIR__ . "/tarefas.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 tarefas (
        id     INTEGER PRIMARY KEY AUTOINCREMENT,
        titulo TEXT    NOT NULL,
        feita  INTEGER NOT NULL DEFAULT 0
    )");
    $pdo->exec("INSERT INTO tarefas (titulo, feita) VALUES ('Comprar pão', 1), ('Estudar PHP', 0)");
}

return $pdo;
<?php
// ---------- Funções de apoio ----------

function responder(int $status, mixed $dados = null): never
{
    http_response_code($status);
    if ($dados !== null) {
        header("Content-Type: application/json; charset=utf-8");
        echo json_encode($dados, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), "\n";
    }
    exit;
}

function lerCorpo(): array
{
    $tipo = $_SERVER["CONTENT_TYPE"] ?? "";
    if (str_starts_with($tipo, "application/json")) {
        $dados = json_decode(file_get_contents("php://input"), true);
        if (!is_array($dados)) {
            responder(400, ["erro" => "JSON inválido"]);
        }
        return $dados;
    }
    return $_POST;
}

function validarTitulo(mixed $titulo): string
{
    if (!is_string($titulo) || trim($titulo) === "") {
        responder(422, ["erro" => "O título é obrigatório"]);
    }
    $titulo = trim($titulo);
    if (mb_strlen($titulo) > 100) {
        responder(422, ["erro" => "O título pode ter no máximo 100 caracteres"]);
    }
    return $titulo;
}

function formatar(array $t): array
{
    return ["id" => $t["id"], "titulo" => $t["titulo"], "feita" => (bool) $t["feita"]];
}

function buscar(PDO $pdo, int $id): array
{
    $st = $pdo->prepare("SELECT * FROM tarefas WHERE id = ?");
    $st->execute([$id]);
    $tarefa = $st->fetch();
    if ($tarefa === false) {
        responder(404, ["erro" => "Tarefa $id não encontrada"]);
    }
    return $tarefa;
}

// ---------- A requisição ----------

$pdo = require __DIR__ . "/banco.php";

$metodo = $_SERVER["REQUEST_METHOD"];
if ($metodo === "POST" && isset($_POST["_method"])) {
    $metodo = strtoupper($_POST["_method"]);   // formulários HTML só enviam GET e POST
}
$caminho = $_SERVER["PATH_INFO"] ?? "/";

// ---------- Rotas ----------

if ($caminho === "/tarefas") {
    if ($metodo === "GET") {
        $tarefas = $pdo->query("SELECT * FROM tarefas ORDER BY id")->fetchAll();
        responder(200, array_map("formatar", $tarefas));
    }
    if ($metodo === "POST") {
        $dados = lerCorpo();
        $titulo = validarTitulo($dados["titulo"] ?? null);
        $st = $pdo->prepare("INSERT INTO tarefas (titulo) VALUES (?)");
        $st->execute([$titulo]);
        $id = (int) $pdo->lastInsertId();
        header("Location: /api.php/tarefas/$id");
        responder(201, formatar(buscar($pdo, $id)));
    }
    header("Allow: GET, POST");
    responder(405, ["erro" => "Método $metodo não permitido em $caminho"]);
}

if (preg_match('#^/tarefas/(\d+)$#', $caminho, $partes)) {
    $id = (int) $partes[1];
    if ($metodo === "GET") {
        responder(200, formatar(buscar($pdo, $id)));
    }
    if ($metodo === "PUT") {
        $tarefa = buscar($pdo, $id);
        $dados = lerCorpo();
        $titulo = array_key_exists("titulo", $dados) ? validarTitulo($dados["titulo"]) : $tarefa["titulo"];
        $feita = array_key_exists("feita", $dados) ? (bool) $dados["feita"] : (bool) $tarefa["feita"];
        $st = $pdo->prepare("UPDATE tarefas SET titulo = ?, feita = ? WHERE id = ?");
        $st->execute([$titulo, (int) $feita, $id]);
        responder(200, formatar(buscar($pdo, $id)));
    }
    if ($metodo === "DELETE") {
        buscar($pdo, $id);
        $st = $pdo->prepare("DELETE FROM tarefas WHERE id = ?");
        $st->execute([$id]);
        responder(204);
    }
    header("Allow: GET, PUT, DELETE");
    responder(405, ["erro" => "Método $metodo não permitido em $caminho"]);
}

responder(404, ["erro" => "Rota não encontrada: $metodo $caminho"]);

Etapa 5 · Erros inesperados

Erros que nós prevemos já viram JSON (400, 404, 405, 422). Mas e um erro no nosso código, como um nome de tabela digitado errado (tarefa em vez de tarefas)?

$ curl -si localhost:8000/api.php/tarefas/1
HTTP/1.1 200 OK
Host: localhost:8000
Date: Sat, 17 Oct 2026 00:08:39 GMT
Connection: close
X-Powered-By: PHP/8.5.8
Content-type: text/html; charset=UTF-8

<br />
<b>Fatal error</b>:  Uncaught PDOException: SQLSTATE[HY000]: General error: 1 no such table: tarefa in /home/ana/api/api.php:46
Stack trace:
#0 /home/ana/api/api.php(46): PDO-&gt;prepare('SELECT * FROM t...')
#1 /home/ana/api/api.php(88): buscar(Object(PDO), 1)
#2 {main}
  thrown in <b>/home/ana/api/api.php</b> on line <b>46</b><br />

Três problemas: o cliente recebe HTML em vez de JSON, o status diz 200 (sucesso!) e a mensagem revela detalhes internos (caminhos de arquivos, o SQL, nomes de tabelas) que ajudam um atacante. O conserto é um "pega-tudo": set_exception_handler registra uma função que roda quando uma exceção não é capturada por nenhum try/catch (Exceções):

set_exception_handler(function (Throwable $e) {
    error_log($e->getMessage());
    responder(500, ["erro" => "Erro interno no servidor"]);
});
TrechoO que faz
function (Throwable $e) { … }Uma função anônima (aula) que recebe a exceção. Throwable pega exceções e erros (TypeError, PDOException…).
error_log($e->getMessage());Guarda a mensagem verdadeira no log do servidor, para você (no php -S, ela aparece no terminal dele).
responder(500, …)Para o cliente, só uma mensagem genérica, em JSON, com o status certo.
$ curl -si localhost:8000/api.php/tarefas/1
HTTP/1.1 500 Internal Server Error
Host: localhost:8000
Date: Sat, 17 Oct 2026 00:04:32 GMT
Connection: close
X-Powered-By: PHP/8.5.8
Content-Type: application/json; charset=utf-8

{
    "erro": "Erro interno no servidor"
}

E, no terminal do servidor, a mensagem real, só para você:

$ php -S localhost:8000
[Fri Oct 16 21:04:31 2026] PHP 8.5.8 Development Server (http://localhost:8000) started
[Fri Oct 16 21:04:31 2026] 127.0.0.1:59032 Accepted
[Fri Oct 16 21:04:32 2026] SQLSTATE[HY000]: General error: 1 no such table: tarefa
[Fri Oct 16 21:04:32 2026] 127.0.0.1:59032 [500]: GET /api.php/tarefas/1
[Fri Oct 16 21:04:32 2026] 127.0.0.1:59032 Closing

Num servidor de verdade, além disso, display_errors fica desligado (Depurar), e nenhum erro vaza na resposta.

Missão (etapa 5): o buscar desta caixa tem o erro de digitação de propósito (FROM tarefa). Não conserte o SQL ainda: acrescente o set_exception_handler para que a resposta seja {"erro": "Erro interno no servidor"} com status 500. Depois, conserte o nome da tabela e veja a tarefa 1 aparecer.

<?php
// ---------- Funções de apoio ----------

function responder(int $status, mixed $dados = null): never
{
    http_response_code($status);
    if ($dados !== null) {
        header("Content-Type: application/json; charset=utf-8");
        echo json_encode($dados, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), "\n";
    }
    exit;
}

function lerCorpo(): array
{
    $tipo = $_SERVER["CONTENT_TYPE"] ?? "";
    if (str_starts_with($tipo, "application/json")) {
        $dados = json_decode(file_get_contents("php://input"), true);
        if (!is_array($dados)) {
            responder(400, ["erro" => "JSON inválido"]);
        }
        return $dados;
    }
    return $_POST;
}

function validarTitulo(mixed $titulo): string
{
    if (!is_string($titulo) || trim($titulo) === "") {
        responder(422, ["erro" => "O título é obrigatório"]);
    }
    $titulo = trim($titulo);
    if (mb_strlen($titulo) > 100) {
        responder(422, ["erro" => "O título pode ter no máximo 100 caracteres"]);
    }
    return $titulo;
}

function formatar(array $t): array
{
    return ["id" => $t["id"], "titulo" => $t["titulo"], "feita" => (bool) $t["feita"]];
}

function buscar(PDO $pdo, int $id): array
{
    $st = $pdo->prepare("SELECT * FROM tarefa WHERE id = ?");
    $st->execute([$id]);
    $tarefa = $st->fetch();
    if ($tarefa === false) {
        responder(404, ["erro" => "Tarefa $id não encontrada"]);
    }
    return $tarefa;
}

// ---------- A requisição ----------

$pdo = require __DIR__ . "/banco.php";

$metodo = $_SERVER["REQUEST_METHOD"];
if ($metodo === "POST" && isset($_POST["_method"])) {
    $metodo = strtoupper($_POST["_method"]);   // formulários HTML só enviam GET e POST
}
$caminho = $_SERVER["PATH_INFO"] ?? "/";

// ---------- Rotas ----------

if ($caminho === "/tarefas") {
    if ($metodo === "GET") {
        $tarefas = $pdo->query("SELECT * FROM tarefas ORDER BY id")->fetchAll();
        responder(200, array_map("formatar", $tarefas));
    }
    if ($metodo === "POST") {
        $dados = lerCorpo();
        $titulo = validarTitulo($dados["titulo"] ?? null);
        $st = $pdo->prepare("INSERT INTO tarefas (titulo) VALUES (?)");
        $st->execute([$titulo]);
        $id = (int) $pdo->lastInsertId();
        header("Location: /api.php/tarefas/$id");
        responder(201, formatar(buscar($pdo, $id)));
    }
    header("Allow: GET, POST");
    responder(405, ["erro" => "Método $metodo não permitido em $caminho"]);
}

if (preg_match('#^/tarefas/(\d+)$#', $caminho, $partes)) {
    $id = (int) $partes[1];
    if ($metodo === "GET") {
        responder(200, formatar(buscar($pdo, $id)));
    }
    if ($metodo === "PUT") {
        $tarefa = buscar($pdo, $id);
        $dados = lerCorpo();
        $titulo = array_key_exists("titulo", $dados) ? validarTitulo($dados["titulo"]) : $tarefa["titulo"];
        $feita = array_key_exists("feita", $dados) ? (bool) $dados["feita"] : (bool) $tarefa["feita"];
        $st = $pdo->prepare("UPDATE tarefas SET titulo = ?, feita = ? WHERE id = ?");
        $st->execute([$titulo, (int) $feita, $id]);
        responder(200, formatar(buscar($pdo, $id)));
    }
    if ($metodo === "DELETE") {
        buscar($pdo, $id);
        $st = $pdo->prepare("DELETE FROM tarefas WHERE id = ?");
        $st->execute([$id]);
        responder(204);
    }
    header("Allow: GET, PUT, DELETE");
    responder(405, ["erro" => "Método $metodo não permitido em $caminho"]);
}

responder(404, ["erro" => "Rota não encontrada: $metodo $caminho"]);
<?php
$arquivo = __DIR__ . "/tarefas.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 tarefas (
        id     INTEGER PRIMARY KEY AUTOINCREMENT,
        titulo TEXT    NOT NULL,
        feita  INTEGER NOT NULL DEFAULT 0
    )");
    $pdo->exec("INSERT INTO tarefas (titulo, feita) VALUES ('Comprar pão', 1), ('Estudar PHP', 0)");
}

return $pdo;
<?php
// ---------- Funções de apoio ----------

function responder(int $status, mixed $dados = null): never
{
    http_response_code($status);
    if ($dados !== null) {
        header("Content-Type: application/json; charset=utf-8");
        echo json_encode($dados, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), "\n";
    }
    exit;
}

function lerCorpo(): array
{
    $tipo = $_SERVER["CONTENT_TYPE"] ?? "";
    if (str_starts_with($tipo, "application/json")) {
        $dados = json_decode(file_get_contents("php://input"), true);
        if (!is_array($dados)) {
            responder(400, ["erro" => "JSON inválido"]);
        }
        return $dados;
    }
    return $_POST;
}

function validarTitulo(mixed $titulo): string
{
    if (!is_string($titulo) || trim($titulo) === "") {
        responder(422, ["erro" => "O título é obrigatório"]);
    }
    $titulo = trim($titulo);
    if (mb_strlen($titulo) > 100) {
        responder(422, ["erro" => "O título pode ter no máximo 100 caracteres"]);
    }
    return $titulo;
}

function formatar(array $t): array
{
    return ["id" => $t["id"], "titulo" => $t["titulo"], "feita" => (bool) $t["feita"]];
}

function buscar(PDO $pdo, int $id): array
{
    $st = $pdo->prepare("SELECT * FROM tarefa WHERE id = ?");
    $st->execute([$id]);
    $tarefa = $st->fetch();
    if ($tarefa === false) {
        responder(404, ["erro" => "Tarefa $id não encontrada"]);
    }
    return $tarefa;
}

// ---------- Erros inesperados viram 500 em JSON ----------

set_exception_handler(function (Throwable $e) {
    error_log($e->getMessage());
    responder(500, ["erro" => "Erro interno no servidor"]);
});

// ---------- A requisição ----------

$pdo = require __DIR__ . "/banco.php";

$metodo = $_SERVER["REQUEST_METHOD"];
if ($metodo === "POST" && isset($_POST["_method"])) {
    $metodo = strtoupper($_POST["_method"]);   // formulários HTML só enviam GET e POST
}
$caminho = $_SERVER["PATH_INFO"] ?? "/";

// ---------- Rotas ----------

if ($caminho === "/tarefas") {
    if ($metodo === "GET") {
        $tarefas = $pdo->query("SELECT * FROM tarefas ORDER BY id")->fetchAll();
        responder(200, array_map("formatar", $tarefas));
    }
    if ($metodo === "POST") {
        $dados = lerCorpo();
        $titulo = validarTitulo($dados["titulo"] ?? null);
        $st = $pdo->prepare("INSERT INTO tarefas (titulo) VALUES (?)");
        $st->execute([$titulo]);
        $id = (int) $pdo->lastInsertId();
        header("Location: /api.php/tarefas/$id");
        responder(201, formatar(buscar($pdo, $id)));
    }
    header("Allow: GET, POST");
    responder(405, ["erro" => "Método $metodo não permitido em $caminho"]);
}

if (preg_match('#^/tarefas/(\d+)$#', $caminho, $partes)) {
    $id = (int) $partes[1];
    if ($metodo === "GET") {
        responder(200, formatar(buscar($pdo, $id)));
    }
    if ($metodo === "PUT") {
        $tarefa = buscar($pdo, $id);
        $dados = lerCorpo();
        $titulo = array_key_exists("titulo", $dados) ? validarTitulo($dados["titulo"]) : $tarefa["titulo"];
        $feita = array_key_exists("feita", $dados) ? (bool) $dados["feita"] : (bool) $tarefa["feita"];
        $st = $pdo->prepare("UPDATE tarefas SET titulo = ?, feita = ? WHERE id = ?");
        $st->execute([$titulo, (int) $feita, $id]);
        responder(200, formatar(buscar($pdo, $id)));
    }
    if ($metodo === "DELETE") {
        buscar($pdo, $id);
        $st = $pdo->prepare("DELETE FROM tarefas WHERE id = ?");
        $st->execute([$id]);
        responder(204);
    }
    header("Allow: GET, PUT, DELETE");
    responder(405, ["erro" => "Método $metodo não permitido em $caminho"]);
}

responder(404, ["erro" => "Rota não encontrada: $metodo $caminho"]);

O código completo

Dois arquivos na pasta ~/api. Para rodar: php -S localhost:8000 nessa pasta e os comandos curl desta página em outro terminal.

<?php
$arquivo = __DIR__ . "/tarefas.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 tarefas (
        id     INTEGER PRIMARY KEY AUTOINCREMENT,
        titulo TEXT    NOT NULL,
        feita  INTEGER NOT NULL DEFAULT 0
    )");
    $pdo->exec("INSERT INTO tarefas (titulo, feita) VALUES ('Comprar pão', 1), ('Estudar PHP', 0)");
}

return $pdo;
<?php
// ---------- Funções de apoio ----------

function responder(int $status, mixed $dados = null): never
{
    http_response_code($status);
    if ($dados !== null) {
        header("Content-Type: application/json; charset=utf-8");
        echo json_encode($dados, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), "\n";
    }
    exit;
}

function lerCorpo(): array
{
    $tipo = $_SERVER["CONTENT_TYPE"] ?? "";
    if (str_starts_with($tipo, "application/json")) {
        $dados = json_decode(file_get_contents("php://input"), true);
        if (!is_array($dados)) {
            responder(400, ["erro" => "JSON inválido"]);
        }
        return $dados;
    }
    return $_POST;
}

function validarTitulo(mixed $titulo): string
{
    if (!is_string($titulo) || trim($titulo) === "") {
        responder(422, ["erro" => "O título é obrigatório"]);
    }
    $titulo = trim($titulo);
    if (mb_strlen($titulo) > 100) {
        responder(422, ["erro" => "O título pode ter no máximo 100 caracteres"]);
    }
    return $titulo;
}

function formatar(array $t): array
{
    return ["id" => $t["id"], "titulo" => $t["titulo"], "feita" => (bool) $t["feita"]];
}

function buscar(PDO $pdo, int $id): array
{
    $st = $pdo->prepare("SELECT * FROM tarefas WHERE id = ?");
    $st->execute([$id]);
    $tarefa = $st->fetch();
    if ($tarefa === false) {
        responder(404, ["erro" => "Tarefa $id não encontrada"]);
    }
    return $tarefa;
}

// ---------- Erros inesperados viram 500 em JSON ----------

set_exception_handler(function (Throwable $e) {
    error_log($e->getMessage());
    responder(500, ["erro" => "Erro interno no servidor"]);
});

// ---------- A requisição ----------

$pdo = require __DIR__ . "/banco.php";

$metodo = $_SERVER["REQUEST_METHOD"];
if ($metodo === "POST" && isset($_POST["_method"])) {
    $metodo = strtoupper($_POST["_method"]);   // formulários HTML só enviam GET e POST
}
$caminho = $_SERVER["PATH_INFO"] ?? "/";

// ---------- Rotas ----------

if ($caminho === "/tarefas") {
    if ($metodo === "GET") {
        $tarefas = $pdo->query("SELECT * FROM tarefas ORDER BY id")->fetchAll();
        responder(200, array_map("formatar", $tarefas));
    }
    if ($metodo === "POST") {
        $dados = lerCorpo();
        $titulo = validarTitulo($dados["titulo"] ?? null);
        $st = $pdo->prepare("INSERT INTO tarefas (titulo) VALUES (?)");
        $st->execute([$titulo]);
        $id = (int) $pdo->lastInsertId();
        header("Location: /api.php/tarefas/$id");
        responder(201, formatar(buscar($pdo, $id)));
    }
    header("Allow: GET, POST");
    responder(405, ["erro" => "Método $metodo não permitido em $caminho"]);
}

if (preg_match('#^/tarefas/(\d+)$#', $caminho, $partes)) {
    $id = (int) $partes[1];
    if ($metodo === "GET") {
        responder(200, formatar(buscar($pdo, $id)));
    }
    if ($metodo === "PUT") {
        $tarefa = buscar($pdo, $id);
        $dados = lerCorpo();
        $titulo = array_key_exists("titulo", $dados) ? validarTitulo($dados["titulo"]) : $tarefa["titulo"];
        $feita = array_key_exists("feita", $dados) ? (bool) $dados["feita"] : (bool) $tarefa["feita"];
        $st = $pdo->prepare("UPDATE tarefas SET titulo = ?, feita = ? WHERE id = ?");
        $st->execute([$titulo, (int) $feita, $id]);
        responder(200, formatar(buscar($pdo, $id)));
    }
    if ($metodo === "DELETE") {
        buscar($pdo, $id);
        $st = $pdo->prepare("DELETE FROM tarefas WHERE id = ?");
        $st->execute([$id]);
        responder(204);
    }
    header("Allow: GET, PUT, DELETE");
    responder(405, ["erro" => "Método $metodo não permitido em $caminho"]);
}

responder(404, ["erro" => "Rota não encontrada: $metodo $caminho"]);
Por que o endereço tem api.php no meio?

Sem configurar nada, o php -S procura um arquivo para cada endereço; o PATH_INFO nos deixa usar um arquivo só. Em produção, o Apache ou o nginx são configurados para mandar todos os endereços para um index.php (o "front controller"), e a API fica com endereços limpos como /tarefas/2. No php -S, o mesmo se faz passando um "roteador": php -S localhost:8000 api.php, e lendo o caminho de parse_url($_SERVER["REQUEST_URI"], PHP_URL_PATH). É um dos desafios.

Desafios extras

  1. Filtro. GET /tarefas?feita=0 lista só as pendentes, ?feita=1 só as feitas (missão abaixo).
  2. PATCH. Aceite PATCH como sinônimo do PUT (in_array($metodo, ["PUT", "PATCH"])) e atualize o cabeçalho Allow.
  3. Data de criação. Uma coluna criada_em TEXT preenchida com date("c") no POST e devolvida no JSON (Datas).
  4. Paginação. ?pagina=2 com 10 por página: LIMIT ? OFFSET ? no SQL (como no projeto do blog).
  5. Senha da API. Exija um cabeçalho X-Token ($_SERVER["HTTP_X_TOKEN"]) igual a um valor guardado em getenv("API_TOKEN"); sem ele, 401. Compare com hash_equals.
  6. Endereços limpos. Rode com php -S localhost:8000 api.php e troque PATH_INFO por parse_url($_SERVER["REQUEST_URI"], PHP_URL_PATH), para atender /tarefas/2.
  7. Um cliente. Uma página HTML com JavaScript (fetch) ou um script PHP que use a API. Veja Consumir APIs.

Missão final: a caixa pede GET /api.php/tarefas?feita=0, mas a API devolve todas as tarefas. Quando $_GET["feita"] existir, liste só as tarefas com aquele valor (prepared statement com WHERE feita = ? e (int) $_GET["feita"]). Teste também ?feita=1 e sem filtro.

<?php
// ---------- Funções de apoio ----------

function responder(int $status, mixed $dados = null): never
{
    http_response_code($status);
    if ($dados !== null) {
        header("Content-Type: application/json; charset=utf-8");
        echo json_encode($dados, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), "\n";
    }
    exit;
}

function lerCorpo(): array
{
    $tipo = $_SERVER["CONTENT_TYPE"] ?? "";
    if (str_starts_with($tipo, "application/json")) {
        $dados = json_decode(file_get_contents("php://input"), true);
        if (!is_array($dados)) {
            responder(400, ["erro" => "JSON inválido"]);
        }
        return $dados;
    }
    return $_POST;
}

function validarTitulo(mixed $titulo): string
{
    if (!is_string($titulo) || trim($titulo) === "") {
        responder(422, ["erro" => "O título é obrigatório"]);
    }
    $titulo = trim($titulo);
    if (mb_strlen($titulo) > 100) {
        responder(422, ["erro" => "O título pode ter no máximo 100 caracteres"]);
    }
    return $titulo;
}

function formatar(array $t): array
{
    return ["id" => $t["id"], "titulo" => $t["titulo"], "feita" => (bool) $t["feita"]];
}

function buscar(PDO $pdo, int $id): array
{
    $st = $pdo->prepare("SELECT * FROM tarefas WHERE id = ?");
    $st->execute([$id]);
    $tarefa = $st->fetch();
    if ($tarefa === false) {
        responder(404, ["erro" => "Tarefa $id não encontrada"]);
    }
    return $tarefa;
}

// ---------- Erros inesperados viram 500 em JSON ----------

set_exception_handler(function (Throwable $e) {
    error_log($e->getMessage());
    responder(500, ["erro" => "Erro interno no servidor"]);
});

// ---------- A requisição ----------

$pdo = require __DIR__ . "/banco.php";

$metodo = $_SERVER["REQUEST_METHOD"];
if ($metodo === "POST" && isset($_POST["_method"])) {
    $metodo = strtoupper($_POST["_method"]);   // formulários HTML só enviam GET e POST
}
$caminho = $_SERVER["PATH_INFO"] ?? "/";

// ---------- Rotas ----------

if ($caminho === "/tarefas") {
    if ($metodo === "GET") {
        $tarefas = $pdo->query("SELECT * FROM tarefas ORDER BY id")->fetchAll();
        responder(200, array_map("formatar", $tarefas));
    }
    if ($metodo === "POST") {
        $dados = lerCorpo();
        $titulo = validarTitulo($dados["titulo"] ?? null);
        $st = $pdo->prepare("INSERT INTO tarefas (titulo) VALUES (?)");
        $st->execute([$titulo]);
        $id = (int) $pdo->lastInsertId();
        header("Location: /api.php/tarefas/$id");
        responder(201, formatar(buscar($pdo, $id)));
    }
    header("Allow: GET, POST");
    responder(405, ["erro" => "Método $metodo não permitido em $caminho"]);
}

if (preg_match('#^/tarefas/(\d+)$#', $caminho, $partes)) {
    $id = (int) $partes[1];
    if ($metodo === "GET") {
        responder(200, formatar(buscar($pdo, $id)));
    }
    if ($metodo === "PUT") {
        $tarefa = buscar($pdo, $id);
        $dados = lerCorpo();
        $titulo = array_key_exists("titulo", $dados) ? validarTitulo($dados["titulo"]) : $tarefa["titulo"];
        $feita = array_key_exists("feita", $dados) ? (bool) $dados["feita"] : (bool) $tarefa["feita"];
        $st = $pdo->prepare("UPDATE tarefas SET titulo = ?, feita = ? WHERE id = ?");
        $st->execute([$titulo, (int) $feita, $id]);
        responder(200, formatar(buscar($pdo, $id)));
    }
    if ($metodo === "DELETE") {
        buscar($pdo, $id);
        $st = $pdo->prepare("DELETE FROM tarefas WHERE id = ?");
        $st->execute([$id]);
        responder(204);
    }
    header("Allow: GET, PUT, DELETE");
    responder(405, ["erro" => "Método $metodo não permitido em $caminho"]);
}

responder(404, ["erro" => "Rota não encontrada: $metodo $caminho"]);
<?php
$arquivo = __DIR__ . "/tarefas.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 tarefas (
        id     INTEGER PRIMARY KEY AUTOINCREMENT,
        titulo TEXT    NOT NULL,
        feita  INTEGER NOT NULL DEFAULT 0
    )");
    $pdo->exec("INSERT INTO tarefas (titulo, feita) VALUES ('Comprar pão', 1), ('Estudar PHP', 0)");
}

return $pdo;
<?php
// ---------- Funções de apoio ----------

function responder(int $status, mixed $dados = null): never
{
    http_response_code($status);
    if ($dados !== null) {
        header("Content-Type: application/json; charset=utf-8");
        echo json_encode($dados, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), "\n";
    }
    exit;
}

function lerCorpo(): array
{
    $tipo = $_SERVER["CONTENT_TYPE"] ?? "";
    if (str_starts_with($tipo, "application/json")) {
        $dados = json_decode(file_get_contents("php://input"), true);
        if (!is_array($dados)) {
            responder(400, ["erro" => "JSON inválido"]);
        }
        return $dados;
    }
    return $_POST;
}

function validarTitulo(mixed $titulo): string
{
    if (!is_string($titulo) || trim($titulo) === "") {
        responder(422, ["erro" => "O título é obrigatório"]);
    }
    $titulo = trim($titulo);
    if (mb_strlen($titulo) > 100) {
        responder(422, ["erro" => "O título pode ter no máximo 100 caracteres"]);
    }
    return $titulo;
}

function formatar(array $t): array
{
    return ["id" => $t["id"], "titulo" => $t["titulo"], "feita" => (bool) $t["feita"]];
}

function buscar(PDO $pdo, int $id): array
{
    $st = $pdo->prepare("SELECT * FROM tarefas WHERE id = ?");
    $st->execute([$id]);
    $tarefa = $st->fetch();
    if ($tarefa === false) {
        responder(404, ["erro" => "Tarefa $id não encontrada"]);
    }
    return $tarefa;
}

// ---------- Erros inesperados viram 500 em JSON ----------

set_exception_handler(function (Throwable $e) {
    error_log($e->getMessage());
    responder(500, ["erro" => "Erro interno no servidor"]);
});

// ---------- A requisição ----------

$pdo = require __DIR__ . "/banco.php";

$metodo = $_SERVER["REQUEST_METHOD"];
if ($metodo === "POST" && isset($_POST["_method"])) {
    $metodo = strtoupper($_POST["_method"]);   // formulários HTML só enviam GET e POST
}
$caminho = $_SERVER["PATH_INFO"] ?? "/";

// ---------- Rotas ----------

if ($caminho === "/tarefas") {
    if ($metodo === "GET") {
        if (isset($_GET["feita"])) {
            $st = $pdo->prepare("SELECT * FROM tarefas WHERE feita = ? ORDER BY id");
            $st->execute([(int) $_GET["feita"]]);
            $tarefas = $st->fetchAll();
        } else {
            $tarefas = $pdo->query("SELECT * FROM tarefas ORDER BY id")->fetchAll();
        }
        responder(200, array_map("formatar", $tarefas));
    }
    if ($metodo === "POST") {
        $dados = lerCorpo();
        $titulo = validarTitulo($dados["titulo"] ?? null);
        $st = $pdo->prepare("INSERT INTO tarefas (titulo) VALUES (?)");
        $st->execute([$titulo]);
        $id = (int) $pdo->lastInsertId();
        header("Location: /api.php/tarefas/$id");
        responder(201, formatar(buscar($pdo, $id)));
    }
    header("Allow: GET, POST");
    responder(405, ["erro" => "Método $metodo não permitido em $caminho"]);
}

if (preg_match('#^/tarefas/(\d+)$#', $caminho, $partes)) {
    $id = (int) $partes[1];
    if ($metodo === "GET") {
        responder(200, formatar(buscar($pdo, $id)));
    }
    if ($metodo === "PUT") {
        $tarefa = buscar($pdo, $id);
        $dados = lerCorpo();
        $titulo = array_key_exists("titulo", $dados) ? validarTitulo($dados["titulo"]) : $tarefa["titulo"];
        $feita = array_key_exists("feita", $dados) ? (bool) $dados["feita"] : (bool) $tarefa["feita"];
        $st = $pdo->prepare("UPDATE tarefas SET titulo = ?, feita = ? WHERE id = ?");
        $st->execute([$titulo, (int) $feita, $id]);
        responder(200, formatar(buscar($pdo, $id)));
    }
    if ($metodo === "DELETE") {
        buscar($pdo, $id);
        $st = $pdo->prepare("DELETE FROM tarefas WHERE id = ?");
        $st->execute([$id]);
        responder(204);
    }
    header("Allow: GET, PUT, DELETE");
    responder(405, ["erro" => "Método $metodo não permitido em $caminho"]);
}

responder(404, ["erro" => "Rota não encontrada: $metodo $caminho"]);
$ curl -s 'localhost:8000/api.php/tarefas?feita=0'
[
    {
        "id": 2,
        "titulo": "Estudar PHP",
        "feita": false
    }
]
$ curl -s 'localhost:8000/api.php/tarefas?feita=1'
[
    {
        "id": 1,
        "titulo": "Comprar pão",
        "feita": true
    }
]

Exercícios

1. Qual o método e o código de status certos para criar uma tarefa?

2. O cliente manda Content-Type: application/json com {"titulo": "Ler"}. Onde o PHP coloca esse corpo?

3. O título chegou vazio. Qual código a API responde?

4. Por que o set_exception_handler responde só "Erro interno no servidor" e não a mensagem da exceção?

Complete para definir o código de status da resposta:

(404);

Complete a opção do json_encode que mantém os acentos:

json_encode($dados, JSON_)

Resumo

  • Uma API responde dados: header("Content-Type: application/json; charset=utf-8") + json_encode (com JSON_UNESCAPED_UNICODE) + http_response_code.
  • A rota é o método (REQUEST_METHOD) + o caminho (PATH_INFO); preg_match extrai o id.
  • GET lê, POST cria (201 + Location), PUT altera (200), DELETE apaga (204). Erros do cliente: 400, 404, 405, 422; nossos: 500.
  • Corpo JSON não vai para $_POST: json_decode(file_get_contents("php://input"), true). Valide tudo.
  • Banco sempre com prepared statements; fetch() pode dar false.
  • set_exception_handler transforma qualquer erro inesperado num 500 em JSON, e a mensagem real vai para o log.

O próximo passo natural é escrever um programa que usa uma API: veja Consumir APIs. Para consultar códigos e funções, use a Referência.