D
P
0
← All articles Baca dalam Bahasa Indonesia

WordPress & PHP in Production

The Category Filter That Never Filtered: I Mapped `?category=` to `s=`, and `s=` Is an AND-Search

· · 9 min read
The Category Filter That Never Filtered: I Mapped `?category=` to `s=`, and `s=` Is an AND-Search

This bug was nobody's inheritance. I wrote it myself, deliberately, as the fix for another bug that felt more urgent at the time on the holiday rental booking platform I was working on.

Here was the original problem. The branch that actually queried the database only fired when one of location, check-in date, or guest count was set. So a bare visit to the search page, and the category links coming from the homepage, bypassed that branch entirely and dumped visitors onto 18 demo cards that all linked to the same bare preview page.

The fix I shipped that day ran the query unconditionally, mapped the category slug to a set of keyword terms handed to WP_Query as s=, and made the demo-card branch cycle real listing IDs via index % count so that even the fallback opened distinct previews.

// the category slug is translated into keywords, then used as a text search
$args['s'] = app_search_category_terms($category);

I logged what was wrong with it the same day

In the release audit notes for that same day, I wrote a numbered finding, MEDIUM #14b: the ?category= mapping uses WP_Query's s= keyword, which is an AND-search across words, so the category does not actually narrow the results down to that category's listings and still falls through to the demo branch. Because the demo branch now cycled real IDs, the symptom was not an empty page. The cards still opened, the titles just did not match the listings that were supposed to be there.

In the same note I wrote down two possible real fixes, a meta_query on the city or a dedicated taxonomy, without deciding between them. Then I punted it to the next patch release. It never landed in that release.

Why s= cannot serve as a filter

s= is not a filtering mechanism. It is a free-text search across post title, content, and excerpt, so the only thing being matched is prose a human happened to type, not structured data.

The more deceptive part is its multi-word behaviour. s= with more than one word behaves as AND, not OR. WordPress splits the search string into words and requires all of them to be present in the same post. That means the more descriptive the keyword mapping, the fewer rows survive. Adding words with the intent of widening the result actually narrows it until nothing is left, which runs against most people's intuition about search boxes.

So why did it ever look like it worked? The later trace named it plainly: the categories were a keyword hack that happened to match the NAMES of my own seed listings, so real owners' listings never landed in any rail and a genuine category filter never worked. The slugs of those six seed listings were all place names, and that vocabulary is what accidentally met the keyword mapping. Those six listings did exist in production, filled with demo content I had prepared for testing, and the person who actually ran the seed on production turned out to be the client, at a point nobody could pin down exactly.

What revived the issue was the client, not me

Twenty-six days after that #14b note, the real fix finally shipped. The trigger was not me reopening my own deferred list. The client flagged it: I had changed a lot and it might not all be wired, and I was asked to go back and check what was not.

The homepage category rails were only one example the client happened to notice, and they were sure there were more that had not occurred to me to wire. They framed the point as a broken chain: plenty of pieces had been repointed to real data, but the chain from owner input, to saved, to displayed or queried, was not connected end to end.

Going into that batch I was not even sure myself whether the initial render of the search page actually handled the category parameter, and I marked it as something to verify first rather than something I already knew.

A second bug with exactly the same shape

The trace found the location filter in the same state. Its taxonomy existed, but it was never set by the property submission form, because that form only had text inputs for city, region, and country, with no location term field at all. The location parameter never reached REST, so wp_set_object_terms never ran.

Three consequences were recorded. The search page's location filter queries that taxonomy, so it would never match an owner's listing. The location display on the detail page falls back to the address because the term is empty. And location matching on the homepage relies on keyword text alone.

Two bugs, one shape: the read side was built, the write side never was. The same trace also found area, noise, property type, city, region, and country saved but never shown, plus a things-to-know section that hardcoded fake per-listing safety alarms and a fixed cancellation policy.

Not everything a sweep flags is real. The cleaning fee made the suspect list for a while, when it was already applied in pricing, in bookings, and in the invoice. One price-check route was reported missing when the file existed in the theme, so that was a false positive. Automated sweeps are good for building a checklist, not for drawing conclusions.

The client decided the shape of the fix

When #14b got deferred, I had not chosen between a meta_query on the city and a dedicated taxonomy. The client was the one who settled the direction: keep the editorial categories, but let the listing owner pick them.

The client decided four other shapes in that same session. Categories can be more than one, through checkboxes. Location becomes a region dropdown. Safety features are set by the owner per listing. The cancellation policy is set by the platform but editable by an admin.

Build the data first, then the query

As long as the category is not data, no query can rescue it.

Step one, an actual flat taxonomy with five defined terms and rewrite turned off, registered alongside the three taxonomies the plugin already had.

register_taxonomy('listing_category', 'listing', [
    'hierarchical' => false,
    'rewrite'      => false,
]);

Step two, the terms have to genuinely exist in production. I gated the seeding to a single run behind an option, so production seeds without reactivating the plugin.

if (!get_option('listing_category_seeded')) {
    foreach (app_category_choices() as $slug => $label) {
        if (!term_exists($slug, 'listing_category')) {
            wp_insert_term($label, 'listing_category', ['slug' => $slug]);
        }
    }
    update_option('listing_category_seeded', 1);
}

That gate is not tidiness, it is a requirement. I have no FTP and no SSH to production, and releases get there as files uploaded by the client. Uploading files does not re-run the activation hook, so seeding hung off it would simply never run. In the same environment, bumping the version constant only invalidates asset URLs through ?ver=, it does not purge the page cache, so any deploy that touches rendered copy still needs a manual purge from the client.

Step three, the write side. The property submission form got category checkboxes, and the REST endpoint accepts a categories[] parameter validated against the default list before anything touches the database.

$allowed    = array_keys(app_category_choices());
$categories = array_values(array_intersect((array) $request['categories'], $allowed));
 
wp_set_object_terms($post_id, $categories, 'listing_category', false);

There is one easily missed JavaScript detail. I generalised the collection of array-typed fields across amenities, categories, and safety features at once, and empty arrays are still sent, so an edit can clear a selection that was already saved. Submission is also blocked client side when zero categories are selected, with a toast saying a category is required rather than silence.

The region field, previously free text, was replaced with a required <select name="region" required> of region terms, which in the taxonomy are children of the country term.

Step four, the read side. The function that assembles property data now returns the categories along with their slugs, so the edit form can re-check what was saved. The query itself lives in one helper that validates the slug before it builds a tax_query.

function app_apply_category_filter(array $args, string $category): array {
    $choices = app_category_choices();
 
    if (!isset($choices[$category])) {
        return $args; // unknown slug, no tax_query is added
    }
 
    $args['tax_query'] = [[
        'taxonomy' => 'listing_category',
        'field'    => 'slug',
        'terms'    => $category,
    ]];
 
    return $args;
}

Look at the branch for unknown slugs in the code above. Returning the args untouched means the page renders with no category filter at all, not an empty page. If an empty result is what you want, the branch has to say so.

The homepage rails, the search results page, and the load-more endpoint all call this helper, so those three surfaces can no longer drift apart. The old keyword function was deleted rather than left sitting idle. One rail did not move over, the popular one, because it genuinely means most recent listings rather than a category.

Verification with data I did not create

I verified it live by clicking through as an admin. The taxonomy registered and its five terms auto-seeded. The property submission form rendered the category checkboxes, a region dropdown with five options, and the safety checkboxes, and a real submit from that form saved and pre-filled again on re-edit.

I gave one test listing two categories and a region. The search page with the first category slug returned it alone, another category gave an empty state, the rails for those two categories contained it, and the remaining three rails were hidden. Safety features on the detail page showed only what the owner had ticked, and the fabricated carbon monoxide claim that used to be baked in was gone. The booking widget and the Reserve button were intact, and the gallery had real photos with no demo leaking in. That test listing itself is still pending cleanup.

The six old seed listings carry no category terms at all, so the editorial rails stay empty until a listing gets categorised. That is the correct and honest state.

Takeaways