PHP CRUD com PDO

Quase todo sistema que guarda dados faz só quatro coisas com eles: cria, lê, atualiza e apaga. Em inglês, Create, Read, Update, Delete: as iniciais formam CRUD, a palavra que todo programador usa para isso. Nesta aula, a Ana vai montar o cadastro de produtos da lojinha com PDO e SQLite: um programa para cada uma das quatro operações e, no fim, uma página web de cadastro.

Você vai aprender
  • O que é CRUD e qual comando SQL faz cada letra.
  • Como cadastrar com INSERT e descobrir o id novo com lastInsertId().
  • Como listar com fetchAll() e buscar um registro só com fetch() (e o que fazer quando ele não existe).
  • Como atualizar com UPDATE e apagar com DELETE, conferindo com rowCount() quantas linhas mudaram.
  • Por que o WHERE é a parte mais importante do UPDATE e do DELETE.
  • Como juntar tudo numa página web com formulário.

Antes, veja Banco de Dados: o Básico (o que são tabelas e SQL) e Conectar com PDO (new PDO, query, exec, fetch). Os programas foram executados com o PHP 8.5 na máquina fictícia da Ana, na pasta ~/loja, e cada sessão de terminal continua a anterior: o banco vai mudando ao longo da aula.


A ideia: o fichário da loja

Antes do computador, a Ana tinha um fichário com uma ficha por produto. O que ela fazia com ele?

LetraInglêsNo fichárioNo SQLNo PDO
CCreate (criar)Escrever uma ficha nova e numerá-laINSERTprepare + execute, lastInsertId()
RRead (ler)Folhear todas, ou puxar a ficha nº 3SELECTquery/execute + fetchAll()/fetch()
UUpdate (atualizar)Riscar o preço e escrever o novoUPDATEprepare + execute, rowCount()
DDelete (apagar)Tirar a ficha do fichárioDELETEprepare + execute, rowCount()

O número da ficha é o id, a chave primária: é por ele que a Ana diz "a ficha 3", sem confusão, mesmo que existam dois produtos com nomes parecidos.


Preparando: a conexão e a tabela

Os quatro programas precisam do banco. Em vez de repetir a conexão em cada um, a Ana a guardou num arquivo e o inclui com require (veja include e require):

<?php
// Abre (ou cria) o arquivo loja.db, na mesma pasta deste arquivo.
$pdo = new PDO("sqlite:" . __DIR__ . "/loja.db", options: [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
__DIR__
Constante mágica com a pasta deste arquivo (aqui, /home/ana/loja). Assim o banco é sempre o loja.db ao lado dos programas, de qualquer pasta que você os rode. O cenário do fim da aula mostra o que acontece sem isso.
options: [...]
As opções da conexão, passadas pelo nome do parâmetro (argumento nomeado, PHP 8.0+), porque o SQLite não usa usuário e senha. É o mesmo que new PDO($dsn, null, null, [...]).
ERRMODE_EXCEPTION
Erro de SQL vira exceção (o padrão desde o PHP 8.0).
FETCH_ASSOC
Cada linha volta como array associativo: $p["nome"].

A tabela é criada uma vez, por um programa só para isso:

<?php
require "conexao.php";

$pdo->exec("CREATE TABLE IF NOT EXISTS produtos (
    id INTEGER PRIMARY KEY,
    nome TEXT NOT NULL,
    preco REAL NOT NULL,
    estoque INTEGER NOT NULL DEFAULT 0
)");

echo "Tabela produtos pronta.\n";
TrechoO que faz
IF NOT EXISTS"Se ainda não existir": rodar o programa de novo não dá erro nem apaga nada.
id INTEGER PRIMARY KEYO número da ficha. O SQLite preenche sozinho (1, 2, 3…) quando você não informa.
NOT NULLA coluna é obrigatória.
DEFAULT 0Se o estoque não for informado, vale 0.
$ php criar-tabela.php
Tabela produtos pronta.
$ ls -l loja.db
-rw-r--r-- 1 ana ana 8192 out 16 20:58 loja.db        « o arquivo do banco foi criado
$ php criar-tabela.php
Tabela produtos pronta.                                « de novo: sem erro, graças ao IF NOT EXISTS

C: cadastrar (INSERT)

Para cadastrar, a Ana passa o nome, o preço e o estoque na linha de comando ($argv, veja PHP na Linha de Comando). Num site, os valores viriam de um formulário; você vai ver isso no fim da aula.

<?php
require "conexao.php";

$nome    = $argv[1];
$preco   = (float) $argv[2];
$estoque = (int) $argv[3];

$stmt = $pdo->prepare("INSERT INTO produtos (nome, preco, estoque) VALUES (?, ?, ?)");
$stmt->execute([$nome, $preco, $estoque]);

echo "Produto cadastrado com o id ", $pdo->lastInsertId(), ".\n";
(float), (int)
Tudo o que chega de fora é texto. A Ana converte para número antes de gravar (veja Conversão de Tipos).
INSERT INTO produtos (nome, preco, estoque)
"Insira na tabela produtos, nestas colunas…". O id fica de fora: o banco escolhe.
VALUES (?, ?, ?)
Três lacunas, uma para cada coluna, na mesma ordem.
$pdo->prepare(...)
Prepara o comando com as lacunas.
$stmt->execute([$nome, $preco, $estoque])
Executa, pondo os três valores nas lacunas, na ordem.
$pdo->lastInsertId()
"Último id inserido": o número que o banco acabou de dar ao produto. Vem como texto ("1").
Por que ? e não '$nome' direto no SQL?

Os valores vêm de fora (aqui, do terminal; num site, de qualquer visitante). Colados no SQL, um simples apóstrofo (copo d'água) quebra o comando, e alguém mal-intencionado pode reescrevê-lo. Com prepare e ?, o valor nunca vira SQL. Por enquanto, siga a regra: todo valor variável entra por um ?. A aula Prepared Statements e SQL Injection mostra o ataque e explica tudo em detalhe.

$ php cadastrar.php Caderno 15.90 30
Produto cadastrado com o id 1.
$ php cadastrar.php "Caneta azul" 2.50 100            « aspas: o nome tem espaço
Produto cadastrado com o id 2.
$ php cadastrar.php Mochila 89.90 5
Produto cadastrado com o id 3.

Cada execução é um programa novo, mas os produtos ficam: estão gravados no arquivo loja.db. E cada um ganhou o próximo número livre.

No editor do guia, o banco é criado na memória a cada vez que você clica em Rodar (veja a aba banco.php): ele já vem com cinco produtos, ids 1 a 5.

Missão: o comando já está preparado. Execute-o com os valores "Borracha", 0.90 e 50 e, logo depois, mostre Cadastrado com o id seguido do id novo (use lastInsertId()). A lista do fim deve mostrar a Borracha com o id 6.

<?php
require "banco.php";

$stmt = $pdo->prepare("INSERT INTO produtos (nome, preco, estoque) VALUES (?, ?, ?)");
// 1. execute o $stmt com "Borracha", 0.90 e 50
// 2. mostre "Cadastrado com o id " e o id novo

foreach ($pdo->query("SELECT id, nome FROM produtos") as $p) {
    echo "#", $p["id"], " ", $p["nome"], "\n";
}
<?php
// Cria a lojinha da Ana na memória, com 5 produtos.
$pdo = new PDO("sqlite::memory:", options: [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$pdo->exec("CREATE TABLE produtos (
    id INTEGER PRIMARY KEY,
    nome TEXT NOT NULL,
    preco REAL NOT NULL,
    estoque INTEGER NOT NULL DEFAULT 0
)");
$pdo->exec("INSERT INTO produtos (nome, preco, estoque) VALUES
    ('Caderno', 15.90, 30),
    ('Caneta azul', 2.50, 100),
    ('Mochila', 89.90, 5),
    ('Lápis', 1.20, 200),
    ('Estojo', 9.90, 12)");
<?php
require "banco.php";

$stmt = $pdo->prepare("INSERT INTO produtos (nome, preco, estoque) VALUES (?, ?, ?)");
$stmt->execute(["Borracha", 0.90, 50]);
echo "Cadastrado com o id ", $pdo->lastInsertId(), "\n";

foreach ($pdo->query("SELECT id, nome FROM produtos") as $p) {
    echo "#", $p["id"], " ", $p["nome"], "\n";
}

R: listar e buscar (SELECT)

Listar todos: fetchAll()

Para a lista, não há nenhum valor de fora no SQL, então um query simples basta:

<?php
require "conexao.php";

$produtos = $pdo->query("SELECT id, nome, preco, estoque FROM produtos ORDER BY nome")->fetchAll();

echo "Produtos cadastrados: ", count($produtos), "\n";
foreach ($produtos as $p) {
    echo "#", $p["id"], " ", $p["nome"], ": R$ ", number_format($p["preco"], 2, ",", "."),
        " (", $p["estoque"], " em estoque)\n";
}
SELECT id, nome, preco, estoque
As colunas que você quer (melhor que *: fica claro o que o programa usa).
ORDER BY nome
Em ordem alfabética do nome.
->fetchAll()
Busca todas as linhas de uma vez: um array de arrays (veja Arrays Multidimensionais). Se não houver nenhuma, um array vazio.
count($produtos)
Quantos produtos vieram.
number_format(…, 2, ",", ".")
Preço no formato brasileiro (veja Formatar Números).
$ php listar.php
Produtos cadastrados: 3
#1 Caderno: R$ 15,90 (30 em estoque)
#2 Caneta azul: R$ 2,50 (100 em estoque)
#3 Mochila: R$ 89,90 (5 em estoque)

Buscar um só: fetch()

Para ver um produto pelo id, o id vem de fora, então volta o prepare. E como o resultado é uma linha (ou nenhuma), usa-se fetch():

<?php
require "conexao.php";

$id = (int) $argv[1];

$stmt = $pdo->prepare("SELECT * FROM produtos WHERE id = ?");
$stmt->execute([$id]);
$produto = $stmt->fetch();

if ($produto === false) {
    echo "Não existe produto com o id $id.\n";
} else {
    echo $produto["nome"], " custa R$ ", number_format($produto["preco"], 2, ",", "."), "\n";
}
$ php ver.php 3
Mochila custa R$ 89,90
$ php ver.php 42
Não existe produto com o id 42.
WHERE id = ?
Só a linha com aquele id. Como o id é a chave primária, vem no máximo uma.
$stmt->fetch()
Pega a próxima linha do resultado, como array. Se não houver mais linhas (ou nenhuma), devolve false.
$produto === false
Sempre confira antes de usar: "não achei" é um caso normal, não um erro do banco (veja o cenário do Trying to access array offset on false).
Quero…UseDevolve
Todas as linhasfetchAll()Array de linhas (vazio se nada)
Uma linha (por id, por e-mail…)fetch()A linha, ou false
Um valor só (uma contagem, um preço)fetchColumn()O valor da 1ª coluna, ou false
Percorrer muitas linhas sem guardar todasforeach ($stmt as $linha)Uma linha por volta

Experimente: troque o $id por outros números (de 1 a 5, e depois um que não existe). Depois, mude o SQL para buscar pelo nome: WHERE nome = ? e execute(["Mochila"]).

<?php
require "banco.php";

$id = 2;

$stmt = $pdo->prepare("SELECT * FROM produtos WHERE id = ?");
$stmt->execute([$id]);
$produto = $stmt->fetch();

if ($produto === false) {
    echo "Não existe produto com o id $id.\n";
} else {
    print_r($produto);
}
<?php
// Cria a lojinha da Ana na memória, com 5 produtos.
$pdo = new PDO("sqlite::memory:", options: [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$pdo->exec("CREATE TABLE produtos (
    id INTEGER PRIMARY KEY,
    nome TEXT NOT NULL,
    preco REAL NOT NULL,
    estoque INTEGER NOT NULL DEFAULT 0
)");
$pdo->exec("INSERT INTO produtos (nome, preco, estoque) VALUES
    ('Caderno', 15.90, 30),
    ('Caneta azul', 2.50, 100),
    ('Mochila', 89.90, 5),
    ('Lápis', 1.20, 200),
    ('Estojo', 9.90, 12)");

U: atualizar (UPDATE)

O Caderno subiu de preço. O programa recebe o id e o preço novo:

<?php
require "conexao.php";

$id    = (int) $argv[1];
$preco = (float) $argv[2];

$stmt = $pdo->prepare("UPDATE produtos SET preco = ? WHERE id = ?");
$stmt->execute([$preco, $id]);

echo $stmt->rowCount(), " produto(s) atualizado(s).\n";
UPDATE produtos
"Altere a tabela produtos…"
SET preco = ?
"…pondo este valor na coluna preco…". Para mudar várias colunas: SET preco = ?, estoque = ?.
WHERE id = ?
"…só na linha com este id." Sem o WHERE, seriam todas as linhas.
[$preco, $id]
Os valores na ordem em que os ? aparecem: primeiro o do SET, depois o do WHERE.
$stmt->rowCount()
"Contagem de linhas": quantas linhas o comando mudou.
$ php atualizar-preco.php 1 17.50
1 produto(s) atualizado(s).
$ php atualizar-preco.php 42 1.00
0 produto(s) atualizado(s).                « não existe o id 42: não é erro, só não mudou nada
$ php listar.php
Produtos cadastrados: 3
#1 Caderno: R$ 17,50 (30 em estoque)
#2 Caneta azul: R$ 2,50 (100 em estoque)
#3 Mochila: R$ 89,90 (5 em estoque)

Repare: um UPDATE que não encontra nenhuma linha não dá erro. Para o banco, "mudar zero linhas" é um resultado válido. Se isso importa para você (e quase sempre importa: o usuário quer saber se deu certo), confira o rowCount().

Contas dentro do SQL

O SET pode usar o valor que já está na coluna: SET estoque = estoque - 1 (vendeu um) ou SET preco = preco * 1.10 (10% mais caro). O banco faz a conta linha por linha.

Missão: reajuste em 10% o preço dos produtos que custam menos que $limite (use preco = preco * 1.10 e WHERE preco < ?) e mostre quantos foram alterados com rowCount(). A saída deve começar assim:

2 produto(s) reajustado(s)
Lápis: R$ 1,32
Caneta azul: R$ 2,75
Estojo: R$ 9,90
…
<?php
require "banco.php";

$limite = 5.00;
// reajuste aqui os produtos que custam menos que $limite
// e mostre "N produto(s) reajustado(s)"

foreach ($pdo->query("SELECT nome, preco FROM produtos ORDER BY preco") as $p) {
    echo $p["nome"], ": R$ ", number_format($p["preco"], 2, ",", "."), "\n";
}
<?php
// Cria a lojinha da Ana na memória, com 5 produtos.
$pdo = new PDO("sqlite::memory:", options: [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$pdo->exec("CREATE TABLE produtos (
    id INTEGER PRIMARY KEY,
    nome TEXT NOT NULL,
    preco REAL NOT NULL,
    estoque INTEGER NOT NULL DEFAULT 0
)");
$pdo->exec("INSERT INTO produtos (nome, preco, estoque) VALUES
    ('Caderno', 15.90, 30),
    ('Caneta azul', 2.50, 100),
    ('Mochila', 89.90, 5),
    ('Lápis', 1.20, 200),
    ('Estojo', 9.90, 12)");
<?php
require "banco.php";

$limite = 5.00;
$stmt = $pdo->prepare("UPDATE produtos SET preco = preco * 1.10 WHERE preco < ?");
$stmt->execute([$limite]);
echo $stmt->rowCount(), " produto(s) reajustado(s)\n";

foreach ($pdo->query("SELECT nome, preco FROM produtos ORDER BY preco") as $p) {
    echo $p["nome"], ": R$ ", number_format($p["preco"], 2, ",", "."), "\n";
}

D: apagar (DELETE)

A Caneta azul saiu de linha:

<?php
require "conexao.php";

$id = (int) $argv[1];

$stmt = $pdo->prepare("DELETE FROM produtos WHERE id = ?");
$stmt->execute([$id]);

if ($stmt->rowCount() === 0) {
    echo "Nada apagado: não existe produto com o id $id.\n";
} else {
    echo "Produto $id apagado.\n";
}
DELETE FROM produtos
"Apague linhas da tabela produtos…"
WHERE id = ?
"…só a que tem este id." Mais uma vez: sem WHERE, apaga todas.
rowCount() === 0
Nenhuma linha apagada: o id não existia (ou já tinha sido apagado).
$ php apagar.php 2
Produto 2 apagado.
$ php apagar.php 2
Nada apagado: não existe produto com o id 2.            « já tinha sido apagado
$ php listar.php
Produtos cadastrados: 2
#1 Caderno: R$ 17,50 (30 em estoque)
#3 Mochila: R$ 89,90 (5 em estoque)
$ php cadastrar.php Lápis 1.20 200
Produto cadastrado com o id 4.                          « o 2 não foi reaproveitado
$ php listar.php
Produtos cadastrados: 3
#1 Caderno: R$ 17,50 (30 em estoque)
#4 Lápis: R$ 1,20 (200 em estoque)
#3 Mochila: R$ 89,90 (5 em estoque)

Um buraco na numeração (não existe mais o 2) é normal e desejável: se alguém guardou "o produto 2" num pedido antigo, ele não vai, de repente, apontar para o Lápis. Não tente "arrumar" os ids.

Apagar de verdade ou só esconder?

Muitos sistemas não apagam: marcam o registro como inativo (uma coluna ativo com 0 ou 1, ou apagado_em com a data) e filtram com WHERE ativo = 1. Isso se chama soft delete ("apagar suave") e permite desfazer e manter o histórico de vendas. Para a lojinha, o DELETE basta; em sistemas com pedidos e notas fiscais, pense nisso antes.


Juntando tudo: uma página web de cadastro

Agora o CRUD vai para o navegador: um formulário para cadastrar e a lista dos produtos logo abaixo, na mesma página. Tudo o que aparece aqui foi visto em aulas anteriores: PHP dentro do HTML, $_POST, validação e htmlspecialchars.

<?php
$pdo = new PDO("sqlite:" . __DIR__ . "/loja.db", options: [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$pdo->exec("CREATE TABLE IF NOT EXISTS produtos (
    id INTEGER PRIMARY KEY,
    nome TEXT NOT NULL,
    preco REAL NOT NULL,
    estoque INTEGER NOT NULL DEFAULT 0
)");

$mensagem = "";
if ($_SERVER["REQUEST_METHOD"] === "POST") {
    $nome    = trim($_POST["nome"] ?? "");
    $preco   = (float) str_replace(",", ".", $_POST["preco"] ?? "");
    $estoque = (int) ($_POST["estoque"] ?? 0);

    if ($nome === "" || $preco <= 0) {
        $mensagem = "Preencha o nome e um preço maior que zero.";
    } else {
        $stmt = $pdo->prepare("INSERT INTO produtos (nome, preco, estoque) VALUES (?, ?, ?)");
        $stmt->execute([$nome, $preco, $estoque]);
        $mensagem = "Produto nº " . $pdo->lastInsertId() . " cadastrado: " . $nome;
    }
}

$produtos = $pdo->query("SELECT * FROM produtos ORDER BY nome")->fetchAll();
?>
<!doctype html>
<html lang="pt-BR">
<head><meta charset="utf-8"><title>Lojinha da Ana</title></head>
<body>
<h1>Cadastro de produtos</h1>

<?php if ($mensagem !== ""): ?>
<p><strong><?= htmlspecialchars($mensagem) ?></strong></p>
<?php endif; ?>

<form method="post">
  <label>Nome: <input name="nome"></label>
  <label>Preço: <input name="preco"></label>
  <label>Estoque: <input name="estoque" type="number" value="0"></label>
  <button>Cadastrar</button>
</form>

<h2>Produtos (<?= count($produtos) ?>)</h2>
<ul>
<?php foreach ($produtos as $p): ?>
  <li><?= htmlspecialchars($p["nome"]) ?>: R$ <?= number_format($p["preco"], 2, ",", ".") ?> (<?= $p["estoque"] ?> un.)</li>
<?php endforeach; ?>
</ul>
</body>
</html>
ParteO que faz
Conexão + CREATE TABLE IF NOT EXISTSNa primeira visita, cria o banco e a tabela; nas outras, não faz nada.
if ($_SERVER["REQUEST_METHOD"] === "POST")Só cadastra quando o formulário foi enviado; uma visita normal (GET) só mostra a página.
trim, str_replace(",", ".", …), (float), (int)Limpa o nome e aceita preço com vírgula (0,90), como os brasileiros digitam.
if ($nome === "" || $preco <= 0)Validação: nada de produto sem nome ou de graça.
prepare + execute + lastInsertIdO C do CRUD, igual ao do terminal.
query(...)->fetchAll()O R: a lista, depois do cadastro (então o produto novo já aparece).
htmlspecialchars(...)Todo texto que veio do usuário (o nome, a mensagem) é escapado antes de ir para o HTML.

A Ana rodou a página com o servidor embutido (veja O Servidor Embutido) e, em outro terminal, enviou o formulário com o curl (o -d manda os campos por POST, como o navegador faz ao clicar em Cadastrar):

$ php -S localhost:8000 -t web
[Fri Oct 16 21:01:35 2026] PHP 8.5.8 Development Server (http://localhost:8000) started
[Fri Oct 16 21:01:36 2026] 127.0.0.1:54646 Accepted
[Fri Oct 16 21:01:36 2026] 127.0.0.1:54646 [200]: POST /
[Fri Oct 16 21:01:36 2026] 127.0.0.1:54646 Closing
[Fri Oct 16 21:01:36 2026] 127.0.0.1:54650 Accepted
[Fri Oct 16 21:01:37 2026] 127.0.0.1:54650 [200]: POST /
[Fri Oct 16 21:01:37 2026] 127.0.0.1:54650 Closing
[Fri Oct 16 21:01:37 2026] 127.0.0.1:54652 Accepted
[Fri Oct 16 21:01:37 2026] 127.0.0.1:54652 [200]: POST /
[Fri Oct 16 21:01:37 2026] 127.0.0.1:54652 Closing
$ curl -s -d "nome=Borracha&preco=0,90&estoque=50" localhost:8000/
<!doctype html>
<html lang="pt-BR">
<head><meta charset="utf-8"><title>Lojinha da Ana</title></head>
<body>
<h1>Cadastro de produtos</h1>

<p><strong>Produto nº 1 cadastrado: Borracha</strong></p>

<form method="post">
  <label>Nome: <input name="nome"></label>
  <label>Preço: <input name="preco"></label>
  <label>Estoque: <input name="estoque" type="number" value="0"></label>
  <button>Cadastrar</button>
</form>

<h2>Produtos (1)</h2>
<ul>
  <li>Borracha: R$ 0,90 (50 un.)</li>
</ul>
</body>
</html>
$ curl -s -d "nome=<b>Régua</b>&preco=4.50&estoque=20" localhost:8000/ | grep -E "<p>|<li>"
<p><strong>Produto nº 2 cadastrado: &lt;b&gt;Régua&lt;/b&gt;</strong></p>
  <li>&lt;b&gt;Régua&lt;/b&gt;: R$ 4,50 (20 un.)</li>
  <li>Borracha: R$ 0,90 (50 un.)</li>
$ curl -s -d "nome=&preco=" localhost:8000/ | grep "<p>"
<p><strong>Preencha o nome e um preço maior que zero.</strong></p>

Três envios: um cadastro normal (o 0,90 com vírgula virou 0.90); um nome com tags HTML, que foi gravado como veio, mas mostrado escapado (&lt;b&gt;), então o navegador exibe o texto <b>Régua</b> em vez de pôr a palavra em negrito; e um envio vazio, barrado pela validação. O servidor registra cada pedido no Terminal 1: três conexões (Accepted), três POST / respondidos com [200] ("OK"). Encerre o servidor com Ctrl+C.

Missão: a página já valida o formulário, mas não grava nada. No lugar do comentário, grave o produto com prepare + execute e ponha em $mensagem o texto Produto nº … cadastrado: … (com o lastInsertId() e o nome). O editor já envia a Borracha na primeira execução; depois, use o formulário da aba Resultado para cadastrar outros produtos.

<?php
$pdo = new PDO("sqlite:" . __DIR__ . "/loja.db", options: [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$pdo->exec("CREATE TABLE IF NOT EXISTS produtos (
    id INTEGER PRIMARY KEY,
    nome TEXT NOT NULL,
    preco REAL NOT NULL,
    estoque INTEGER NOT NULL DEFAULT 0
)");

$mensagem = "";
if ($_SERVER["REQUEST_METHOD"] === "POST") {
    $nome    = trim($_POST["nome"] ?? "");
    $preco   = (float) str_replace(",", ".", $_POST["preco"] ?? "");
    $estoque = (int) ($_POST["estoque"] ?? 0);

    if ($nome === "" || $preco <= 0) {
        $mensagem = "Preencha o nome e um preço maior que zero.";
    } else {
        // grave o produto aqui e preencha $mensagem
    }
}

$produtos = $pdo->query("SELECT * FROM produtos ORDER BY nome")->fetchAll();
?>
<!doctype html>
<html lang="pt-BR">
<head><meta charset="utf-8"><title>Lojinha da Ana</title></head>
<body>
<h1>Cadastro de produtos</h1>

<?php if ($mensagem !== ""): ?>
<p><strong><?= htmlspecialchars($mensagem) ?></strong></p>
<?php endif; ?>

<form method="post">
  <label>Nome: <input name="nome"></label>
  <label>Preço: <input name="preco"></label>
  <label>Estoque: <input name="estoque" type="number" value="0"></label>
  <button>Cadastrar</button>
</form>

<h2>Produtos (<?= count($produtos) ?>)</h2>
<ul>
<?php foreach ($produtos as $p): ?>
  <li><?= htmlspecialchars($p["nome"]) ?>: R$ <?= number_format($p["preco"], 2, ",", ".") ?> (<?= $p["estoque"] ?> un.)</li>
<?php endforeach; ?>
</ul>
</body>
</html>
<?php
$pdo = new PDO("sqlite:" . __DIR__ . "/loja.db", options: [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$pdo->exec("CREATE TABLE IF NOT EXISTS produtos (
    id INTEGER PRIMARY KEY,
    nome TEXT NOT NULL,
    preco REAL NOT NULL,
    estoque INTEGER NOT NULL DEFAULT 0
)");

$mensagem = "";
if ($_SERVER["REQUEST_METHOD"] === "POST") {
    $nome    = trim($_POST["nome"] ?? "");
    $preco   = (float) str_replace(",", ".", $_POST["preco"] ?? "");
    $estoque = (int) ($_POST["estoque"] ?? 0);

    if ($nome === "" || $preco <= 0) {
        $mensagem = "Preencha o nome e um preço maior que zero.";
    } else {
        $stmt = $pdo->prepare("INSERT INTO produtos (nome, preco, estoque) VALUES (?, ?, ?)");
        $stmt->execute([$nome, $preco, $estoque]);
        $mensagem = "Produto nº " . $pdo->lastInsertId() . " cadastrado: " . $nome;
    }
}

$produtos = $pdo->query("SELECT * FROM produtos ORDER BY nome")->fetchAll();
?>
<!doctype html>
<html lang="pt-BR">
<head><meta charset="utf-8"><title>Lojinha da Ana</title></head>
<body>
<h1>Cadastro de produtos</h1>

<?php if ($mensagem !== ""): ?>
<p><strong><?= htmlspecialchars($mensagem) ?></strong></p>
<?php endif; ?>

<form method="post">
  <label>Nome: <input name="nome"></label>
  <label>Preço: <input name="preco"></label>
  <label>Estoque: <input name="estoque" type="number" value="0"></label>
  <button>Cadastrar</button>
</form>

<h2>Produtos (<?= count($produtos) ?>)</h2>
<ul>
<?php foreach ($produtos as $p): ?>
  <li><?= htmlspecialchars($p["nome"]) ?>: R$ <?= number_format($p["preco"], 2, ",", ".") ?> (<?= $p["estoque"] ?> un.)</li>
<?php endforeach; ?>
</ul>
</body>
</html>
E editar e apagar pela página?

Seguem a mesma receita: um link ou botão por produto que envia o id (<form method="post"> com um <input type="hidden" name="apagar" value="3">), e o PHP faz o UPDATE ou o DELETE com WHERE id = ?. Apagar e alterar sempre por POST, nunca por um link GET (um robô de busca que siga os links apagaria tudo). O projeto Tarefas com SQLite monta o CRUD completo na web, e a aula CSRF mostra como proteger esses formulários.


Quando dá errado

Esqueceu o WHERE no UPDATE (ou no DELETE)
<?php
require "conexao.php";

// A Ana queria zerar o estoque só da Mochila (id 3)... e esqueceu o WHERE.
$afetados = $pdo->exec("UPDATE produtos SET estoque = 0");
echo $afetados, " produto(s) com estoque zerado.\n";
$ php zerar-estoque.php
3 produto(s) com estoque zerado.
$ php listar.php
Produtos cadastrados: 3
#1 Caderno: R$ 17,50 (0 em estoque)
#4 Lápis: R$ 1,20 (0 em estoque)
#3 Mochila: R$ 89,90 (0 em estoque)

O que significa: nenhuma mensagem de erro, porque o SQL estava certo: "zere o estoque de todos". O banco obedeceu. (O exec devolve, como o rowCount, quantas linhas mudaram.) Com DELETE FROM produtos sem WHERE, seria a tabela inteira apagada.

Como evitar: escreva o WHERE primeiro. Antes de um UPDATE ou DELETE importante, rode um SELECT com o mesmo WHERE para ver quais linhas seriam atingidas. Confira o rowCount(): se você esperava 1 e veio 3, algo está errado. Use transações para poder desfazer. E tenha cópia de segurança do loja.db (é só copiar o arquivo).

Trying to access array offset on false
<?php
require "conexao.php";

$stmt = $pdo->prepare("SELECT * FROM produtos WHERE id = ?");
$stmt->execute([42]);
$produto = $stmt->fetch();

echo $produto["nome"], " custa R$ ", $produto["preco"], "\n";
$ php ver-sem-teste.php

Warning: Trying to access array offset on false in /home/ana/loja/ver-sem-teste.php on line 8
 custa R$
Warning: Trying to access array offset on false in /home/ana/loja/ver-sem-teste.php on line 8

O que significa: "tentando acessar uma posição de array em false". Não existe produto 42, então o fetch() devolveu false, e o programa tentou ler $produto["nome"] como se fosse um array. O PHP avisa duas vezes (uma para ["nome"], outra para ["preco"]) e continua, mostrando um texto pela metade.

Como consertar: confira o resultado antes de usar: if ($produto === false) { … }, como em ver.php.

NOT NULL constraint failed: produtos.preco
<?php
require "conexao.php";

$stmt = $pdo->prepare("INSERT INTO produtos (nome, estoque) VALUES (?, ?)");
$stmt->execute(["Borracha", 50]);
$ php cadastrar-sem-preco.php

Fatal error: Uncaught PDOException: SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: produtos.preco in /home/ana/loja/cadastrar-sem-preco.php:5
Stack trace:
#0 /home/ana/loja/cadastrar-sem-preco.php(5): PDOStatement->execute(Array)
#1 {main}
  thrown in /home/ana/loja/cadastrar-sem-preco.php on line 5

O que significa: "violação de restrição de integridade: a restrição NOT NULL falhou em produtos.preco". A coluna preco é obrigatória e o INSERT não a preencheu. O código 23000 é o de "regra da tabela violada".

Como consertar: inclua a coluna no INSERT. Num formulário, valide no PHP antes (como a página web faz com $preco <= 0) para mostrar uma mensagem amigável em vez de um erro.

no such table: produto
<?php
require "conexao.php";

$produtos = $pdo->query("SELECT * FROM produto")->fetchAll();
$ php errou-tabela.php

Fatal error: Uncaught PDOException: SQLSTATE[HY000]: General error: 1 no such table: produto in /home/ana/loja/errou-tabela.php:4
Stack trace:
#0 /home/ana/loja/errou-tabela.php(4): PDO->query('SELECT * FROM p...')
#1 {main}
  thrown in /home/ana/loja/errou-tabela.php on line 4

O que significa: "não existe a tabela produto": faltou o s. Com um nome de coluna errado, a mensagem é parecida: no such column: ….

Como consertar: confira a grafia. Se o nome está certo, a tabela realmente não existe neste banco: rodou o criar-tabela.php? Está abrindo o arquivo certo? (Veja o próximo cenário.)

no such table: produtos só quando roda de outra pasta
<?php
$pdo = new PDO("sqlite:loja.db");   // caminho relativo: depende da pasta em que você está

$produtos = $pdo->query("SELECT nome FROM produtos")->fetchAll();
echo count($produtos), " produtos\n";
$ cd ~
$ php loja/listar-relativo.php

Fatal error: Uncaught PDOException: SQLSTATE[HY000]: General error: 1 no such table: produtos in /home/ana/loja/listar-relativo.php:4
Stack trace:
#0 /home/ana/loja/listar-relativo.php(4): PDO->query('SELECT nome FRO...')
#1 {main}
  thrown in /home/ana/loja/listar-relativo.php on line 4
$ ls -l loja.db
-rw-r--r-- 1 ana ana 0 out 16 20:58 loja.db           « um banco novo e vazio, criado na pasta errada
$ rm loja.db

O que significa: sqlite:loja.db é um caminho relativo: é procurado na pasta em que você está, não na pasta do programa. Rodando de ~, o PDO não achou ~/loja.db e, sem avisar, criou um arquivo vazio ali (0 bytes). Num banco vazio, não existe tabela nenhuma. Num site, a "pasta em que você está" depende do servidor, e o erro aparece só lá.

Como consertar: use "sqlite:" . __DIR__ . "/loja.db", como em conexao.php, e apague o arquivo vazio criado por engano.


Referência

OperaçãoSQLPHPPara conferir
CriarINSERT INTO produtos (nome, preco) VALUES (?, ?)$stmt->execute([$nome, $preco])$pdo->lastInsertId() (o id novo, como texto)
R: todosSELECT … FROM produtos ORDER BY nome$pdo->query($sql)->fetchAll()count($lista)
R: umSELECT … WHERE id = ?$stmt->execute([$id]); $stmt->fetch()=== false se não achou
R: um valorSELECT COUNT(*) FROM produtos->fetchColumn()
UpdateUPDATE produtos SET preco = ? WHERE id = ?$stmt->execute([$preco, $id])$stmt->rowCount()
DeleteDELETE FROM produtos WHERE id = ?$stmt->execute([$id])$stmt->rowCount()
SQL fixo, sem resultadoCREATE TABLE …, UPDATE … sem variáveis$pdo->exec($sql)devolve as linhas afetadas

Exercícios

1. O id novo

Complete para mostrar o id do produto que acabou de ser cadastrado:

echo "Novo id: ", $pdo->;

2. Atualizar o estoque

Complete o SQL para mudar o estoque do produto de um certo id:

UPDATE produtos SET estoque = ?

3. Apagar

Complete o SQL para apagar o produto de um certo id:

produtos WHERE id = ?

4. O que $stmt->fetch() devolve quando o SELECT … WHERE id = ? não encontra nada?

5. Um UPDATE … WHERE id = ? com um id que não existe…

6. O que acontece com $pdo->exec("DELETE FROM produtos")?

7. Por que conexao.php usa __DIR__ no caminho do banco?


Resumo

  • CRUD = Create, Read, Update, Delete = INSERT, SELECT, UPDATE, DELETE.
  • Guarde a conexão num arquivo (conexao.php) e use "sqlite:" . __DIR__ . "/loja.db".
  • Todo valor que vem de fora entra por ?: prepare + execute([...]).
  • Depois do INSERT, $pdo->lastInsertId() dá o id novo.
  • fetchAll() para listas; fetch() para um registro, conferindo se veio false.
  • UPDATE e DELETE sempre com WHERE; confira com rowCount(). Zero linhas não é erro.
  • Na web: cadastre só no POST, valide antes de gravar e use htmlspecialchars ao mostrar.

Próxima aula: Prepared Statements e SQL Injection, para entender por que o ? é obrigatório e como agrupar comandos com transações.