Chapter 20 ยท Final Project
Project Part 3: Finish & Polish
The final stretch: editing, safe deleting, and locking the whole app behind a login. Then we step back, admire the finished application, and talk about where your journey goes next. Let's finish strong.
The edit page
Editing is the add form's twin, with two differences: we first load the existing
student, and save() will UPDATE because the object has an id. Our class design makes
this page almost free:
edit.php<?php
session_start();
require "config.php";
require "helpers.php";
require "src/Database.php";
require "src/Student.php";
// 1. Which student? The id arrives in the URL: edit.php?id=3
$id = (int)($_GET["id"] ?? 0);
$student = Student::find($id);
if ($student === null) { // someone typed a fake id in the URL
flash_set("error", "Student not found.");
redirect("index.php");
}
$errors = [];
// 2. Same processing as create.php
if ($_SERVER["REQUEST_METHOD"] === "POST") {
$student->studentNumber = trim($_POST["student_number"] ?? "");
$student->fullName = trim($_POST["full_name"] ?? "");
$student->program = $_POST["program"] ?? "";
$student->feeBalance = (float)($_POST["fee_balance"] ?? 0);
$errors = $student->validate();
if (count($errors) === 0) {
try {
$student->save(); // id exists โ UPDATE happens automatically
flash_set("success", "Student {$student->fullName} updated.");
redirect("index.php");
} catch (PDOException $ex) {
$errors[] = $ex->getCode() == 23000
? "That student number already exists."
: "Database error โ please try again.";
}
}
}
$pageTitle = "Edit student";
require "partials/header.php";
?>
<h1>Edit: <?= e($student->fullName) ?></h1>
<?php if ($errors): ?>
<div class="errors">
<?php foreach ($errors as $err): ?><p><?= e($err) ?></p><?php endforeach; ?>
</div>
<?php endif; ?>
<form method="post" class="card">
<label>Student number</label>
<input type="text" name="student_number" value="<?= e($student->studentNumber) ?>">
<label>Full name</label>
<input type="text" name="full_name" value="<?= e($student->fullName) ?>">
<label>Programme</label>
<select name="program">
<?php foreach (Config::PROGRAMS as $p): ?>
<option value="<?= e($p) ?>" <?= $student->program === $p ? "selected" : "" ?>>
<?= e($p) ?>
</option>
<?php endforeach; ?>
</select>
<label>Fee balance (K)</label>
<input type="number" name="fee_balance" step="0.01" min="0"
value="<?= e((string)$student->feeBalance) ?>">
<p><button class="btn" type="submit">Save changes</button></p>
</form>
<?php require "partials/footer.php"; ?>
The first visit is a GET: the form appears pre-filled from the database. Submit, and the same object saves back. Notice we handle a hostile URL (fake id) gracefully โ never assume the id in a URL is real; users can type anything up there.
Delete โ with confirmation
A delete link that fires instantly is how records get destroyed by accidental clicks. We show a confirmation page first, and the real deletion happens by POST โ remember GET asks, POST changes:
delete.php<?php
session_start();
require "config.php";
require "helpers.php";
require "src/Database.php";
require "src/Student.php";
$id = (int)($_GET["id"] ?? $_POST["id"] ?? 0);
$student = Student::find($id);
if ($student === null) {
flash_set("error", "Student not found.");
redirect("index.php");
}
// The actual deletion โ only ever by POST
if ($_SERVER["REQUEST_METHOD"] === "POST") {
Student::delete($student->id);
flash_set("success", "Student {$student->fullName} deleted.");
redirect("index.php");
}
$pageTitle = "Delete student";
require "partials/header.php";
?>
<h1>Delete student?</h1>
<p>You are about to permanently delete
<strong><?= e($student->fullName) ?> (<?= e($student->studentNumber) ?>)</strong>.
This cannot be undone.</p>
<form method="post">
<input type="hidden" name="id" value="<?= $student->id ?>">
<button class="btn btn-danger" type="submit">Yes, delete</button>
<a class="btn" href="index.php">Cancel</a>
</form>
<?php require "partials/footer.php"; ?>
(The hidden input smuggles the id inside the POST envelope โ a very common trick.)
Creating the admin account
We need one user in the users table, with a properly cooked password. Run this once, then delete the file โ a hash generator lying around on a live server is a gift to attackers:
make-admin.php (run once, then DELETE)<?php
require "config.php";
require "src/Database.php";
$pdo = Database::connect();
$stmt = $pdo->prepare("INSERT INTO users (username, password_hash) VALUES (?, ?)");
$stmt->execute(["admin", password_hash("admin123", PASSWORD_DEFAULT)]);
echo "Admin created. Username: admin, password: admin123. NOW DELETE THIS FILE.";
Login, logout, and the bouncer
Straight from Chapter 17, dressed in our project's layout:
login.php<?php
session_start();
require "config.php";
require "helpers.php";
require "src/Database.php";
if (isset($_SESSION["user_id"])) redirect("index.php"); // already logged in? go in.
$error = "";
if ($_SERVER["REQUEST_METHOD"] === "POST") {
$username = trim($_POST["username"] ?? "");
$password = $_POST["password"] ?? "";
$stmt = Database::connect()->prepare("SELECT * FROM users WHERE username = ?");
$stmt->execute([$username]);
$user = $stmt->fetch();
if ($user && password_verify($password, $user["password_hash"])) {
session_regenerate_id(true);
$_SESSION["user_id"] = $user["id"];
$_SESSION["username"] = $user["username"];
redirect("index.php");
} else {
$error = "Wrong username or password.";
}
}
$pageTitle = "Login";
require "partials/header.php";
?>
<h1>Staff login</h1>
<?php if ($error): ?><div class="errors"><p><?= e($error) ?></p></div><?php endif; ?>
<form method="post" class="card">
<label>Username</label>
<input type="text" name="username">
<label>Password</label>
<input type="password" name="password">
<p><button class="btn" type="submit">Log in</button></p>
</form>
<?php require "partials/footer.php"; ?>
logout.php<?php
session_start();
$_SESSION = [];
session_destroy();
header("Location: login.php");
exit;
auth-check.php<?php
if (!isset($_SESSION["user_id"])) {
header("Location: login.php");
exit;
}
Finally, lock the protected pages. In index.php, create.php,
edit.php and delete.php, add one line right after
session_start();:
session_start();
require "auth-check.php"; // โ the bouncer
require "config.php";
// ...
Open a private browser window and visit index.php โ you're bounced to the login.
Log in as admin / admin123 โ the app opens, the header greets you by name, and
Log out works. ๐
๐ Stand back and look at what you built
A complete, secure, database-driven web application:
- OOP architecture:
Config,Database,Studentclasses; pages free of SQL. - Full CRUD through prepared statements โ SQL-injection-proof.
- Validated, sticky forms; every output escaped โ XSS-proof.
- Hashed passwords, session login, protected pages, flash messages, search.
This is not a toy โ it is the same skeleton inside real student information systems, inventory managers, clinic registers and booking systems. Change the table and the class, and you can build any of them.
Ideas to grow the project (excellent homework!)
- Easy: add an
emailcolumn end-to-end (setup.sql โ Student class โ both forms โ validation withFILTER_VALIDATE_EMAILโ the table). - Medium: pagination โ show 10 students per page using
LIMITand a?page=2GET parameter. - Medium: a "record payment" form that subtracts from a student's fee balance โ with a rule that it can't go below zero (a perfect job for a method with validation!).
- Harder: a
coursestable and amarkstable with foreign keys, plus a page showing a student's marks using JOIN (Chapter 14's preview). - Harder: user roles โ an
is_admincolumn where only admins may delete.
Where to next?
- Composer โ package management and PSR-4 autoloading for real (Chapter 11 prepared you).
- Laravel โ the most popular PHP framework. You will smile constantly: its Eloquent models are our Student class grown up; its request validation is our
validate(); its Blade templates are our partials. You already think in Laravel's language. - Git & GitHub โ version control for your code, essential from your very next project.
- Rebuild this project from a blank folder without peeking. When you can, you haven't memorised OOP โ you own it.
Why does the actual deletion happen via POST and not a plain link?
A user visits edit.php?id=99999 (no such student). What does our app do?
- Edit = find โ pre-fill form โ same validate/save flow; the object's id makes save() UPDATE.
- Never trust URL ids: handle
find()returning null gracefully. - Destructive actions: confirm first, execute only via POST.
- Lock pages with one shared
auth-check.phpaftersession_start(). - Delete one-off tools like make-admin.php immediately after use.
- You have completed PHP OOP: Zero to Hero. Rebuild the project from scratch, extend it, then go meet Laravel. Mwabombeni โ well done! ๐