Relevance Over Recency: Fixing a Laravel Newsroom's Broken Search
Reading time: ~ 5 minutes
A search box that can't find what you know is there
Every content team has felt this. You know a story exists, because you published it, and the site's own search returns nothing. So you go to Google and search the site from the outside, because the outside tool does a better job of finding your words than your own site does.
That was the daily reality for the public newsroom of a global sportswear leader. It is where the company posts its press releases, feature stories, and announcements, and it draws a large, steady audience. Behind the headlines sit years of published, legally approved content. Communications teams search it for past announcements. Employees use it as a reference. Legal reviewers comb it for the exact language the company has used before. For all of them, search is the front door to the archive, and when search cannot find a story, the story may as well not exist.
The application is a mature, live Laravel build on the Twill CMS, serving every language the brand publishes in. We've shipped a steady stream of work on it over the years, and this was one more improvement in that line. We also couldn't take it offline or break its behavior while people were relying on it.
Two things were broken
The first problem was what search looked at. It matched on titles, tags, and topics, and it ignored the words inside the articles themselves. Search for a phrase that appears in the body of a published piece, and you got nothing back.
The second problem was the ordering. Results came back strictly newest first, so relevance decided only whether an article showed up at all, never where it landed. An exact match to an older article's headline could sit on the second page, behind fifteen recent articles that happened to share one common word. A legal reviewer hunting for an approved phrase was left scrolling or giving up. On top of that, an old quality cut-off was quietly discarding some older matches before they could appear at all.
We chose to improve the search the site already had rather than bolt a separate product onto it, which kept the content model intact and avoided a migration nobody needed.
Change one: read the whole article
Twill stores each story as a set of editable content blocks, translated per locale. To make the body searchable, we walked those blocks and pulled every translated string out in a single pass.
/** * Full-text body copy pulled from the model's Twill blocks, grouped by * locale. The blocks relation is flat and already includes repeater * children, so every translated (locale-keyed) string is collected in a * single pass — new block types are indexed without a per-type allow-list. * Only applies to translatable, block-based models. */ protected function searchableBody() { if (!$this->searchableResultTranslatable || !method_exists($this, 'blocks')) { return null; } $locales = config('translatable.locales'); $byLocale = array_fill_keys($locales, []); foreach ($this->blocks()->get() as $block) { foreach ($block->content ?? [] as $value) { if (!is_array($value)) { continue; } foreach ($locales as $locale) { if (!isset($value[$locale]) || !is_string($value[$locale])) { continue; } $text = trim(html_entity_decode(strip_tags($value[$locale]))); if ($text !== '') { $byLocale[$locale][] = $text; } } } } $body = []; foreach ($byLocale as $locale => $parts) { if (!empty($parts)) { $body[$locale] = trim(preg_replace('/\s+/', ' ', implode(' ', $parts))); } } return $body ?: null; }
Two properties make this hold up over time. It grabs every locale in the same pass, so the whole multilingual archive becomes searchable together rather than one language at a time. It also doesn't rely on a fixed list of block types, so when editors introduce a new kind of content block later, its text gets indexed automatically. Search keeps pace as the newsroom grows instead of quietly missing the newest formats.
Then we rebuilt the index, and a search could finally match a specific phrase buried in the middle of a three-year-old article.
Change two: rank on relevance, and treat recency as a dial
This is where the real work was, and where the disagreement lived.
We introduced ranking that weighs a headline match most heavily, a summary match next, and body text least, so the most relevant results rise to the top. Recency still counts, but it stops deciding everything. Here is the recency curve we gave the keyword search.
/** * Recency curve for keyword search. Deliberately shallower than * decayOlderContent(): search multiplies this into the text score, so a * steep curve drives older documents to effectively zero and buries exact * matches on archive content. The offset leaves recent results unpenalised. */ public function decaySearchResults() { return [ 'date' => [ 'origin' => request()->get('now'), 'offset' => '180d', 'scale' => '1825d', 'decay' => 0.5, ], ]; }
In plain terms: origin is now. offset is a grace period, so anything published in the last 180 days pays no recency tax at all. scale and decay work together: once a story is past the grace period, it loses weight gradually until, five years later (1825 days), it carries half its original weight. Older stories keep easing down from there instead of dropping off a cliff.
The key decision is spelled out in the comment. The site already had a steeper decay curve, decayOlderContent(), for ordering content in general, and we could have reused it. But search multiplies this factor into the relevance score, and a steep curve there would have driven older articles toward zero no matter how well they matched, which is exactly the burial we were trying to fix. So search got its own, gentler curve.
That distinction is what lets us handle the hard conversation well. Some stakeholders wanted newest-first to stay the rule. We didn't agree just because it was the easier answer, and we didn't steamroll them either. We made the case that search is a relevance problem first, then handed them a tunable curve set to a sensible starting point. The balance between relevance and freshness became a number that could be adjusted rather than an argument in the abstract or a return to the version that hid the content.
What changed
The archive became usable again. A search for a real term that appears in published articles now returns those articles. An exact headline match now comes back at the top instead of buried behind newer articles. Coverage widened too. With the old quality cut-off removed, older matches that used to be discarded now show up, so more of the archive is reachable.
The lesson
Newest-first feels safe. It is easy to explain, easy to defend, and it looks fair. What it does quietly is answer a question search was never asking. Search is about relevance first. Recency belongs inside that judgment as one input, and it should not be the thing that overrules everything else.
The technical fixes here were not exotic: index the real content, weight the fields, shape a decay curve. The part that took judgment was refusing the easy default and being willing to explain why. Give people a dial instead of a fight, set it somewhere reasonable, and let them tune from there. That is usually how a mature application earns a second life, one honest improvement at a time.
This search work was one piece of a longer relationship. We’ve also helped this client modernize the system that keeps their product archive in sync.
If your Laravel app has a search box your own team has stopped trusting, or a feature that works but no longer does what people need, that’s the kind of work we do. See how we help Laravel teams, or tell us what’s going on.