The Editing System — Admin CRUD & Edit Mode
index.php in an editor. By the end you'll have an in-browser editing system — an "edit mode" that lets the logged-in owner add, edit, and delete content right on the page, safely gated behind the login you just built.require_admin() check and CSRF tokens protect every destructive action. This needs both the database (ch4) and login (ch8).You already know CRUD — let's complete it
Quick confidence boost: you've already built three of the four CRUD letters. Chapter 5's tasks did Create (add), Read (list), and Delete. The only newcomer is Update, and it's the same POST-Redirect-GET shape with an UPDATE statement. So this chapter isn't new mechanics — it's applying the mechanics you have to all the editable content (playlist, tasks, activity), and wrapping the whole thing in proper access control.
The reason editing comes last isn't difficulty for its own sake — it's dependencies. You can't safely let someone edit content until you know who they are (that's why it needs login) and you have somewhere to save edits (that's why it needs the database). Build the gate before you build the door it guards.
Edit mode — a toggle, not a separate page
Rather than build a whole separate admin area, we add an "edit mode" to the existing pages. A flag in the URL or session flips editable items from display mode into little inline forms. It's the difference between Wikipedia's "read" view and its "edit" view — same page, more controls when you're allowed.
// near the top of index.php, after $me = require_login();
$canEdit = ($me['role'] === 'admin');
$editMode = $canEdit && (($_GET['edit'] ?? '') === '1');
Then in the markup, an editable item checks $editMode: off, it renders normally; on, it renders an edit form. A single "✎ edit" link in the corner toggles ?edit=1. Only admins ever see it, because $canEdit gates the link itself — and even if someone forges ?edit=1, the server-side $canEdit check means the actual edit/delete actions still reject them.
The admin gate — defense in depth
Hiding the edit button is UX, not security. The real protection is a server-side check on every mutating action. Add a guard alongside require_login():
function require_admin(array $me): void {
if (($me['role'] ?? 'user') !== 'admin') {
http_response_code(403);
exit('Forbidden');
}
}
Call require_admin($me) at the start of every edit/delete handler. This is defense in depth: the button is hidden (layer 1), the edit forms only render in edit mode (layer 2), and the action itself refuses non-admins (layer 3). An attacker who crafts a raw POST still hits a brick wall at layer 3. Never rely on a hidden button to protect anything — hide it and check on the server.
<a href="delete.php?id=5"> can be triggered by a prefetcher, an image tag, or a crawler, silently deleting data. Deletes go through POST forms. (2) Use a CSRF token — without it, a malicious page can submit your edit forms using your logged-in cookies. Generate a token into the session, embed it as a hidden field, and verify it on every POST.
An inline edit form, end to end
Here's a playlist track in edit mode — it becomes a tiny form that posts an update:
<?php if ($editMode): ?>
<form method="post" action="<?= e(tabUrl('music')) ?>" class="edit-row">
<input type="hidden" name="action" value="edit_track">
<input type="hidden" name="csrf" value="<?= e($_SESSION['csrf']) ?>">
<input type="hidden" name="id" value="<?= (int)$track['id'] ?>">
<input name="title" value="<?= e($track['title']) ?>">
<input name="artist" value="<?= e($track['artist']) ?>">
<button name="save">save</button>
<button name="action" value="delete_track"
onclick="return confirm('Delete this track?')">✕</button>
</form>
<?php else: ?>
<!-- normal display row -->
<?php endif; ?>
And the handler, with all three protections in place:
if (($_POST['action'] ?? '') === 'edit_track') {
require_admin($me); // layer 3
if (!hash_equals($_SESSION['csrf'] ?? '', $_POST['csrf'] ?? '')) {
http_response_code(400); exit('Bad token'); // CSRF check
}
$stmt = db()->prepare(
"UPDATE tracks SET title = ?, artist = ? WHERE id = ?"
);
$stmt->execute([
trim($_POST['title'] ?? ''),
trim($_POST['artist'] ?? ''),
(int) ($_POST['id'] ?? 0),
]);
header('Location: ' . tabUrl('music') . '&edit=1');
exit;
}
hash_equals() (not ==) compares the CSRF token in constant time so you don't leak it through timing. Every value is validated and bound. The redirect keeps you in edit mode so you can keep editing. That's the full, safe loop.
require_admin isn't being called in the handler — the hidden button alone is never enough.
▣ Mini Project: Edit Mode for the Whole Dashboard
The grand finale of the build. By the end, you'll run your entire dashboard — playlist, tasks, content — without ever opening a code editor. That's the moment it stops being "a project you're coding" and becomes "an app you're using." Genuinely a great feeling; savor it.
- Add a CSRF token helper: generate
$_SESSION['csrf']once, expose it to forms, verify withhash_equalson every POST. - Add
require_admin()and the$canEdit/$editModeflags. - Add an "✎ edit" toggle (admin-only) that flips
?edit=1. - Build inline edit + delete for the playlist tracks and the tasks.
- Test as admin (full control), as a normal user (no edit link), and as an attacker (forged POST → 403).
Stretch goals:
- Build an in-browser editor for a "notes" or "now/next" widget so you can jot things without code.
- Add an audit log: every edit/delete writes who-did-what-when into the
activitytable. - Give the orphaned
banner.phpType Inspector a real home — add an admin-only "tools" link to it (finally wiring up the tool you saved back in chapter 1).
What you flexed: the full CRUD set, an edit-mode toggle, defense in depth (hide + gate + server-check), CSRF protection with hash_equals, POST-only destructive actions, and confirmation prompts. You can now run your dashboard entirely from the browser — the editing system is done.