LEARN TO CODE | PHP | 04 MINI PROJECTS AND NEXT STEPS
04.3 Project: a contact form
Build and test a complete browser-to-PHP-to-database enquiry form, with clear feedback and no false claim that email was sent.
01 | DEFINE THE PROJECT BEFORE CODING
A club wants visitors to leave an enquiry. The visitor supplies a name, email address and message. The server checks the fields and saves one record. If something is wrong, it explains the problem and keeps the entered values for correction.
This project saves an enquiry to a database. It does not send email. This lets you learn form processing without also configuring a mail service. The success wording says exactly what happened.
Use PHP 8.1 or later with PDO MySQL and a running local MariaDB/MySQL server. The PHP built-in server does not install or start the database. Download the complete Section 04 example files , extract them and open the php04-examples folder in VS Code.
The public folder contains browser-facing pages. The private and tools folders are siblings outside that document root. Keep the complete folder structure: copying individual PHP pages without their shared files will not work.
02 | TURN THE BRIEF INTO ACCEPTANCE CRITERIA
| Requirement | Evidence |
|---|---|
| Required name, email and message | Blank submissions display errors; database unchanged |
| Valid email shape | Malformed address rejected |
| No script execution from message text | Special characters redisplayed as text |
| Exactly one save after valid submit | Database row count rises by one; refresh after redirect does not add another |
| Untrusted POST does not bypass checks | Missing token rejected; arrays handled safely |
| Failure is not success | Unavailable database never produces a saved confirmation |
Validation checks email syntax, not whether the mailbox exists or belongs to the sender. A saved enquiry also does not prove that anyone has read it.
03 | PREPARE THE STORAGE
In your database administration client, run setup.sql once in a new local practice environment . Replace the password placeholder first. Copy private/config.example.php to private/config.php and enter the same account, host, port and database details. Never put your administrator credentials in the application.
The setup creates Users, Resources and Messages. If you already ran it, do not keep rerunning CREATE DATABASE; inspect the existing practice database or ask your teacher for a clean one. Use fictional details throughout.
CREATE TABLE Messages (
message_id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
sender_name VARCHAR(100) NOT NULL,
email VARCHAR(254) NOT NULL,
message TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;
This is the Messages part of setup.sql. message_id identifies each separate enquiry. created_at is recorded by the database. The sender does not choose either value.
The application account can INSERT into Messages but cannot read a public inbox. Use your administrator SQL client for classroom verification. A real inbox would need separate authentication and permission checks; do not add a public SELECT page containing private enquiries.
04 | PLAN THE REQUEST JOURNEY
- GET requests contact.php and displays the empty form with a CSRF token.
- The browser POSTs the fields and token to the same page.
- PHP checks the token and validates the fields.
- Invalid fields redisplay the form; valid fields go into a prepared INSERT.
- After a successful save, PHP stores a one-use session message and redirects with 303.
- The new GET shows the confirmation and removes that message.
The session flag remembers a confirmation between two requests. It is not a copy of the enquiry and is not the permanent storage.
05 | SHARED CODE YOU ALREADY KNOW
contact.php requires private/bootstrap.php from 04.1. That file starts sessions, creates the token, defines h and postText, and loads openDatabase.
postText returns trimmed text or an empty string if a malicious or mistaken request sends an array. h escapes values for HTML text and ordinary quoted attributes. checkCsrf compares the submitted token to the session token before processing the form.
Use the full shared files in the download. No sign-in is required for this contact form; a CSRF token identifies a form session, not a logged-in person.
06 | BUILD THE COMPLETE FORM
public/contact.php
<?php
require __DIR__ . '/../private/bootstrap.php';
$errors = [];
$name = $email = $message = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
checkCsrf();
$name = postText('name');
$email = postText('email');
$message = postText('message');
if ($name === '' || strlen($name) > 100) $errors[] = 'Name must be 1 to 100 bytes.';
if (strlen($email) > 254 || !filter_var($email, FILTER_VALIDATE_EMAIL)) $errors[] = 'Enter a valid email address.';
if ($message === '' || strlen($message) > 2000) $errors[] = 'Message must be 1 to 2000 bytes.';
if ($errors === []) {
$db = openDatabase();
$q = $db->prepare('INSERT INTO Messages (sender_name, email, message) VALUES (:name, :email, :message)');
$q->execute(['name' => $name, 'email' => $email, 'message' => $message]);
$_SESSION['contact_saved'] = true;
redirect('/contact.php');
}
}
$saved = $_SESSION['contact_saved'] ?? false;
unset($_SESSION['contact_saved']);
?>
<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Contact project</title></head><body>
<h1>Contact the club</h1>
<p>This local demo saves an enquiry to the database. It does not send email. Use fictional details.</p>
<?php if ($saved): ?><p role="status">Your enquiry was saved.</p><?php endif; ?>
<?php foreach ($errors as $error): ?><p role="alert"><?= h($error) ?></p><?php endforeach; ?>
<form method="post">
<input type="hidden" name="csrf" value="<?= h($_SESSION['csrf']) ?>">
<p><label for="name">Name : </label><input id="name" name="name" required maxlength="100" value="<?= h($name) ?>"></p>
<p><label for="email">Email : </label><input id="email" type="email" name="email" required maxlength="254" value="<?= h($email) ?>"></p>
<p><label for="message">Message : </label><textarea id="message" name="message" required maxlength="2000"><?= h($message) ?></textarea></p>
<button>Save enquiry</button>
</form>
<p><a href="/resources.php">Resource directory</a></p>
</body></html>
07 | BREAK DOWN THE VALIDATION
The server requires a non-blank name and message, limits their lengths and uses FILTER_VALIDATE_EMAIL for the address. These checks run even if the browser’s required or type=email checks were bypassed.
strlen counts bytes , not user-perceived characters. The project deliberately enforces name 1 to 100 bytes and message 1 to 2000 bytes; some non-ASCII characters use several bytes. HTML maxlength uses a different counting convention, so server messages remain authoritative. A multilingual product should choose a deliberate character-counting rule and matching storage/UI behaviour.
We collect errors in an array rather than stopping at the first invalid field. The page then displays each error and keeps the name, email and message, escaped before output.
TRY IT YOURSELF
Submit name[]=Alex rather than a normal name value. What should happen?
Reveal guidance
postText returns an empty string for an array, so validation rejects the name without a trim TypeError. Browser controls cannot be trusted to guarantee scalar request values.
08 | SAVE DATA WITHOUT BUILDING SQL FROM IT
The INSERT statement has fixed SQL and placeholders for the three field values. execute supplies those values separately. A name such as O’Neill or text containing an apostrophe must stay data rather than becoming part of SQL syntax.
Prepared statements protect the query boundary. They do not make a message suitable for HTML. If you display it later, escape it for the output context. Avoid HTML-escaping the value before storage merely to make this one page work; store the intended plain text.
Only a successful execute reaches contact_saved and the redirect. A database exception is handled as failure, with no saved flag set.
09 | WHY REDIRECT AFTER SAVING?
Without a redirect, refreshing a page that resulted from POST can repeat the submission. Here a 303 tells the browser to make a new GET. Refreshing that result repeats the read, not the INSERT.
The confirmation is consumed after it is read, so it is not permanently attached to the URL. This is often called a flash message: short-lived feedback passed through a session.
This pattern prevents ordinary refresh resubmission; it does not guarantee deduplication of simultaneous double-clicks or intentionally repeated POSTs. A stronger exactly-once workflow would need its own idempotency design.
10 | RUN AND INSPECT BOTH SIDES
In the VS Code terminal, from the project root, start the server once:
php -c development.ini -S 127.0.0.1:8000 -t public
Keep this terminal running. Hover over http://127.0.0.1:8000 and follow the link, then add the required filename in the browser. Save PHP changes in VS Code and refresh the browser. You do not need to restart the server for every source edit; configuration changes may require a restart.
Use this local PHP server rather than a static Live Server extension. The browser must request a page through PHP for server-side code to execute.
SELECT message_id, sender_name, email, message, created_at
FROM Messages
ORDER BY message_id DESC;
Open /contact.php. Submit a fictional enquiry, then run this SELECT as administrator. Verify the stored text and row count, not just the browser’s confirmation.
TRY IT YOURSELF
Test a valid enquiry, blank message, invalid email, angle brackets, an oversized message, and a missing token. Then refresh after a successful submission.
Reveal guidance
Only valid submissions add records. Markup is displayed as text when redisplayed. Missing CSRF receives 403. Refresh after the redirect does not add another row. A larger-than-permitted value must fail server validation even if the browser would normally block it.
11 | EXTEND THE PROJECT HONESTLY
Add an allow-listed enquiry category and store it in a matching column. Update the schema, validation, INSERT and tests together. A new dropdown is not complete until the server checks it.
If you later send email, use a maintained mail library or provider with fixed recipient and sender configuration; do not concatenate submitted values into raw mail headers. Only say “email sent” when that delivery stage actually succeeds, and distinguish acceptance by a mail service from final delivery.
Before a real public launch, plan abuse prevention, privacy information, retention and restricted staff access. These are practical project requirements, not features provided automatically by saving a form.
KEEP LEARNING
Previous page 04.4 Project: a database-backed resource directory
Download complete Section 04 example files
Official references: PHP error configuration · Password hashing · Password verification · Session identifier rotation