<?php OOP: Zero to Hero Chapter 20 of 20

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:

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!)

Where to next?

Quick quiz

Why does the actual deletion happen via POST and not a plain link?

Quick quiz

A user visits edit.php?id=99999 (no such student). What does our app do?

๐Ÿ”‘ Key points โ€” and congratulations!
  • 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.php after session_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! ๐ŸŽ“