Gemini Structured Output With Zod in Next.js
Describe the JSON once in Zod, send z.toJSONSchema() to Gemini as responseJsonSchema, then validate the reply with safeParse and retry once with the errors.
A PDO prepared statement keeps your SQL and your data apart: you write the query with placeholders like ? or :email, call $pdo->prepare(), then pass the real values to execute(). The database never treats those values as SQL, which is what protects you from SQL injection. In this beginner tutorial I connect to MySQL with PDO, then insert, select, update and delete with prepared statements, and cover LIKE searches, IN lists, LIMIT, transactions and the mistakes I made.
I ran every example on PHP 8.4 against a real MariaDB database (MariaDB works the same as MySQL for everything here), and the comments at the end of each block show the actual output. The examples run in order, so the data changes as you go.
Here's the problem in one example. Imagine a login or search box where someone types nobody@example.com' OR '1'='1:
<?php
require __DIR__ . '/db.php';
$email = "nobody@example.com' OR '1'='1"; // what an attacker might type
// UNSAFE: the input becomes part of the SQL itself
$rows = $pdo->query("SELECT name FROM students WHERE email = '$email'")->fetchAll();
echo "Unsafe query returned ", count($rows), " rows\n";
// SAFE: the input is sent separately as a value
$stmt = $pdo->prepare('SELECT name FROM students WHERE email = ?');
$stmt->execute([$email]);
echo "Prepared query returned ", count($stmt->fetchAll()), " rows\n";
// Output:
// Unsafe query returned 4 rows
// Prepared query returned 0 rows
When the input is pasted straight into the SQL string, the quote closes the email value early and OR '1'='1' becomes part of the query. It's always true, so the unsafe version returned every student in the table. The prepared version sent the whole input as one plain value, found no student with that strange "email", and returned nothing. That's the whole point of prepared statements.
I keep the connection in one file and require it everywhere else:
<?php
// db.php - keep this file outside your public web folder if you can
$host = getenv('DB_HOST') ?: '127.0.0.1';
$name = getenv('DB_NAME') ?: 'school';
$user = getenv('DB_USER') ?: 'app';
$pass = getenv('DB_PASS') ?: '';
$dsn = "mysql:host=$host;dbname=$name;charset=utf8mb4";
$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // throw on errors
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, // rows as ['col' => value]
PDO::ATTR_EMULATE_PREPARES => false, // real prepared statements
]);
charset=utf8mb4 in the DSN makes the connection use full UTF-8, so Bangla text and emoji are stored correctly.ERRMODE_EXCEPTION makes PDO throw a PDOException when something goes wrong. Since PHP 8.0 this is the default, but I set it explicitly so nobody has to wonder.FETCH_ASSOC returns rows as arrays keyed by column name, instead of duplicating every value under a number too.EMULATE_PREPARES => false asks MySQL to do the preparing itself, and it changes some behaviour you'll see later.For the examples I created a small table:
<?php
require __DIR__ . '/db.php';
$pdo->exec('DROP TABLE IF EXISTS enrolments, students');
$pdo->exec('CREATE TABLE students (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(60) NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE,
class_level INT NOT NULL
)');
echo "Table ready\n";
// Output:
// Table ready
<?php
require __DIR__ . '/db.php';
$stmt = $pdo->prepare(
'INSERT INTO students (name, email, class_level) VALUES (:name, :email, :level)'
);
$students = [
['name' => 'Rafi', 'email' => 'rafi@example.com', 'level' => 10],
['name' => 'Nusrat', 'email' => 'nusrat@example.com', 'level' => 11],
['name' => 'Tanvir', 'email' => 'tanvir@example.com', 'level' => 10],
['name' => 'Mim', 'email' => 'mim@example.com', 'level' => 12],
];
foreach ($students as $s) {
$stmt->execute($s); // prepare once, execute many times
echo "Inserted {$s['name']} with id ", $pdo->lastInsertId(), "\n";
}
// Output:
// Inserted Rafi with id 1
// Inserted Nusrat with id 2
// Inserted Tanvir with id 3
// Inserted Mim with id 4
Named placeholders like :name make longer queries easier to read. The array keys match the placeholder names, and the leading colon in the keys is optional. Notice that I call prepare() once and execute() four times. The query is parsed once and reused, which is cleaner and can be faster for repeated inserts. lastInsertId() gives you the auto-increment ID of the new row.
<?php
require __DIR__ . '/db.php';
// One row, positional ? placeholder
$stmt = $pdo->prepare('SELECT id, name, class_level FROM students WHERE email = ?');
$stmt->execute(['nusrat@example.com']);
$student = $stmt->fetch(); // an array, or false if no row matched
if ($student === false) {
echo "Not found\n";
} else {
echo "{$student['name']} is in class {$student['class_level']}\n";
}
// Many rows, named placeholder
$stmt = $pdo->prepare('SELECT name FROM students WHERE class_level = :level ORDER BY name');
$stmt->execute(['level' => 10]);
foreach ($stmt->fetchAll() as $row) {
echo "- {$row['name']}\n";
}
// Just one column from every row
$stmt = $pdo->prepare('SELECT name FROM students WHERE class_level >= ?');
$stmt->execute([11]);
print_r($stmt->fetchAll(PDO::FETCH_COLUMN));
// Output:
// Nusrat is in class 11
// - Rafi
// - Tanvir
// Array
// (
// [0] => Nusrat
// [1] => Mim
// )
fetch() returns the next row, or false when there are no more. Always check for false before using the result.fetchAll() returns every row as an array. That's fine for small results. For thousands of rows, loop with fetch() instead.fetchAll(PDO::FETCH_COLUMN) gives a flat array of the first column, which is perfect for lists of names or IDs.Positional ? placeholders are filled in order from a plain array. You can use either style, but not both in the same query.
These two confuse almost every beginner, because the "obvious" way doesn't work:
<?php
require __DIR__ . '/db.php';
// LIKE: put the % signs in the value, not in the SQL
$search = 'a';
$stmt = $pdo->prepare('SELECT name FROM students WHERE name LIKE ? ORDER BY name');
$stmt->execute(['%' . $search . '%']);
echo implode(', ', $stmt->fetchAll(PDO::FETCH_COLUMN)), "\n";
// IN (...): build one ? per value
$ids = [1, 3, 4];
$placeholders = implode(', ', array_fill(0, count($ids), '?'));
$stmt = $pdo->prepare("SELECT name FROM students WHERE id IN ($placeholders)");
$stmt->execute($ids);
echo implode(', ', $stmt->fetchAll(PDO::FETCH_COLUMN)), "\n";
// Output:
// Nusrat, Rafi, Tanvir
// Rafi, Tanvir, Mim
For LIKE, the % wildcards belong in the value you pass, not around the placeholder in the SQL. For IN, one placeholder can only hold one value, so you generate a ? for each item and pass the array. Never implode() the values themselves into the SQL.
<?php
require __DIR__ . '/db.php';
// LIMIT/OFFSET: bind as integers
$perPage = 2;
$page = 2;
$stmt = $pdo->prepare('SELECT name FROM students ORDER BY id LIMIT :limit OFFSET :offset');
$stmt->bindValue(':limit', $perPage, PDO::PARAM_INT);
$stmt->bindValue(':offset', ($page - 1) * $perPage, PDO::PARAM_INT);
$stmt->execute();
echo "Page 2: ", implode(', ', $stmt->fetchAll(PDO::FETCH_COLUMN)), "\n";
// ORDER BY a column chosen by the user: placeholders can't be used for
// column names, so map the input to an allow-list instead
$sortInput = $_GET['sort'] ?? 'name'; // pretend this came from the URL
$columns = ['name' => 'name', 'level' => 'class_level'];
$orderBy = $columns[$sortInput] ?? 'name';
$rows = $pdo->query("SELECT name FROM students ORDER BY $orderBy")->fetchAll(PDO::FETCH_COLUMN);
echo "Sorted by $orderBy: ", implode(', ', $rows), "\n";
// Output:
// Page 2: Tanvir, Mim
// Sorted by name: Mim, Nusrat, Rafi, Tanvir
For pagination, I bind LIMIT and OFFSET with bindValue() and PDO::PARAM_INT. This matters when emulated prepares are on: in my test, passing a LIMIT value as a string through execute() worked with emulation off but failed with a syntax error (SQLSTATE 42000) with emulation on, because PDO quoted it as '2'. Binding as an integer works in both modes.
Placeholders only work for values. You can't use them for table names, column names or keywords like ASC. When the user picks a sort column, map their choice to a fixed list of real column names, and fall back to a default for anything else. The user's input then never reaches the SQL at all.
<?php
require __DIR__ . '/db.php';
$stmt = $pdo->prepare('UPDATE students SET class_level = class_level + 1 WHERE class_level = ?');
$stmt->execute([10]);
echo $stmt->rowCount(), " students moved up\n";
$stmt = $pdo->prepare('DELETE FROM students WHERE email = ?');
$stmt->execute(['nobody@example.com']);
echo $stmt->rowCount(), " rows deleted\n"; // 0: no error, just nothing matched
// Output:
// 2 students moved up
// 0 rows deleted
rowCount() tells you how many rows were changed. A query that matches nothing isn't an error, so check this when you need to know whether something actually happened, like showing "not found" when deleting a record that doesn't exist.
When several queries must succeed together, wrap them in a transaction. If anything fails, rollBack() undoes all of it:
<?php
require __DIR__ . '/db.php';
$pdo->exec('CREATE TABLE IF NOT EXISTS enrolments (
student_id INT NOT NULL,
course VARCHAR(40) NOT NULL
)');
try {
$pdo->beginTransaction();
$stmt = $pdo->prepare('INSERT INTO students (name, email, class_level) VALUES (?, ?, ?)');
$stmt->execute(['Sadia', 'sadia@example.com', 9]);
$id = (int) $pdo->lastInsertId();
$stmt = $pdo->prepare('INSERT INTO enrolments (student_id, course) VALUES (?, ?)');
$stmt->execute([$id, 'Web Development']);
// Same email again: the UNIQUE column makes this fail
$stmt = $pdo->prepare('INSERT INTO students (name, email, class_level) VALUES (?, ?, ?)');
$stmt->execute(['Sadia again', 'sadia@example.com', 9]);
$pdo->commit();
} catch (PDOException $e) {
$pdo->rollBack();
echo "Rolled back: SQLSTATE ", $e->getCode(), "\n";
}
$count = $pdo->query("SELECT COUNT(*) FROM students WHERE email = 'sadia@example.com'")->fetchColumn();
echo "Sadia rows saved: $count\n";
// Output:
// Rolled back: SQLSTATE 23000
// Sadia rows saved: 0
The third insert broke the UNIQUE rule on the email column. Because everything was in one transaction, the first two inserts were undone too, and Sadia wasn't saved in a half-finished state. Transactions need a storage engine that supports them, like InnoDB, which is MySQL's default.
$pdo->prepare("SELECT * FROM users WHERE id = $id") isn't protected at all, because the value was already pasted into the SQL before prepare() saw it. The value must go into execute() or bindValue().
<?php
require __DIR__ . '/db.php';
try {
// The same named placeholder twice, with emulated prepares turned off
$stmt = $pdo->prepare('SELECT name FROM students WHERE name = :term OR email = :term');
$stmt->execute(['term' => 'Rafi']);
} catch (PDOException $e) {
echo "Error: SQLSTATE ", $e->getCode(), "\n";
}
// Fix: give each placeholder its own name
$stmt = $pdo->prepare('SELECT name FROM students WHERE name = :name OR email = :email');
$stmt->execute(['name' => 'Rafi', 'email' => 'Rafi']);
echo $stmt->fetchColumn(), "\n";
// Output:
// Error: SQLSTATE HY093
// Rafi
With emulated prepares turned off, each named placeholder can appear only once, otherwise you get SQLSTATE HY093 ("invalid parameter number"). Give each one its own name, even if the value is the same.
WHERE email = '?' searches for a literal question mark. Placeholders never get quotes. PDO handles that.
Catch PDOException, log the details, and show a friendly message. Error messages can reveal table names, queries or connection details.
Prepared statements stop SQL injection, but they don't check that an email is an email or that a number is in range. Validate first, then store.
Both support prepared statements. I prefer PDO because the same API works with MySQL, SQLite, PostgreSQL and other databases, and named placeholders make queries easier to read.
Passing an array to execute() is the simplest and covers most cases. bindValue() lets you set a type, such as PARAM_INT. bindParam() binds a variable by reference, so its value is read when execute() runs, which beginners rarely need.
Yes. Prepared statements protect the database. When you print data from the database into a page, escape it with htmlspecialchars() to protect against XSS.
Yes, PHP closes it when the script ends. You can set $pdo = null to close it earlier, but in a normal web request you don't need to.
PDO prepared statements come down to one habit: SQL with placeholders goes into prepare(), values go into execute(). Connect with utf8mb4 and exceptions turned on, put % inside LIKE values, generate placeholders for IN lists, bind LIMIT as an integer, use allow-lists for column names, and wrap related writes in transactions.
The PHP manual's page on prepared statements and stored procedures is short and worth reading. You can also see what I'm building on my projects page.
// note
This is a learning note from studying the web. It is one small topic, written so I can remember it. It is not a course and not a claim that I have finished the subject.
If a sentence is wrong, say so from the contact page and name this title. Drafts never appear here. Related notes, when they exist, are other published posts, and the same sample rule applies to each of them.
Describe the JSON once in Zod, send z.toJSONSchema() to Gemini as responseJsonSchema, then validate the reply with safeParse and retry once with the errors.
Step by step: a Gemini key from Google AI Studio in .env.local, a server-only helper, a Next.js route handler, Vercel environment variables and a leak test.
A small offline habit tracker in plain HTML and JavaScript: habits saved as JSON in localStorage, streaks from local dates, a 7-day row and a JSON backup.