The Catalogue List & Detail Pages
Personal Catalogue: PHP & MySQL
Chapter 7 · The Catalogue List & Detail Pages
Chapters 3-6 built every write and search operation the catalogue needs, but there's
still no ordinary browsing page — the one a real visit to the site opens on. This
chapter builds index.php, a full list of every item with its type and tags
visible at a glance, and view_item.php, a single-item detail page.
The Naive Approach, and Why It's a Real Problem
The obvious first attempt at listing every item's own tags alongside it is to fetch all
items, then loop over them and run get_tags_for_item() (from Chapter 5)
once per row:
The Fix: One Query, GROUP_CONCAT, and a LEFT JOIN
MySQL's GROUP_CONCAT() aggregate function collapses several rows' worth of
a column into one comma-separated string per group — exactly what's needed to fetch
every item's own tags in the same query as the item itself, using GROUP BY
to group the joined tag rows back down to one row per item:
JOIN (an inner join) only keeps rows that have a match on both
sides — an item with zero tags (every CD and DVD, and any book that hasn't been tagged
yet) has zero matching rows in item_tags, so a plain JOIN would
silently drop it from the results entirely. LEFT JOIN keeps every row from
items regardless of whether a match exists on the right-hand side, filling
in NULL for the tag columns when there's no match — exactly the same
NULL-for-"no value" pattern Chapter 2's own schema already relies on. item_types
still uses a plain JOIN here, deliberately, since every item is guaranteed
(by the NOT NULL foreign key from Chapter 2) to have exactly one real type.
Rendering the List
With $items already carrying type_name and tag_names
directly, the list page itself is a simple loop — no further queries needed per row:
The delete form matches Chapter 3's own POST-only requirement exactly, wrapped in an
inline-styled <form> so it sits naturally inside a table cell next to
the edit link.
A "Recently Added" Section, Using Chapter 2's Own created_at Column
Chapter 2 called out created_at's own automatic default as "free" —
something to use later. A small recently-added widget on the same page is exactly that
payoff:
No WHERE clause, no parameters — created_at was populated
automatically by MySQL's own DEFAULT CURRENT_TIMESTAMP for every row added
since Chapter 3, so ordering by it and limiting to 5 costs nothing extra in application
code.
The Detail Page
view_item.php reuses the same GROUP_CONCAT query shape, just
narrowed to one row with a WHERE items.id = :id clause:
SELECT
list to also appear in GROUP BY. MySQL relaxes this specifically when the
GROUP BY column is a table's own primary key (items.id
here) — since a primary key uniquely determines every other column in that same row,
grouping by it alone is functionally identical to grouping by every column, and MySQL
is smart enough to allow the shorter form. This is real, documented MySQL-specific
behavior (called "functional dependency") — it isn't guaranteed to work the same way on
every SQL database, worth remembering if this project's own database is ever swapped later,
echoing the same portability caveat Chapter 6 already raised for reused named placeholders.
Hands-On Exercises
Build index.php using the GROUP_CONCAT query from this chapter against your real database (with at least one tagged book, one untagged book, and one CD or DVD already in it from earlier chapters). Confirm the untagged items show a sensible fallback (e.g. "—") in the Tags column rather than a blank cell or a PHP warning.
Temporarily change the list query's own LEFT JOIN item_tags to a plain JOIN item_tags, reload index.php, and describe exactly what changes in the result set. Then change it back and confirm the original behavior returns.
Build view_item.php, then visit it with a real item's own id, and separately with an id you know doesn't exist (e.g. 999999). Confirm the second case shows "Item not found." rather than a blank page or a PHP error, and explain why $stmt->fetch() returning false is what makes the if (!$item) check work.
Chapter 7 Quick Reference
- N+1 query problem — fetching items, then looping to fetch each one's own tags separately, means 1+N real queries for N items
GROUP_CONCAT()— collapses several joined tag rows into one comma-separated string per item, fixing N+1 in a single queryLEFT JOIN item_tags/tags— keeps items with zero tags in the results; a plainJOINwould silently drop themGROUP BY items.idalone — valid in MySQL specifically becauseidis the primary key (functional dependency)- Recently added widget — the real payoff of Chapter 2's own "free"
created_at DEFAULT CURRENT_TIMESTAMPcolumn - Next chapter: Fast Manual Entry — reducing friction given the project's own real deadline