loupe/matcher
Loupe Matcher turns search queries and arbitrary text into precise matches: tokenize phrases and negations, normalize locale-specific spelling variants, decompose compound words, calculate match spans, and format results with highlighting, cropping, and truncation.
Architecture fit
loupe/matcher is a pure-PHP, framework-agnostic presentation library: Tokenizer → Matcher → Formatter, with an immutable FormatterOptions value object. That is a clean fit for Laravel's service layer — no global state, no I/O, deterministic output, trivially unit-testable and queue-safe. It belongs in the application/presentation layer (search UX), not the domain layer.
0.4.x does not change that picture. 0.4.0 replaced the internal decompounder cache with a bounded, complete-token LRU memoization cache; 0.4.1 speeds up match highlighting on long texts. Both are internal-architecture work, not API work: no new public classes, no signature changes that we can see. Instance-scoped memoization is not shared/global state, so output remains deterministic for a given input and options, and queue-safety and testability are preserved. Two things to verify in the spike rather than assume: that the cache is genuinely per-instance (not static) and that its bound is configurable or sanely fixed, since it now lives for the lifetime of a long-running worker.
The one architectural claim worth re-testing after 0.4.1 is the cost model. The library tokenizes the full source text before cropping; 0.4.1 explicitly targets "long texts", which is the shape of our worst-case documents. That does not change the O(document) characteristic, but it is the first release in the 0.4 line whose benefit is not gated on the decompounder, so it is materially more likely to apply to our English-only corpus than 0.4.0 was. Treat it as promising and unquantified — there is no published benchmark for 0.4.1.
The critical fit test is what our search backend already gives us. This package is only additive if we are generating snippets ourselves:
| Search backend | Fit |
|---|---|
Laravel Scout database driver, LIKE queries, TNTSearch |
Strong — we currently have nothing better than regex/str_ireplace |
| Loupe | Strong, and likely the intended pairing (same maintainers) |
Meilisearch (_formatted), Algolia (_highlightResult), Elasticsearch (highlighter) |
Weak — the engine already returns highlighted/cropped fields; this duplicates CPU work and risks inconsistent markup |
The second genuine fit is consistency: if we expose more than one backend, or need to crop stored fields that the engine does not return, this gives one snippet format everywhere.
Integration feasibility
composer require loupe/matcher; no Laravel service provider is shipped, so we write a ~30-line provider binding a singleton.ext-intl (ICU word boundaries + transliterator for diacritic folding). Present in Laravel Sail/Forge/Herd by default; not present in bare php:8.x-alpine images — this must be added to our Dockerfile or Composer install fails.Technical risk
[!CAUTION] about API churn, and the release history confirms it: 0.2.0 changed stopword/token separation, 0.2.1 changed normalization, 0.2.2 changed span semantics, 0.2.3–0.2.4 changed folding state and added truncation, 0.4.0 re-architected the cache. 0.4.1 now reworks the highlighting path — the exact code that decides which spans get marked. Behavioral changes land in minor releases, and internal rewrites land in minor and patch releases without a migration note.0.4.* with a lockfile rather than a float.0.2.*; the current release is 0.4.1. Any version discipline we write must start from 0.4.x, and we should read the 0.3.x changelog before the spike — we have never reviewed those changes.bench-movies fixture and specifically exercise the decompounder, which only engages for compound-word languages (Germanic/Scandinavian). On an English-only corpus we should expect a materially smaller gain. The 0.4.1 release notes publish no numbers at all. Treat both as directional, not as capacity-planning inputs.<, &, or HTML, output must not be rendered with {!! !!} naively.latin1 columns remains unverified.Key questions
"phrase" and -negation semantics to agree with the engine's, or is approximate highlighting fine for display?Stack fit
PHP 8.x + ext-intl, zero Laravel coupling, returns a plain string — works unchanged in Blade, Livewire, Inertia, or an API resource. Register as a singleton; make all tunables config-driven so marketing/UX can adjust snippet length without a deploy.
If we run under Octane or persistent workers, note that the singleton now retains an internal LRU cache for the process lifetime. That is by design and bounded, but it is new state we did not have in 0.2.x — measure it before rollout rather than after.
Recommended wiring
App\Support\Search\SnippetFormatter, implemented by LoupeSnippetFormatter. Nothing outside this class may use Loupe\Matcher\*. This is the anti-corruption layer that makes the pre-1.0 churn a one-file problem — and 0.3 → 0.4.1 are exactly the kind of churn it exists for.config/search.php with crop_length, highlight_start/end, crop_marker, truncation settings, per-locale stopwords, decompounder enable/disable per locale, and a default locale derived from app()->getLocale().@snippet($text, $query)) for template ergonomics, but keep controllers and API resources calling the interface.Escaping pipeline (do this first, not last)
strip_tags() the source field (or confirm it is plain text).%%HL%% / %%/HL%%) rather than <mark>.e() the resulting string.<mark>/<em> tags.FormatterOptions and the standalone Cropper both accept arbitrary start/end tags, so this costs nothing and removes the XSS class of failure entirely. It also means any 0.4.1 change to span selection surfaces as differently-placed sentinels in a golden file rather than as a rendering surprise in production.
Migration path
Purely additive: no schema change, no data migration, no backfill. Replace ad-hoc regex highlighting one surface at a time.
Compatibility
Pin loupe/matcher:0.4.* with a committed lockfile, and treat even patch bumps as code changes requiring a green golden-file run. Do not carry forward the 0.2.x pin from the prior review — it is stale and would silently exclude the current line. Because everything sits behind our interface, swapping to a fork, to Loupe's native highlighting, or to Meilisearch's _formatted response is a single-class change with no call-site edits.
Sequencing
Sequencing caveat: if a Scout → Meilisearch/Algolia migration is on the roadmap within two quarters, defer this. Those engines ship this feature and we would build it twice. Second caveat: if the spike shows 0.4.1's long-text win is negligible on our corpus, nothing else about the case changes — we adopt on output quality and consistency or not at all.
Maintenance
Treat minor and patch bumps as potentially breaking until 1.0. 0.4.1 is a two-day-later patch release that touches the highlighting path — the least reviewable kind of change — which reinforces rather than relaxes the discipline. Budget ~1–2 dev-days per quarter for upgrade + regression pass, and require a golden-file diff review on every bump. Code surface is small enough that a fork is a realistic fallback if maintenance stalls. Positives: the project is clearly under active development with maintainer-led PRs, and two consecutive releases are performance work, which shows someone is profiling real workloads. Negatives: release metadata is inconsistent (0.2.4 appears future-dated, cadence is bursty — nothing for a while, then 0.4.0 → 0.4.1 in two days) and the version line has moved three releases since our last review. Do not assume a predictable support heartbeat, and do not assume our prior review covers 0.3.x, 0.4.0, or 0.4.1.
Support
Community-only: GitHub issues, no SLA, no security policy, ~10 stars. We own production support. Required internal artifacts: one-page doc, a runbook entry for "snippets look wrong", a pinned version + lockfile policy, and golden-file tests on real content in each supported locale. Without those, every upgrade is a blind deploy — and 0.4.0/0.4.1 together show upgrades can change cache internals and the highlighting path even when the changelog reads as "performance only".
Scaling
Stateless, CPU-only, no I/O — horizontal scaling is a non-issue. The cost driver is results per page × text length, and note that tokenization runs over the full source text before cropping, so cost is O(document), not O(snippet). 0.4.1 attacks exactly this for long documents and, unlike 0.4.0, is not gated on the decompounder — so it is the more relevant release for our corpus, though still a constant-factor improvement and still unmeasured by us. The O(document) characteristic is unchanged. Mitigations, in order of value:
doc_id + normalized_query_hash + options_hash (the package already accepts pre-calculated matches, so cache the match set, not just the string).Realistically a few ms per snippet for typical 200–500 char fields — fine at 10–20 results/page, but measure rather than assume. Expect first-call cost per unique token to be higher than steady state (cold LRU), which matters for spiky traffic and for workers that churn through many distinct documents.
Failure modes
| Failure | Symptom | Mitigation |
|---|---|---|
ext-intl missing in image |
Composer/install or runtime fatal | Add to Dockerfile; add a health check |
Malformed UTF-8 / legacy latin1 |
Exception or garbled crop | Normalize/validate before formatting; identify offending columns |
| HTML in source text | XSS if rendered unescaped | Sentinel-tag + e() pipeline |
| No match (empty or stopword-only query) | No highlight; crop falls back to document head | Define UX explicitly; show leading excerpt + "no exact match" |
| Source is one giant token (URL, base64, minified JS) | Crop cannot find a word boundary | Pre-strip such fields or cap token length |
| Locale mismatch | Diacritics not matched, silent under-highlighting | Per-locale config + tests |
| Decompounder enabled for a non-compound locale | Wasted CPU, surprising highlight spans | Enable per locale; validate config at boot |
| Long-lived worker retains LRU across many documents | Slow memory growth vs. 0.2.x baseline | Confirm cache is bounded; monitor RSS under Octane/queues |
| Patch upgrade changes span/folding/cache behavior (0.4.1 risk) | Highlight regressions after composer update |
Version pin + lockfile + golden-file diff gate in CI on every bump, patches included |
| Unbalanced configured tags | Broken page layout | Validate config at boot, not at render |
Ramp-up
Low and unchanged. README is above average for a 10-star package and the public surface is roughly five classes — a mid-level Laravel dev is productive in half a day, fully confident in a week. No Laravel-specific docs exist, so we write our own examples. On-call impact is near zero (no external calls, no jobs of its own) provided the escaping pipeline and input bounds are in place before the first user-facing surface ships, and provided we are disciplined about the pin now that the version line has moved and patches are shipping with behavioral surface area.
How can I help you explore Laravel packages today?