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:
- Promoted constructor (Ch. 05) with
?int $idโ null means "not saved yet". - Static methods (Ch. 09) for actions that start without an object
(
all(),find(),delete()); instance methods (validate(),save()) for actions on a particular student. fromRow()is private (Ch. 06) โ an internal kitchen detail no page needs.- Every query is a prepared statement (Ch. 15). The class is injection-proof.
- One clever
save()that inserts or updates by itself โ the page won't even need to know the difference.
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
- Visit
http://localhost/student-manager/index.phpโ three students from setup.sql appear. - Search "chanda" โ one row remains.
- Add a valid student โ green banner, new row in the list.
- Try an empty name โ red errors, your other typing preserved.
- Add the same student number twice โ friendly duplicate message. ๐ช
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>
In our design, where does SQL live?
How does save() decide between INSERT and UPDATE?
- 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 = nullencodes "not saved yet" โ lettingsave()choose INSERT vs UPDATE.- Pages stay clean: gather input โ fill object โ validate โ save โ flash โ redirect.
- Catch
PDOExceptionand translate technical failures (like duplicates, code 23000) into human messages. - Search uses GET + a prepared LIKE query โ bookmarkable and injection-proof.