Dia 13 — PDO: PHP com MySQL: PHP e MySQL

Informatica · Conteudo · publicado em 02/10/2026
Dia 13 de 17

PDO: PHP com MySQL

O SQL dos dias anteriores era escrito para ser executado à mão no phpMyAdmin ou no terminal. A partir daqui ele passa a ser executado pelo próprio PHP, o que exige um caminho diferente para chegar no banco. Este dia introduz o `PDO`, a conexão com `DSN` e variável de ambiente, e o prepared statement, que é o que separa o SQL que o aluno digita do SQL que o banco executa.

Aula 1

PDO: conexão

A conexão com o banco pelo PHP passa por uma string que se chama DSN e por um objeto PDO. A DSN descreve onde está o banco; o construtor recebe a DSN, o usuário e a senha, e um quarto argumento com as opções de comportamento. A senha vem de getenv(), nunca escrita no arquivo.

Montando a conexão

new PDO(string $dsn, ?string $usuario, ?string $senha, array $opcoes = []): PDO

A DSN do MySQL segue o formato mysql:host=HOST;port=PORTA;dbname=BANCO;charset=utf8mb4. A porta só precisa aparecer quando não for a 3306.

$dsn = 'mysql:host=127.0.0.1;port=3306;dbname=escola;charset=utf8mb4';

try {
    $pdo = new PDO($dsn, 'app_user', getenv('DB_PASS') ?: '', [
        PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        PDO::ATTR_EMULATE_PREPARES   => false,
    ]);
} catch (PDOException $e) {
    exit('Nao foi possivel conectar ao banco.');
}

As três opções que valem a pena

OpçãoValorO que muda sem ela
PDO::ATTR_ERRMODEPDO::ERRMODE_EXCEPTIONO erro vira false silencioso e o script continua com um resultado quebrado
PDO::ATTR_DEFAULT_FETCH_MODEPDO::FETCH_ASSOCfetch() devolve array com chave numérica e coluna, em vez de ['nome' => 'Ana']
PDO::ATTR_EMULATE_PREPARESfalseO PHP monta o SQL por conta própria, e a consulta real vai para o banco como texto

Onde a mensagem de erro pode vazar

A conexão que falha é o caso em que a exceção carrega mais do que deveria: a mensagem original costuma conter o host e o usuário do banco. error_log() grava o detalhe no log do servidor; a tela recebe uma frase genérica.

PDOException é a exceção que o PDO lança. Sem ERRMODE_EXCEPTION, ela nunca chega, e o catch não existe.

Para descobrir se a extensão do driver está instalada, sem precisar de servidor: PDO::getAvailableDrivers(): array. Se mysql não estiver na lista, o problema é a extensão pdo_mysql, não o código.

Exemplo

<?php
declare(strict_types=1);

// Esta aula monta a configuracao e a DSN de verdade (PHP puro, roda sempre).
// A conexao em si aparece como texto: o gerador roda este arquivo numa imagem
// php:8.3-cli que nao tem o driver pdo_mysql nem servidor MySQL.

$config = [
    'host'     => getenv('DB_HOST') ?: '127.0.0.1',
    'port'     => (int) (getenv('DB_PORT') ?: 3306),
    'database' => getenv('DB_NAME') ?: 'escola',
    'username' => getenv('DB_USER') ?: 'app_user',
    'password' => getenv('DB_PASS') ?: '',
    'charset'  => 'utf8mb4',
];

echo "=== 1. Configuracao vinda do ambiente ===\n";
foreach ($config as $chave => $valor) {
    printf("  %-9s %s\n", $chave, $chave === 'password' ? '(de getenv, nunca no codigo)' : $valor);
}

// A DSN e uma string: describe o banco para o driver. Nao e segredo e pode
// aparecer em log de conexao.
$dsn = sprintf(
    'mysql:host=%s;port=%d;dbname=%s;charset=%s',
    $config['host'],
    $config['port'],
    $config['database'],
    $config['charset'],
);

echo "\n=== 2. A DSN montada ===\n{$dsn}\n";

// --- 3. A conexao real, exibida como texto ---
$codigo = <<<'PHP'
<?php
declare(strict_types=1);

$senha = getenv('DB_PASS');

$dsn = 'mysql:host=127.0.0.1;port=3306;dbname=escola;charset=utf8mb4';

try {
    $pdo = new PDO($dsn, 'app_user', $senha ?: '', [
        PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        PDO::ATTR_EMULATE_PREPARES   => false,
    ]);

    echo "conectado\n";
} catch (PDOException $e) {
    // A mensagem original carrega host e usuario do banco. Ela vai para o log
    // do servidor, nunca para a tela de quem esta usando o site.
    error_log('Falha de conexao: ' . $e->getMessage());
    exit('Nao foi possivel conectar ao banco.' . PHP_EOL);
}
PHP;

echo "\n=== 3. A conexao real ===\n{$codigo}\n";

// --- 4. As opcoes do quarto argumento ---
$opcoes = [
    'PDO::ATTR_ERRMODE'            => 'PDO::ERRMODE_EXCEPTION',
    'PDO::ATTR_DEFAULT_FETCH_MODE' => 'PDO::FETCH_ASSOC',
    'PDO::ATTR_EMULATE_PREPARES'   => 'false',
];

echo "\n=== 4. Opcoes passadas na construcao ===\n";
foreach ($opcoes as $constante => $valor) {
    printf("  %-30s => %s\n", $constante, $valor);
}

// --- 5. O que esta build do PHP tem compilado ---
// getAvailableDrivers() responde sem precisar de servidor: e o jeito de
// descobrir se a extensao pdo_mysql esta instalada.
echo "\n=== 5. Drivers desta build do PHP ===\n";
printf("  %s\n", implode(', ', PDO::getAvailableDrivers()));
echo "  (numa instalacao com MySQL, 'mysql' aparece na lista)\n";

Saída real

=== 1. Configuracao vinda do ambiente ===
  host      127.0.0.1
  port      3306
  database  escola
  username  app_user
  password  (de getenv, nunca no codigo)
  charset   utf8mb4

=== 2. A DSN montada ===
mysql:host=127.0.0.1;port=3306;dbname=escola;charset=utf8mb4

=== 3. A conexao real ===
<?php
declare(strict_types=1);

$senha = getenv('DB_PASS');

$dsn = 'mysql:host=127.0.0.1;port=3306;dbname=escola;charset=utf8mb4';

try {
    $pdo = new PDO($dsn, 'app_user', $senha ?: '', [
        PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        PDO::ATTR_EMULATE_PREPARES   => false,
    ]);

    echo "conectado\n";
} catch (PDOException $e) {
    // A mensagem original carrega host e usuario do banco. Ela vai para o log
    // do servidor, nunca para a tela de quem esta usando o site.
    error_log('Falha de conexao: ' . $e->getMessage());
    exit('Nao foi possivel conectar ao banco.' . PHP_EOL);
}

=== 4. Opcoes passadas na construcao ===
  PDO::ATTR_ERRMODE              => PDO::ERRMODE_EXCEPTION
  PDO::ATTR_DEFAULT_FETCH_MODE   => PDO::FETCH_ASSOC
  PDO::ATTR_EMULATE_PREPARES     => false

=== 5. Drivers desta build do PHP ===
  sqlite
  (numa instalacao com MySQL, 'mysql' aparece na lista)
Aula 2

Prepared statement

Comando preparado é o SQL com a variável separada do valor. O PHP monta o comando uma vez e manda o valor por fora, então o banco nunca interpreta o que o usuário digitou como sintaxe de SQL. Todo acesso a dado que venha de formulário passa por prepare().

As três formas de enviar o valor

prepare(string $sql): PDOStatement cria o comando. execute(array $pares = []): bool envia os valores. bindValue(string|int $parametro, mixed $valor, int $tipo = PDO::PARAM_STR): bool copia o valor no momento da chamada. bindParam(string|int $parametro, mixed &$variavel, int $tipo = PDO::PARAM_STR): bool guarda a referência e lê a variável no momento do execute().

// placeholder nomeado: o mesmo SQL para qualquer valor
$stmt = $pdo->prepare('SELECT id, nome FROM alunos WHERE nome = :nome');
$stmt->execute([':nome' => $nome]);

// placeholder posicional: cada ? na ordem dos valores
$stmt = $pdo->prepare('INSERT INTO alunos (nome, email) VALUES (?, ?)');
$stmt->execute([$nome, $email]);

// bindParam, para lote: a variavel e lida a cada execute
$stmt = $pdo->prepare('INSERT INTO alunos (nome) VALUES (:nome)');
foreach ($nomes as $nome) {
    $stmt->bindParam(':nome', $nome);
    $stmt->execute();
}

Por que concatenar valor é SQL Injection

Concatenar monta o SQL com texto e número misturados, e o valor do usuário entra no mesmo lugar onde o PHP esperava escrever código. Quando esse valor contém uma aspa simples, a aspa fecha o trecho de texto do SQL e o que vem depois é lido como comando.

O que a pessoa digita no campo de busca: Ana'; DROP TABLE alunos; --

O SQL que chega ao banco, montado por concatenação:

SELECT id, nome FROM alunos WHERE nome = 'Ana'; DROP TABLE alunos; --'

O mesmo valor, com placeholder, vira só o texto Ana'; DROP TABLE alunos; -- dentro da coluna nome. O DROP nunca é interpretado, porque o driver enviou comando e valor separados.

Não existe sanitização manual que feche esse caminho: addslashes() quebra com barra invertida, e trocar a aspa por nada altera o dado. Não existe caso em que concatenar valor de usuário no SQL seja aceitável.

O que EMULATE_PREPARES => false protege de verdade

A emulação faz o PHP montar o SQL inteiro e o mandar como texto comum. Nesse caminho, o driver do lado do PHP continua sendo quem escapa o valor, e é aí que moram bugs de escape e de codificação.

Com false, o comando vai ao servidor com os valores fora dele, e quem interpreta é o MySQL. O ponto não é o prepare() sozinho: é o prepare() com o valor indo por fora.

Uma consequência prática: com emulação desligada, LIMIT e OFFSET-placeholder precisam de tipo declarado, senão o MySQL recusa.

$stmt = $pdo->prepare('SELECT id FROM alunos ORDER BY nome LIMIT :limite OFFSET :offset');
$stmt->bindValue(':limite', 10, PDO::PARAM_INT);
$stmt->bindValue(':offset', 20, PDO::PARAM_INT);
$stmt->execute();

Exemplo

<?php
declare(strict_types=1);

// Ataque e defesa de SQL Injection. As consultas aparecem como texto porque a
// imagem php:8.3-cli do gerador nao tem driver pdo_mysql. O que roda de
// verdade aqui e a comparacao entre as duas formas de montar o comando.

$busca = "Ana'; DROP TABLE alunos; --";

echo "=== 1. Jeito que abre brecha: o valor entra no texto do SQL ===\n";
echo "valor digitado pelo usuario: {$busca}\n\n";
echo 'SQL que chega ao banco:' . "\n";
echo "  SELECT id, nome FROM alunos WHERE nome = '{$busca}'\n\n";
echo "O ponto e o apostrofo: ele fecha a string, o resto vira comando.\n";

// Uma string com aspa simples e barra invertida e o que a pessoa consegue
// escrever. Isso mostra que validar na mao e o caminho para a falha.
$comBarra = "\\";
echo "\nCom uma barra invertida, o proprio escape fica com um caracter a menos:\n";
echo "  nome = '{$comBarra}'  ->  a aspa seguinte fecha a string\n";

// --- 2. O mesmo SQL com placeholder ---
$sqlSeguro = 'SELECT id, nome FROM alunos WHERE nome = :nome';

echo "\n=== 2. Jeito correto: o valor nao entra no texto do SQL ===\n";
echo "SQL, sempre identico: {$sqlSeguro}\n";
echo "valor enviado a parte:     {$busca}\n\n";
echo "O driver manda o comando e o valor separados. O MySQL ve\n";
echo "'{$busca}' como um texto so, nunca como sintaxe.\n";

// --- 3. As duas formas de enviar o valor ---
echo "\n=== 3. execute com array (a mais comum) ===\n";
$parametros = [':nome' => $busca];
foreach ($parametros as $chave => $valor) {
    printf("  %-6s <= %s\n", $chave, $valor);
}

echo "\n=== 4. bindValue e bindParam ===\n";
// bindValue copia o valor no momento da chamada.
// bindParam guarda a referencia: se a variavel mudar depois, o valor enviado
// e o novo. E o que se usa com loop de INSERT em lote.
$valor = 'Ana Souza';
printf("  bindValue copia:   %s\n", $valor);
$valor = 'Bruno Lima';
printf("  bindParam le junto: %s\n", $valor);

// --- 5. Um trecho real de prepared statement ---
$codigo = <<<'PHP'
<?php
declare(strict_types=1);

// Com placeholder nomeado: o mesmo SQL para qualquer valor.
$stmt = $pdo->prepare('SELECT id, nome FROM alunos WHERE nome = :nome');
$stmt->execute([':nome' => $busca]);
$alunos = $stmt->fetchAll();

// Com placeholder posicional: cada ? na ordem em que os valores aparecem.
$stmt = $pdo->prepare('INSERT INTO alunos (nome, email) VALUES (?, ?)');
$stmt->execute([$nome, $email]);

// bindParam, util em lote: a variavel e lida no momento do execute.
$stmt = $pdo->prepare('INSERT INTO alunos (nome) VALUES (:nome)');
foreach ($nomes as $nome) {
    $stmt->bindParam(':nome', $nome);
    $stmt->execute();
}

// Limite e deslocamento sao inteiros: declarados como PARAM_INT, nunca string.
$stmt = $pdo->prepare('SELECT id, nome FROM alunos ORDER BY nome LIMIT :limite OFFSET :offset');
$stmt->bindValue(':limite', 10, PDO::PARAM_INT);
$stmt->bindValue(':offset', 20, PDO::PARAM_INT);
$stmt->execute();
PHP;

echo "\n=== 5. Prepared statement na pratica ===\n{$codigo}\n";

Saída real

=== 1. Jeito que abre brecha: o valor entra no texto do SQL ===
valor digitado pelo usuario: Ana'; DROP TABLE alunos; --

SQL que chega ao banco:
  SELECT id, nome FROM alunos WHERE nome = 'Ana'; DROP TABLE alunos; --'

O ponto e o apostrofo: ele fecha a string, o resto vira comando.

Com uma barra invertida, o proprio escape fica com um caracter a menos:
  nome = '\'  ->  a aspa seguinte fecha a string

=== 2. Jeito correto: o valor nao entra no texto do SQL ===
SQL, sempre identico: SELECT id, nome FROM alunos WHERE nome = :nome
valor enviado a parte:     Ana'; DROP TABLE alunos; --

O driver manda o comando e o valor separados. O MySQL ve
'Ana'; DROP TABLE alunos; --' como um texto so, nunca como sintaxe.

=== 3. execute com array (a mais comum) ===
  :nome  <= Ana'; DROP TABLE alunos; --

=== 4. bindValue e bindParam ===
  bindValue copia:   Ana Souza
  bindParam le junto: Bruno Lima

=== 5. Prepared statement na pratica ===
<?php
declare(strict_types=1);

// Com placeholder nomeado: o mesmo SQL para qualquer valor.
$stmt = $pdo->prepare('SELECT id, nome FROM alunos WHERE nome = :nome');
$stmt->execute([':nome' => $busca]);
$alunos = $stmt->fetchAll();

// Com placeholder posicional: cada ? na ordem em que os valores aparecem.
$stmt = $pdo->prepare('INSERT INTO alunos (nome, email) VALUES (?, ?)');
$stmt->execute([$nome, $email]);

// bindParam, util em lote: a variavel e lida no momento do execute.
$stmt = $pdo->prepare('INSERT INTO alunos (nome) VALUES (:nome)');
foreach ($nomes as $nome) {
    $stmt->bindParam(':nome', $nome);
    $stmt->execute();
}

// Limite e deslocamento sao inteiros: declarados como PARAM_INT, nunca string.
$stmt = $pdo->prepare('SELECT id, nome FROM alunos ORDER BY nome LIMIT :limite OFFSET :offset');
$stmt->bindValue(':limite', 10, PDO::PARAM_INT);
$stmt->bindValue(':offset', 20, PDO::PARAM_INT);
$stmt->execute();
Aula 3

CRUD completo

CRUD são as quatro operações de um cadastro: criar, ler, atualizar e apagar. Em SQL são INSERT, SELECT, UPDATE e DELETE, e no PDO as quatro seguem o mesmo par: prepare() seguido de execute().

As quatro operações

// CREATE
$stmt = $pdo->prepare('INSERT INTO alunos (nome, email, nota) VALUES (:nome, :email, :nota)');
$stmt->execute([':nome' => $nome, ':email' => $email, ':nota' => $nota]);
$novoId = (int) $pdo->lastInsertId();

// READ
$stmt = $pdo->prepare('SELECT id, nome, nota FROM alunos WHERE nota >= :minima ORDER BY nota DESC');
$stmt->execute([':minima' => 7.0]);
$alunos = $stmt->fetchAll();

// UPDATE
$stmt = $pdo->prepare('UPDATE alunos SET nota = :nota WHERE id = :id');
$stmt->execute([':nota' => 7.75, ':id' => $novoId]);

// DELETE
$stmt = $pdo->prepare('DELETE FROM alunos WHERE id = :id');
$stmt->execute([':id' => $novoId]);

lastInsertId() e rowCount()

lastInsertId(): string devolve o AUTO_INCREMENT da última inserção, como texto. Serve para redirecionar para o registro que acabou de ser criado. Depois de um UPDATE ou DELETE, devolve 0.

rowCount(): int devolve as linhas afetadas por INSERT, UPDATE e DELETE. Em SELECT não serve: quem conta resultado de leitura é o fetch ou o fetchAll.

VerificaçãoComo fica
Inseriu mesmo?$stmt->rowCount() === 1
Qual o id novo?(int) $pdo->lastInsertId()
Alterou alguma linha?$stmt->rowCount() > 0
Apagou algo?$stmt->rowCount() === 1

O caso que quebra: DELETE sem WHERE

UPDATE e DELETE sem WHERE valem para a tabela inteira. Não há aviso e não há erro: o banco executa e apaga tudo, e o rowCount() confirma com um número grande demais. Em produção, a primeira defesa é sempre o WHERE com placeholder, e a segunda é conferir o rowCount().

O mesmo vale para UPDATE sem WHERE: a coluna nova é gravada em todas as linhas. Manter o WHERE sempre visível na tela do editor evita a maior parte dos acidentes desses dois.

Para operações que mexem em mais de uma tabela, cada operação vai dentro de uma transação, senão o cadastro fica pela metade quando a terceira etapa falha.

Exemplo

<?php
declare(strict_types=1);

// CRUD completo: as quatro operacoes com prepared statement.
//
// O gerador roda este arquivo numa imagem php:8.3-cli que nao tem pdo_mysql
// nem servidor MySQL, entao o exemplo roda em SQLite (sqlite: fica na
// memoria). A API do PDO e identica nos dois: prepare, execute, rowCount e
// lastInsertId se comportam do mesmo jeito. O que muda entre os dois e apenas
// o CREATE TABLE, que aparece abaixo no SQL de MySQL.

echo "=== 1. Estrutura da tabela (SQL de MySQL) ===\n";
$sql = <<<'SQL'
CREATE TABLE alunos (
    id    INT AUTO_INCREMENT PRIMARY KEY,
    nome  VARCHAR(120) NOT NULL,
    email VARCHAR(160) NOT NULL UNIQUE,
    nota  DECIMAL(4,2) DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
SQL;
echo $sql . "\n";

$pdo = new PDO('sqlite::memory:', null, null, [
    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$pdo->exec('CREATE TABLE alunos (id INTEGER PRIMARY KEY AUTOINCREMENT, nome TEXT NOT NULL, email TEXT NOT NULL UNIQUE, nota REAL)');

// --- CREATE ---
echo "\n=== 2. CREATE: INSERT ===\n";
$novos = [
    ['Ana Souza',      '[email protected]',      8.50],
    ['Bruno Lima',     '[email protected]',    6.00],
    ['Carla Dias',     '[email protected]',    9.25],
];

$sql = 'INSERT INTO alunos (nome, email, nota) VALUES (:nome, :email, :nota)';
$stmt = $pdo->prepare($sql);

$inseridas = 0;
foreach ($novos as [$nome, $email, $nota]) {
    $stmt->execute([':nome' => $nome, ':email' => $email, ':nota' => $nota]);
    printf("  inserido %-14s id %d (esta insercao alterou %d linha)\n",
        $nome, (int) $pdo->lastInsertId(), $stmt->rowCount());
    $inseridas += $stmt->rowCount();
}
printf("  %d linhas inseridas no total\n", $inseridas);

// --- READ ---
echo "\n=== 3. READ: SELECT com filtro ===\n";
$sql = 'SELECT id, nome, nota FROM alunos WHERE nota >= :minima ORDER BY nota DESC';
$stmt = $pdo->prepare($sql);
$stmt->execute([':minima' => 7.0]);

foreach ($stmt->fetchAll() as $aluno) {
    printf("  #%d %-14s %5.2f\n", $aluno['id'], $aluno['nome'], $aluno['nota']);
}

// --- UPDATE ---
echo "\n=== 4. UPDATE: SET com WHERE ===\n";
$sql = 'UPDATE alunos SET nota = :nota WHERE id = :id';
$stmt = $pdo->prepare($sql);
$stmt->execute([':nota' => 7.75, ':id' => 2]);
printf("  linhas alteradas: %d\n", $stmt->rowCount());

// --- DELETE ---
echo "\n=== 5. DELETE com WHERE ===\n";
$sql = 'DELETE FROM alunos WHERE id = :id';
$stmt = $pdo->prepare($sql);
$stmt->execute([':id' => 3]);
printf("  linhas removidas: %d\n", $stmt->rowCount());

// --- O DELETE sem WHERE apaga a tabela inteira ---
echo "\n=== 6. Por que o WHERE e obrigatorio ===\n";
$pdo->exec('CREATE TABLE backup_alunos AS SELECT * FROM alunos');
$antes = (int) $pdo->query('SELECT COUNT(*) FROM backup_alunos')->fetchColumn();

$stmt = $pdo->prepare('DELETE FROM backup_alunos');   // esqueceu o WHERE
$stmt->execute();
$depois = (int) $pdo->query('SELECT COUNT(*) FROM backup_alunos')->fetchColumn();

printf("  linhas antes do DELETE sem WHERE: %d\n", $antes);
printf("  linhas depois:                   %d\n", $depois);

// --- Chave que nao existe ---
echo "\n=== 7. Efeito colateral de nao conferir o rowCount ===\n";
$stmt = $pdo->prepare('DELETE FROM alunos WHERE id = :id');
$stmt->execute([':id' => 999]);
printf("  DELETE do id 999: %d linhas (nenhum aluno tem esse id)\n", $stmt->rowCount());

echo "\nAlunos que sobraram:\n";
foreach ($pdo->query('SELECT id, nome, nota FROM alunos ORDER BY id') as $aluno) {
    printf("  #%d %-14s %5.2f\n", $aluno['id'], $aluno['nome'], $aluno['nota']);
}

Saída real

=== 1. Estrutura da tabela (SQL de MySQL) ===
CREATE TABLE alunos (
    id    INT AUTO_INCREMENT PRIMARY KEY,
    nome  VARCHAR(120) NOT NULL,
    email VARCHAR(160) NOT NULL UNIQUE,
    nota  DECIMAL(4,2) DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4

=== 2. CREATE: INSERT ===
  inserido Ana Souza      id 1 (esta insercao alterou 1 linha)
  inserido Bruno Lima     id 2 (esta insercao alterou 1 linha)
  inserido Carla Dias     id 3 (esta insercao alterou 1 linha)
  3 linhas inseridas no total

=== 3. READ: SELECT com filtro ===
  #3 Carla Dias      9.25
  #1 Ana Souza       8.50

=== 4. UPDATE: SET com WHERE ===
  linhas alteradas: 1

=== 5. DELETE com WHERE ===
  linhas removidas: 1

=== 6. Por que o WHERE e obrigatorio ===
  linhas antes do DELETE sem WHERE: 2
  linhas depois:                   0

=== 7. Efeito colateral de nao conferir o rowCount ===
  DELETE do id 999: 0 linhas (nenhum aluno tem esse id)

Alunos que sobraram:
  #1 Ana Souza       8.50
  #2 Bruno Lima      7.75
Aula 4

Lendo resultados

Depois do execute(), o PDOStatement vira um cursor sobre o resultado. A escolha é entre puxar uma linha por vez ou trazer o array inteiro, e entre receber array ou objeto.

Os métodos de leitura

MétodoDevolveUso
fetch(?int $modo = null): mixeda próxima linha, ou false no fimwhile (($linha = $stmt->fetch()) !== false)
fetchAll(?int $modo = null): arraytodas as linhas, de uma vezlistagem de tamanho conhecido
fetchColumn(int $indice = 0): mixedum valor só da próxima linhaCOUNT(*), checagem de existência
fetchObject(string $classe = 'stdClass', array $construtor = []): objecta próxima linha como objetoresultado lido por atributo
$stmt = $pdo->prepare('SELECT id, nome FROM alunos ORDER BY nome');
$stmt->execute();

// uma linha por vez
while (($linha = $stmt->fetch()) !== false) {
    echo $linha['nome'], "\n";
}

// o array inteiro
$alunos = $stmt->fetchAll();

// um valor só
$stmt = $pdo->prepare('SELECT COUNT(*) FROM alunos WHERE turno = :turno');
$stmt->execute([':turno' => 'noturno']);
$total = (int) $stmt->fetchColumn();

O fetchColumn() anda com o cursor: chamado em sequência, devolve o valor da coluna pedida de cada linha. Com COUNT(*) numa consulta, devolve o total em uma chamada.

fetchAll() com dois argumentos

fetchAll(int $modo): array aceita um modo que muda o formato do array, sem passar por atributo.

ModoFormato do array
PDO::FETCH_ASSOC['id' => 1, 'nome' => 'Ana']
PDO::FETCH_NUM[0 => 1, 1 => 'Ana']
PDO::FETCH_OBJobjeto com atributo ->id e ->nome
PDO::FETCH_KEY_PAIRid => nome, quando o SELECT tem duas colunas
PDO::FETCH_COLUMNsó a coluna pedida, como lista de valores

PDO::FETCH_KEY_PAIR resolve o array associativo de duas colunas sem laço: SELECT id, nome vira [1 => 'Ana', 2 => 'Bruno'].

O que o fetch devolve no fim

fetch() devolve false quando não há mais linha. Com while ($linha = $stmt->fetch()), uma linha vazia ou com valor 0 interrompe o laço antes da hora. O while (($linha = $stmt->fetch()) !== false) evita isso.

O fetchAll() carrega o resultado inteiro na memória. Em tabela grande, o laço com fetch() percorre sem nada acumular, e é a diferença entre uma listagem de mil linhas e uma de cem mil.

Exemplo

<?php
declare(strict_types=1);

// Os jeitos de ler o resultado de uma consulta: fetch, fetchAll, fetchColumn,
// fetchObject e fetchAll com chave.
//
// Roda em SQLite (sqlite:) porque a imagem do gerador nao tem pdo_mysql. A
// API de leitura do PDO e a mesma para MySQL, entao o que aparece aqui e
// exatamente o que o aluno vera no MySQL.

$pdo = new PDO('sqlite::memory:', null, null, [
    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$pdo->exec('CREATE TABLE cursos (id INTEGER PRIMARY KEY AUTOINCREMENT, nome TEXT, turno TEXT, vagas INTEGER)');

$stmt = $pdo->prepare('INSERT INTO cursos (nome, turno, vagas) VALUES (:nome, :turno, :vagas)');
foreach ([
    ['Eletronica', 'matutino', 40],
    ['Informatica', 'vespertino', 35],
    ['Mecanica',  'noturno',    30],
    ['Quimica',   'matutino',  25],
] as [$nome, $turno, $vagas]) {
    $stmt->execute([':nome' => $nome, ':turno' => $turno, ':vagas' => $vagas]);
}

$sql = 'SELECT id, nome, turno, vagas FROM cursos ORDER BY nome';

echo "=== 1. fetch(): uma linha por vez, cursor fica no Statement ===\n";
$stmt = $pdo->prepare($sql);
$stmt->execute();
while (($curso = $stmt->fetch()) !== false) {
    printf("  %-12s %-11s %d vagas\n", $curso['nome'], $curso['turno'], $curso['vagas']);
}

echo "\n=== 2. fetchAll(): o array inteiro, de uma vez ===\n";
$stmt = $pdo->prepare($sql);
$stmt->execute();
$cursos = $stmt->fetchAll();
printf("  %d registros, %d colunas em cada um\n", count($cursos), count($cursos[0]));
printf("  primeira linha: %s -> id %d\n", $cursos[0]['nome'], $cursos[0]['id']);

echo "\n=== 3. fetchColumn(): um unico valor, nao a linha ===\n";
$stmt = $pdo->prepare($sql);
$stmt->execute();
printf("  fetchColumn() com indice 1: %s\n", $stmt->fetchColumn(1));

$stmt = $pdo->prepare('SELECT COUNT(*) FROM cursos WHERE turno = :turno');
$stmt->execute([':turno' => 'matutino']);
printf("  COUNT(*) do turno matutino: %d\n", (int) $stmt->fetchColumn());

echo "\n=== 4. fetchColumn() andando com o indice do loop ===\n";
$stmt = $pdo->prepare($sql);
$stmt->execute();
$nomes = [];
while (($nome = $stmt->fetchColumn(1)) !== false) {
    $nomes[] = $nome;
}
printf("  %s\n", implode(', ', $nomes));

echo "\n=== 5. fetchObject(): a linha vira um objeto ===\n";
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_OBJ);
$stmt = $pdo->prepare($sql);
$stmt->execute();
$curso = $stmt->fetch();
printf("  %s tem %d vagas no turno %s\n", $curso->nome, $curso->vagas, $curso->turno);

echo "\n=== 6. fetchAll(PDO::FETCH_KEY_PAIR): duas colunas viram chave e valor ===\n";
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
$stmt = $pdo->prepare('SELECT id, nome FROM cursos ORDER BY nome');
$stmt->execute();
$porId = $stmt->fetchAll(PDO::FETCH_KEY_PAIR);
foreach ($porId as $id => $nome) {
    printf("  %d => %s\n", $id, $nome);
}

echo "\n=== 7. rowCount() e o numero de linhas afetadas ===\n";
$stmt = $pdo->prepare('UPDATE cursos SET vagas = vagas - 1 WHERE turno = :turno');
$stmt->execute([':turno' => 'matutino']);
printf("  UPDATE: %d linha(s) alterada(s)\n", $stmt->rowCount());

$stmt = $pdo->prepare('SELECT id FROM cursos');
$stmt->execute();
printf("  SELECT devolveu %d linha(s) (aqui o certo e contar o fetch)\n", count($stmt->fetchAll()));

echo "\n=== 8. Memoria: fetchAll em tabela grande vs while com fetch ===\n";
$stmt = $pdo->prepare('SELECT nome FROM cursos ORDER BY nome');
$stmt->execute();
$contador = 0;
while ($stmt->fetch() !== false) {   // uma linha por vez: o array nunca cresce
    $contador++;
}
printf("  percorridas %d linhas sem guardar o resultado inteiro\n", $contador);

Saída real

=== 1. fetch(): uma linha por vez, cursor fica no Statement ===
  Eletronica   matutino    40 vagas
  Informatica  vespertino  35 vagas
  Mecanica     noturno     30 vagas
  Quimica      matutino    25 vagas

=== 2. fetchAll(): o array inteiro, de uma vez ===
  4 registros, 4 colunas em cada um
  primeira linha: Eletronica -> id 1

=== 3. fetchColumn(): um unico valor, nao a linha ===
  fetchColumn() com indice 1: Eletronica
  COUNT(*) do turno matutino: 2

=== 4. fetchColumn() andando com o indice do loop ===
  Eletronica, Informatica, Mecanica, Quimica

=== 5. fetchObject(): a linha vira um objeto ===
  Eletronica tem 40 vagas no turno matutino

=== 6. fetchAll(PDO::FETCH_KEY_PAIR): duas colunas viram chave e valor ===
  1 => Eletronica
  2 => Informatica
  3 => Mecanica
  4 => Quimica

=== 7. rowCount() e o numero de linhas afetadas ===
  UPDATE: 2 linha(s) alterada(s)
  SELECT devolveu 4 linha(s) (aqui o certo e contar o fetch)

=== 8. Memoria: fetchAll em tabela grande vs while com fetch ===
  percorridas 4 linhas sem guardar o resultado inteiro