Search
Personal Catalogue: PHP & MySQL
Chapter 6 · Search
Chapter 1 named the real question this whole project exists to answer fast:
"do I already own this?" Every design decision since then — the shared
items table in particular — was made specifically to keep that question
a single, simple query. This chapter builds the actual search page.
The Simplest Version First
A basic title search, matching anywhere inside the title, not just at the start:
The % wildcards are built into the PHP string, not the SQL — the
placeholder is still bound as one plain parameter value, exactly the same
PDO::ATTR_EMULATE_PREPARES => false protection from Chapter 1 applies to
it. This is a genuinely important distinction: it would be a real SQL injection risk to
build the wildcards by concatenating raw SQL text instead — "title LIKE '%" .
$searchTerm . "%'" — since that puts user input directly into the query string.
Searching Creator Too, Not Just Title
A search for "Tolkien" should find "The Hobbit" even though the word "Tolkien" never
appears in its title — it's in creator. A real search has to check both
columns:
:query twice in the SQL above and passing one 'query'
key in the execute() array actually works correctly with PDO's MySQL
driver — it substitutes the same bound value into both positions. This is real,
documented PDO/MySQL behavior, but it's not universal across every database driver PDO
supports, so it's worth knowing it's a MySQL-specific convenience rather than a general
PDO guarantee, in case this project's own database is ever swapped later.
Optional Filter: Item Type
Adding a "Type" dropdown to the search form (All / Book / CD / DVD / Blu-ray) means the
query's own WHERE clause has to change shape depending on whether a type
was actually chosen. Building this correctly means never string-concatenating raw
$_GET values into the SQL — the clause structure changes, but every value
still flows through a bound parameter:
The SQL string is assembled conditionally, but every actual piece of user-supplied data
— the search term, the type id — still only ever reaches the query as a bound parameter.
Building the WHERE clause's own shape in PHP is safe; building a value's own
text directly into the SQL string is not, and the difference between those two things is
the whole point of this pattern.
Optional Filter: Tag (Reusing Chapter 5's Own Query)
Adding a tag filter means joining item_tags and tags only when
a tag was actually selected — otherwise every non-book item would be silently excluded
by an unconditional join, since a DVD has no rows in item_tags at all:
item_tags.tag_id = :tag_id in the JOIN ... ON clause
(rather than a separate WHERE item_tags.tag_id = :tag_id) means the join
itself only matches the one specific tag's own pairing row — a plain inner join would
still work either way for this particular query, since either placement excludes
non-matching rows entirely, but keeping the tag condition attached to the join it
actually belongs to keeps the query's own logic easy to read as it grows: "join in this
tag's own pairings, then filter the resulting rows by title/creator/type."
Escaping Literal % and _ in a Search Term
LIKE's own % (any characters) and _ (any single
character) are special wildcard characters — if a real title happens to contain one
literally (a book called "50% Off", or a format note with an underscore in it),
searching for that exact text needs those characters escaped, or MySQL will interpret
them as wildcards instead of literal characters:
The backslash itself has to be escaped first, before % and _
— otherwise a search term that already contains a literal backslash would end up
double-escaped incorrectly. The order of the str_replace() array matters
here for exactly that reason.
% or _.
It's included here because it's a real, documented LIKE behavior worth
knowing about, not because this project's own real data is expected to trigger it often.
Assembling the Search Page
The full search.php combines every piece above: a form with a text input,
a type dropdown (reusing get_item_types() from Chapter 1), and a tag
dropdown (reusing get_all_tags() from Chapter 5), all submitting via
GET — deliberately GET, not POST, since a search is read-only and a GET
request means the results page has its own shareable, bookmarkable URL:
Hands-On Exercises
Build the full search.php from this chapter against your real database. Search for "Tolkien" with the type dropdown left on "All types" and confirm "The Hobbit" appears in the results even though "Tolkien" never appears in its own title.
Search with an empty search term but the type dropdown set to "cd." Explain, using the SQL your own PHP would build for this case, why every CD in the catalogue is returned even though the search box was left blank.
📄 View solutionAdd the escape_like() function from this chapter, add a book to your database with a title containing a literal underscore (e.g. "Learn_Python: A Guide"), and confirm that searching for the exact substring "Learn_Python" matches only that book — not accidentally matching every title where any single character happens to sit where the underscore is.
Chapter 6 Quick Reference
- Wildcards built in PHP, bound as one value —
'%' . $searchTerm . '%'passed as a single parameter, never concatenated into the raw SQL string - Title OR creator — a real search checks both, since a creator's own name never appears in the title column
- Conditional
WHERE/JOINclauses — built up in PHP based on which filters are active, with every actual value still flowing through a bound parameter escape_like()— escapes literal%/_/\characters in a search term soLIKEdoesn't misinterpret them as wildcards- Search form uses GET — deliberately, so results have their own shareable, bookmarkable URL, unlike the POST-only delete action from Chapter 3
- Next chapter: The Catalogue List & Detail Pages