In the space of a month I was asked three questions that looked completely unrelated:
Can search work on a site translated with TranslatePress, where a visitor types in one language and reads in another?
Can search hide course content the visitor hasn't bought?
Why does search behave differently inside a premium theme's AJAX overlay?
Different plugins, different problem domains, different people asking. All three turned out to be the same question, and the answer is an architectural decision you make once, near the start, and then live with.
The choice
If you write a plugin that replaces WordPress search, you pick one of two shapes.
Shape one: own the pipeline. You build your own index, you match against it, you produce the result list, and you render it. You control everything from query string to markup.
Shape two: answer with post IDs. You do your matching however you like, then hand WordPress a list of IDs in the order you want them, and let the rest of WordPress carry on as normal.
Shape one is tempting because it is the only way to guarantee the output looks exactly how you intended. It is also the reason most search plugins fight with everything else on the site.
Why the first shape breaks on multilingual sites
TranslatePress stores translations in its own tables and applies them at render time. The post itself stays in the original language.
Now put a search plugin next to it that maintains its own index. That index is built from the original content, because that is what is in the database. A visitor browsing in Finnish types a Finnish word. The plugin looks in its own index, finds nothing that matches, and returns nothing. The translation layer never gets a chance to help, because the plugin never handed anything to WordPress in the first place.
With the second shape, the flow is different:
content stored in language A
↓
plugin matches, returns post IDs
↓
WordPress builds the loop
↓
translation layer renders in language C
The plugin does not need to know that translations exist. It never sees them, never indexes them, never has an opinion about them. It answers with IDs and gets out of the way.
That is not a feature anyone built. It is what you get for free by not owning the last mile.
Why it matters for access control
The second question came from a site running membership software, where lessons belong to courses and visitors have bought some courses and not others. The request was for search results to exclude content the visitor cannot open.
If your plugin renders its own results, you now have to understand somebody else's permission model. You have to know how that plugin stores entitlements, keep up as it changes, and repeat the exercise for every membership plugin your users install.
If your plugin returns IDs, you add one filter:
$ids = apply_filters( 'my_plugin_result_ids', $ids, $query );
and the site decides. The membership plugin already knows who can see what. It does not need you to reimplement it, and you do not need to ship an integration per vendor.
The developer who asked me this wrote that filter himself and sent it to me. It is in the plugin now. That is only possible because IDs are the interface.
The failure mode nobody thinks about
Here is the part I would most like you to take away, because it bit me and it is not obvious.
To replace search, you generally do two things: you inject your results, and you remove WordPress's own LIKE clause so its keyword matching does not fight your ordering.
add_filter( 'posts_search', function ( $search, $query ) {
if ( $query->is_search() ) {
return ''; // strip the native LIKE clause
}
return $search;
}, 10, 2 );
Perfectly reasonable, and quietly dangerous.
Consider what happens when your matching fails. Your API times out, or the site runs out of quota, or a network path breaks. You return no IDs. But you have already stripped the native search clause, so WordPress has nothing to fall back on either.
Search does not degrade. It goes blank. The site owner finds out from a customer.
The fix is to make the removal conditional on having actually succeeded:
add_filter( 'posts_search', function ( $search, $query ) {
if ( $query->is_search() && $query->get( 'my_plugin_used' ) ) {
return '';
}
return $search; // untouched when we did not produce results
}, 10, 2 );
Set that flag only after you have results in hand. Now failure means the visitor gets ordinary WordPress keyword search, which is worse than what you offer but very much better than nothing. The shop keeps selling. What is lost is relevance, not availability.
This is worth more attention than it usually gets. Anything that talks to a network will fail sometimes, and the state you leave the site in when it does is part of your plugin's design, not an accident.
Ordering, since it is the usual stumbling block
Once you hand over IDs, WordPress will happily reorder them for you unless you say otherwise:
$query->set( 'post__in', $ids );
$query->set( 'orderby', 'post__in' );
$query->set( 'post_type', 'any' );
orderby => post__in is the piece people miss. Without it your carefully ranked list comes back sorted by date, and you spend an afternoon convinced your ranking is broken.
Worth knowing too: a theme can undo this downstream. If a template runs its own query, or a page builder widget has its own Query settings with a fixed Order By, that widget wins. The plugin is fine and the ranking is fine, and the page still shows the wrong order. If a user reports scrambled results, check the template before you check yourself. That was the third question, and the answer turned out to be a theme's own AJAX search intercepting the request before WordPress ever saw it.
Where I have not finished applying my own argument
Being honest about the limit of the pattern as I have implemented it.
I hook the main query. That covers the standard search results page, which is where the overwhelming majority of searches happen. It does not cover a secondary WP_Query in a template, and three people have now asked for exactly that, wanting to build a custom results layout that still uses my ranking.
The right answer is obvious in hindsight and follows directly from everything above: if IDs are the interface, they should be reachable without going through the main query at all.
$ids = my_plugin_search_ids( $term, [ 'limit' => 20 ] );
Then a developer can do whatever they like with them, and I do not need to know what.
It is not built yet. It is a good illustration of the general point though. The value of returning IDs is not the specific hook you use to deliver them. It is that the interface is small enough that other people can build things you did not think of, without asking your permission or waiting for your next release.
Top comments (0)