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.
To validate and sanitize a PHP form safely: read each field from $_POST with a default, trim() it, check it against clear rules with functions like filter_var(), mb_strlen() and an allow-list, collect the errors in an array, and only use the data when there are none. Then escape every value with htmlspecialchars() when you output it into HTML, and use prepared statements when you store it. In this guide I build a complete contact form handler step by step on PHP 8.4, including CSRF protection and the Post/Redirect/Get pattern.
When I started with PHP, I thought "sanitizing" meant running every input through a magic function and calling it safe. That's not how it works. There are two separate jobs: validation (is this input acceptable?) and escaping (make this value safe for the place it's going). I tested every snippet here with php-cli, and I ran the full form on PHP's built-in server and sent real requests to it with curl.
htmlspecialchars() for HTML, prepared statements for SQL, escapeshellarg() for shell commands.The rule I follow now: validate on input, escape on output.
I keep validation in a plain function that takes an array and returns the clean data plus an array of errors. That makes it easy to test without a browser:
<?php
declare(strict_types=1);
/**
* Validate a contact form submission.
* Returns [cleanData, errors]. Errors are keyed by field name.
*/
function validate_contact(array $input): array
{
$errors = [];
// 1. Read each field as a trimmed string. Missing fields or arrays
// (someone sending name[]=x) become an empty string.
$field = fn(string $key): string =>
is_string($input[$key] ?? null) ? trim($input[$key]) : '';
$name = $field('name');
$email = $field('email');
$age = $field('age');
$topic = $field('topic');
$message = $field('message');
// 2. Validate each one
if ($name === '') {
$errors['name'] = 'Please enter your name.';
} elseif (mb_strlen($name) > 60) {
$errors['name'] = 'Name must be 60 characters or fewer.';
}
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
$errors['email'] = 'Please enter a valid email address.';
}
$ageInt = null;
if ($age !== '') {
$ageInt = filter_var($age, FILTER_VALIDATE_INT, [
'options' => ['min_range' => 13, 'max_range' => 120],
]);
if ($ageInt === false) {
$errors['age'] = 'Age must be a whole number from 13 to 120.';
}
}
$allowedTopics = ['project', 'freelance', 'question'];
if (!in_array($topic, $allowedTopics, true)) {
$errors['topic'] = 'Please choose a topic from the list.';
}
$length = mb_strlen($message);
if ($length < 10 || $length > 2000) {
$errors['message'] = 'Message must be between 10 and 2000 characters.';
}
$clean = [
'name' => $name,
'email' => $email,
'age' => $ageInt,
'topic' => $topic,
'message' => $message,
];
return [$clean, $errors];
}
A few choices here are deliberate:
$field helper handles missing fields and also the case where someone sends name[]=x, which makes $_POST['name'] an array. Without the check, that causes warnings or type errors.FILTER_VALIDATE_EMAIL checks the email format. It doesn't prove the address exists. Only a confirmation email can do that.FILTER_VALIDATE_INT with min_range/max_range rejects 12.5, 19abc and out-of-range numbers in one call. Note the check for === false: a valid value of 0 is falsy, so a loose check would treat it as an error.in_array(..., true) uses strict comparison.mb_strlen() instead of strlen(). More on that in the mistakes section.<?php
require __DIR__ . '/validate.php';
// A good submission (note the extra spaces and Bangla text)
[$data, $errors] = validate_contact([
'name' => ' Mahir Faysal ',
'email' => 'mahir@example.com',
'age' => '19',
'topic' => 'project',
'message' => 'আমি à¦à¦•টি ওয়েবসাইট চাই। Can we talk?',
]);
var_dump($errors);
echo $data['name'], ' | age ', $data['age'], "\n";
// A bad submission
[, $errors] = validate_contact([
'name' => '',
'email' => 'not-an-email',
'age' => '12.5',
'topic' => 'admin',
'message' => 'hi',
]);
print_r($errors);
// Someone sends arrays instead of strings: no warnings, just errors
[, $errors] = validate_contact(['name' => ['x'], 'email' => ['a@b.c']]);
echo count($errors), " errors\n";
// Output:
// array(0) {
// }
// Mahir Faysal | age 19
// Array
// (
// [name] => Please enter your name.
// [email] => Please enter a valid email address.
// [age] => Age must be a whole number from 13 to 120.
// [topic] => Please choose a topic from the list.
// [message] => Message must be between 10 and 2000 characters.
// )
// 4 errors
The good submission, with extra spaces and a Bangla message, passes and comes back trimmed. The bad one produces a clear message for every field, and the array input doesn't cause a single warning. I always test validation like this before wiring up the HTML.
Whatever the user typed must never be printed into HTML as-is, or a value like <script> runs in your visitors' browsers. That's cross-site scripting (XSS).
<?php
$comment = '<script>alert("hacked")</script> Tom & Jerry\'s "show"';
// Escape when you OUTPUT into HTML, not when you save
echo htmlspecialchars($comment, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), "\n";
// A tiny helper so you never forget the flags
function e(?string $value): string
{
return htmlspecialchars($value ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
echo '<input name="name" value="' . e('Rafi "the coder"') . '">', "\n";
// Output:
// <script>alert("hacked")</script> Tom & Jerry's "show"
// <input name="name" value="Rafi "the coder"">
Since PHP 8.1, htmlspecialchars() escapes both double and single quotes by default, but I still pass ENT_QUOTES | ENT_SUBSTITUTE and 'UTF-8' explicitly, so the code is safe on any version and obvious to read. The short e() helper means I never have to remember the flags. Escape inside attributes too, like value="...", not just in text.
Here's the full page. It shows the form on GET, validates on POST, keeps what the user typed when there are errors, and redirects after a successful submission:
<?php
declare(strict_types=1);
session_start();
require __DIR__ . '/validate.php';
function e(?string $value): string
{
return htmlspecialchars($value ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
// One CSRF token per session
if (empty($_SESSION['csrf'])) {
$_SESSION['csrf'] = bin2hex(random_bytes(32));
}
$errors = [];
$old = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$token = (string) ($_POST['csrf'] ?? '');
if (!hash_equals($_SESSION['csrf'], $token)) {
http_response_code(400);
exit('Invalid form token. Please reload the page and try again.');
}
[$data, $errors] = validate_contact($_POST);
// Keep what the user typed so they don't have to start again
$old = array_map(fn($v) => is_string($v) ? trim($v) : '', $_POST);
if ($errors === []) {
// Save or email $data here (with prepared statements for the database)
$_SESSION['flash'] = 'Thanks, ' . $data['name'] . '! Your message was sent.';
header('Location: ' . $_SERVER['PHP_SELF'], true, 303); // Post/Redirect/Get
exit;
}
http_response_code(422);
}
$flash = $_SESSION['flash'] ?? '';
unset($_SESSION['flash']);
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Contact</title>
</head>
<body>
<?php if ($flash !== ''): ?>
<p><?= e($flash) ?></p>
<?php endif; ?>
<form method="post" novalidate>
<input type="hidden" name="csrf" value="<?= e($_SESSION['csrf']) ?>">
<label for="name">Name</label>
<input id="name" name="name" type="text" value="<?= e($old['name'] ?? '') ?>" required maxlength="60">
<?php if (isset($errors['name'])): ?><p><?= e($errors['name']) ?></p><?php endif; ?>
<label for="email">Email</label>
<input id="email" name="email" type="email" value="<?= e($old['email'] ?? '') ?>" required>
<?php if (isset($errors['email'])): ?><p><?= e($errors['email']) ?></p><?php endif; ?>
<label for="age">Age (optional)</label>
<input id="age" name="age" type="number" min="13" max="120" value="<?= e($old['age'] ?? '') ?>">
<?php if (isset($errors['age'])): ?><p><?= e($errors['age']) ?></p><?php endif; ?>
<label for="topic">Topic</label>
<select id="topic" name="topic">
<?php foreach (['project' => 'A project', 'freelance' => 'Freelance work', 'question' => 'A question'] as $value => $label): ?>
<option value="<?= e($value) ?>"<?= ($old['topic'] ?? '') === $value ? ' selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
<?php if (isset($errors['topic'])): ?><p><?= e($errors['topic']) ?></p><?php endif; ?>
<label for="message">Message</label>
<textarea id="message" name="message" required minlength="10" maxlength="2000"><?= e($old['message'] ?? '') ?></textarea>
<?php if (isset($errors['message'])): ?><p><?= e($errors['message']) ?></p><?php endif; ?>
<button type="submit">Send</button>
</form>
</body>
</html>
What each part does:
hash_equals() compares them in constant time. This stops another site from submitting the form on behalf of a logged-in visitor. In my test, a POST with a wrong token got a 400 response.<b>Rafi</b> "x" as the name, and it came back as harmless text in the value attribute.novalidate on the form is there so I could test the server side. On a real site, remove it and keep the HTML required, maxlength and type attributes for instant feedback. They're a convenience, not protection.This "check everything on the server, whatever the client did" idea is the same one I used in a Next.js project, where the API route checks each request against a strict schema and rejects unknown fields. I wrote about it in how I kept my AI API key off the browser.
Many older tutorials still use it. It has been deprecated since PHP 8.1, and on PHP 8.4 using it prints a deprecation notice. It also mangled legitimate input. Validate the value and escape it on output instead.
Running htmlspecialchars() before saving to the database stores &-style text, which then gets double-escaped or breaks in emails and JSON. Store the clean original, and escape for the specific output.
<?php
$name = 'মাহির';
echo strlen($name), "\n"; // bytes
echo mb_strlen($name), "\n"; // characters
// Output:
// 15
// 5
A five-letter Bangla name is 15 bytes in UTF-8. With strlen(), a limit of 60 "characters" would reject names far shorter than you meant to allow. Use mb_strlen() for user-facing length limits (it needs the mbstring extension, which most hosts enable).
<?php
var_dump(filter_var('42', FILTER_VALIDATE_INT));
var_dump(filter_var('42abc', FILTER_VALIDATE_INT));
var_dump(filter_var('0', FILTER_VALIDATE_INT)); // valid, but falsy!
var_dump(filter_var('yes', FILTER_VALIDATE_BOOLEAN));
var_dump(filter_var('https://mfaysal.com', FILTER_VALIDATE_URL));
var_dump(filter_var('javascript://x%0Aalert(1)', FILTER_VALIDATE_URL)); // passes!
// So also check the scheme yourself before using a URL in a link
$url = 'javascript://x%0Aalert(1)';
$scheme = parse_url($url, PHP_URL_SCHEME);
var_dump(in_array($scheme, ['http', 'https'], true));
// Output:
// int(42)
// bool(false)
// int(0)
// bool(true)
// string(19) "https://mfaysal.com"
// string(25) "javascript://x%0Aalert(1)"
// bool(false)
FILTER_VALIDATE_URL checks the format, not whether the URL is safe. A javascript: URL can pass. If you're going to put a URL into a link, also check that the scheme is http or https.
$_REQUEST mixes GET, POST and cookies, so you can't be sure where a value came from. Read from $_POST and check $_SERVER['REQUEST_METHOD'].
No. It improves the experience, but anyone can bypass it with dev tools or by sending a request directly. Server-side validation is required. Client-side validation is optional, but worth adding: see HTML form input types and validation.
Usually not. It deletes parts of what the user typed, such as anything after a < in "a <3 b". Escaping on output with htmlspecialchars() keeps the text and makes it safe.
Use PDO with prepared statements and pass the values separately from the SQL. Validation and prepared statements protect against different problems, so you need both.
Next to each field, as in the example, plus a short summary at the top for long forms. Keep the user's input so they only fix what's wrong.
Secure PHP form handling comes down to a few habits: read inputs defensively, validate with filter_var(), mb_strlen() and allow-lists, collect errors and redisplay the form with the user's input, protect it with a CSRF token, redirect after success, and escape everything on output with htmlspecialchars(). To email the result instead of storing it, see my PHP contact form with mail(), and to block bots add a simple math captcha.
The PHP manual's page on filter_var() links to the full list of validation filters and their options, and the htmlspecialchars() page next to it explains every flag.
// 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.