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

Chapter 19 ยท Final Project

Project Part 2: Classes & CRUD

The foundation stands. Now we build the heart of the application: the Student class that handles all database work, the list page with search, and the add-student form. By the end of this part, the app is already genuinely useful.

The Student class โ€” one class to rule the table

Design decision: everything the app does with the students table goes through this one class. Pages never write SQL themselves โ€” they politely ask the Student class. That separation (pages = presentation, classes = logic and data) is the single biggest habit that separates professionals from beginners.

src/Student.php<?php
class Student {
    public function __construct(
        public ?int $id,
        public string $studentNumber,
        public string $fullName,
        public string $program,
        public float $feeBalance
    ) {}

    /* ---------- turning database rows into objects ---------- */

    private static function fromRow(array $row): Student {
        return new Student(
            (int)$row["id"],
            $row["student_number"],
            $row["full_name"],
            $row["program"],
            (float)$row["fee_balance"]
        );
    }

    /* ---------- READ ---------- */

    /** @return Student[] All students, optionally filtered by a search term. */
    public static function all(string $search = ""): array {
        $pdo = Database::connect();

        if ($search !== "") {
            $stmt = $pdo->prepare(
                "SELECT * FROM students
                 WHERE full_name LIKE ? OR student_number LIKE ?
                 ORDER BY full_name"
            );
            $stmt->execute(["%$search%", "%$search%"]);
        } else {
            $stmt = $pdo->query("SELECT * FROM students ORDER BY full_name");
        }

        $students = [];
        foreach ($stmt->fetchAll() as $row) {
            $students[] = self::fromRow($row);
        }
        return $students;
    }

    /** Find one student by id โ€” or null if they don't exist. */
    public static function find(int $id): ?Student {
        $stmt = Database::connect()->prepare("SELECT * FROM students WHERE id = ?");
        $stmt->execute([$id]);
        $row = $stmt->fetch();
        return $row ? self::fromRow($row) : null;
    }

    /* ---------- VALIDATE ---------- */

    /** @return string[] A list of problems โ€” empty means all good. */
    public function validate(): array {
        $errors = [];

        if (trim($this->studentNumber) === "") {
            $errors[] = "Student number is required.";
        }
        if (strlen(trim($this->fullName)) < 3) {
            $errors[] = "Full name must be at least 3 characters.";
        }
        if (!in_array($this->program, Config::PROGRAMS)) {
            $errors[] = "Please choose a valid programme.";
        }
        if ($this->feeBalance < 0) {
            $errors[] = "Fee balance cannot be negative.";
        }
        return $errors;
    }

    /* ---------- CREATE / UPDATE ---------- */

    /** Insert if new (no id yet), update if existing. */
    public function save(): void {
        $pdo = Database::connect();

        if ($this->id === null) {
            $stmt = $pdo->prepare(
                "INSERT INTO students (student_number, full_name, program, fee_balance)
                 VALUES (?, ?, ?, ?)"
            );
            $stmt->execute([
                $this->studentNumber, $this->fullName,
                $this->program, $this->feeBalance
            ]);
            $this->id = (int)$pdo->lastInsertId();
        } else {
            $stmt = $pdo->prepare(
                "UPDATE students
                 SET student_number = ?, full_name = ?, program = ?, fee_balance = ?
                 WHERE id = ?"
            );
            $stmt->execute([
                $this->studentNumber, $this->fullName,
                $this->program, $this->feeBalance, $this->id
            ]);
        }
    }

    /* ---------- DELETE ---------- */

    public static function delete(int $id): void {
        $stmt = Database::connect()->prepare("DELETE FROM students WHERE id = ?");
        $stmt->execute([$id]);
    }
}

Pause and admire what your course knowledge built:

The list page with search

index.php<?php
session_start();
require "config.php";
require "helpers.php";
require "src/Database.php";
require "src/Student.php";

$search = trim($_GET["search"] ?? "");     // GET: a search is a question, not a change
$students = Student::all($search);
$pageTitle = "Students";

require "partials/header.php";
?>

<h1>Students (<?= count($students) ?>)</h1>

<form method="get" class="search">
    <input type="text" name="search" placeholder="Search by name or student number..."
           value="<?= e($search) ?>">
    <button class="btn" type="submit">Search</button>
</form>

<?php if (count($students) === 0): ?>
    <p>No students found<?= $search ? " for '" . e($search) . "'" : "" ?>.
       <a href="create.php">Add the first one</a>.</p>
<?php else: ?>
    <table>
        <tr>
            <th>Student โ„–</th><th>Full name</th><th>Programme</th>
            <th>Fee balance</th><th>Actions</th>
        </tr>
        <?php foreach ($students as $s): ?>
        <tr>
            <td><?= e($s->studentNumber) ?></td>
            <td><?= e($s->fullName) ?></td>
            <td><?= e($s->program) ?></td>
            <td>K<?= number_format($s->feeBalance, 2) ?></td>
            <td>
                <a href="edit.php?id=<?= $s->id ?>">Edit</a> ยท
                <a href="delete.php?id=<?= $s->id ?>">Delete</a>
            </td>
        </tr>
        <?php endforeach; ?>
    </table>
<?php endif; ?>

<?php require "partials/footer.php"; ?>

Look how clean the page is: no SQL, no PDO โ€” it just asks Student::all() and loops over beautiful objects. The "list of objects โ†’ foreach โ†’ HTML table" pattern from Chapter 04, now in its natural habitat.

The add-student form

The full sticky-form pattern from Chapter 16, powered by our class:

create.php<?php
session_start();
require "config.php";
require "helpers.php";
require "src/Database.php";
require "src/Student.php";

// Start with an empty, unsaved student (id = null)
$student = new Student(null, "", "", "", 0);
$errors = [];

if ($_SERVER["REQUEST_METHOD"] === "POST") {
    // Refill the object from the form
    $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();
            flash_set("success", "Student {$student->fullName} added successfully.");
            redirect("index.php");
        } catch (PDOException $ex) {
            // Code 23000 = a UNIQUE rule was broken โ€” duplicate student number
            $errors[] = $ex->getCode() == 23000
                ? "That student number already exists."
                : "Database error โ€” please try again.";
        }
    }
}

$pageTitle = "Add student";
require "partials/header.php";
?>

<h1>Add a student</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">
        <option value="">โ€” choose โ€”</option>
        <?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 student</button></p>
</form>

<?php require "partials/footer.php"; ?>

Follow the happy path: submit โ†’ fill object โ†’ validate() returns [] โ†’ save() INSERTs โ†’ flash success โ†’ redirect โ†’ list page shows the green banner and the new row. Follow the sad path: errors โ†’ the same form reappears, sticky, with red messages. And the try/catch turns a cryptic duplicate-key crash into the friendly "That student number already exists."

Test drive

  1. Visit http://localhost/student-manager/index.php โ€” three students from setup.sql appear.
  2. Search "chanda" โ€” one row remains.
  3. Add a valid student โ€” green banner, new row in the list.
  4. Try an empty name โ€” red errors, your other typing preserved.
  5. Add the same student number twice โ€” friendly duplicate message. ๐Ÿ’ช
โœ๏ธ Try it yourself

Add a footer line to the list page showing the total fee balance of the displayed students. Solve it in the page with a small loop over the objects.

Show solution
<?php
$total = 0;
foreach ($students as $s) {
    $total += $s->feeBalance;
}
?>
<p><strong>Total outstanding: K<?= number_format($total, 2) ?></strong></p>
Quick quiz

In our design, where does SQL live?

Quick quiz

How does save() decide between INSERT and UPDATE?

๐Ÿ”‘ Key points
  • All table access flows through one class: Student::all(), find(), validate(), save(), delete().
  • Static methods start without an object; instance methods act on one student.
  • ?int $id = null encodes "not saved yet" โ€” letting save() choose INSERT vs UPDATE.
  • Pages stay clean: gather input โ†’ fill object โ†’ validate โ†’ save โ†’ flash โ†’ redirect.
  • Catch PDOException and translate technical failures (like duplicates, code 23000) into human messages.
  • Search uses GET + a prepared LIKE query โ€” bookmarkable and injection-proof.