Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Matcher Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and check requirements. The library leans on ext-intl for ICU word boundaries and diacritics folding, so verify it exists before anything else:

composer require loupe/matcher
php -m | grep intl   # must output "intl"

If you're on a slim Docker image: docker-php-ext-install intl && docker-php-ext-enable intl. Add it to CI too — tokenization silently degrades or throws without it.

Pin the version. The README carries a "work in progress, expect frequent API changes" caution and the package is at 0.x with a small star count. The 0.4.0 release is a drop-in if you were on 0.3.x (it's an internal cache redesign, not an API change), but pin "loupe/matcher": "0.4.*" rather than ^0.4 — the project has shipped position-mapping fixes (0.2.2) and late-landing features (truncation in 0.2.4) inside a minor series, so a wildcard minor is not a safe default. If you're still on a 0.2.* pin, budget the upgrade as two minors of accumulated change, not one patch: read the 0.3.x changelog before bumping, and let your adapter tests be the gate.

Regardless of version, write at least one test that asserts your rendered snippet, so a minor bump that changes crop markers or span behaviour fails loudly.

Register it once. There is no Laravel auto-discovery; bind the three objects as singletons in a small service provider and keep options in config/matcher.php so they're env-tunable:

// config/matcher.php
return [
    'locale'            => env('MATCHER_LOCALE'), // null → app locale
    'stop_words'        => ['the', 'and', 'or', 'a', 'of'],
    'highlight_start'   => '<mark>',
    'highlight_end'     => '</mark>',
    'crop_length'       => 150,
    'crop_marker'       => ' … ',
    'truncation_length' => 300,
];
// app/Providers/MatcherServiceProvider.php
public function register(): void
{
    $this->app->singleton(Tokenizer::class, fn () => new Tokenizer(
        config('matcher.locale') ?? str_replace('-', '_', app()->getLocale())
    ));

    $this->app->singleton(Matcher::class, fn ($app) => new Matcher(
        $app->make(Tokenizer::class),
        config('matcher.stop_words', [])
    ));

    $this->app->singleton(Formatter::class, fn ($app) => new Formatter($app->make(Matcher::class)));
}

First use case — a search results page. This is where 90% of the value is: you have a Body/Description column, a q query param, and you want Google-style snippets. Wrap it in one class so the rest of the app never touches the library directly:

namespace App\Support;

class SearchSnippet
{
    public function __construct(private Formatter $formatter) {}

    public function for(string $text, ?string $query): string
    {
        $query = trim((string) $query);

        if ($query === '' || $text === '') {
            return Str::limit($text, 200); // no query → plain fallback
        }

        $options = (new FormatterOptions())
            ->withEnableHighlight()
            ->withHighlightStartTag(config('matcher.highlight_start'))
            ->withHighlightEndTag(config('matcher.highlight_end'))
            ->withEnableCrop()
            ->withCropLength(config('matcher.crop_length'))
            ->withCropMarker(config('matcher.crop_marker'));

        return $this->formatter->format($text, $query, $options)->getFormattedText();
    }
}
{{-- resources/views/components/search-snippet.blade.php --}}
@props(['text', 'query'])
<span class="search-snippet">
    {!! app(\App\Support\SearchSnippet::class)->for(strip_tags($text), $query) !!}
</span>

Where to look first. Read the Core Components section and start with Formatter + FormatterOptions. Only drop down to Matcher::calculateMatches() / calculateMatchSpans() when you need positions, and to Cropper when the text is already highlighted (e.g. by Meilisearch). Tokenizer you'll mostly configure, not call. The decompounder cache that 0.4.0 rewrote is entirely behind these — you configure a locale and it shows up as a speedup or not at all.

Implementation Patterns

Keep a single adapter service. Everything above (tags, crop lengths, fallback, empty-query guard) lives in SearchSnippet. This is your insurance against the WIP API and against swapping in a different library later. Inject it into resources, Livewire components, controllers, mailers.

Blade component for rendered pages. A <x-search-snippet :text="$post->body" :query="request('q')" /> keeps views clean and makes it trivial to change tags globally. Render with {!! !!} — but see the escaping gotcha below.

API resources: return HTML and spans. Some clients (SPA, mobile, Inertia) would rather receive offsets than server-rendered markup:

class PostResource extends JsonResource
{
    public function toArray($request): array
    {
        $q = (string) $request->string('q');
        $matcher = app(Matcher::class);

        $matches = $matcher->calculateMatches($this->body, $q);
        $spans   = $matcher->calculateMatchSpans($this->body, $q, $matches);

        return [
            'id'      => $this->id,
            'title'   => $this->title,
            'snippet' => app(SearchSnippet::class)->for($this->body, $q),
            'spans'   => array_map(
                fn ($s) => [$s->getStartPosition(), $s->getEndPosition()],
                $spans
            ),
        ];
    }
}

That gives you <mark> highlighting today and client-side highlighting (or a JS-based highlighter) later without changing the backend.

Cache matches, not HTML. Formatter::format() accepts a pre-built TokenCollection via matches:, and the release notes (0.1.1) exist precisely for this. When you change snippet options (crop length from a user preference, for instance) you still avoid re-tokenizing:

$key = "match:{$post->id}:{$post->updated_at->timestamp}:" . md5($q);

$matches = Cache::remember($key, now()->addHour(), fn () =>
    app(Matcher::class)->calculateMatches($post->body, $q)
);

$snippet = app(Formatter::class)
    ->format($post->body, $q, $options, matches: $matches)
    ->getFormattedText();

Key on the record's updated_at and the query — this makes repeated pagination and search-as-you-type nearly free. This application-level cache is orthogonal to the internal decompounder cache that 0.4.0 improved; keep both, they solve different problems (yours survives across requests, the library's does not).

Let the search engine highlight, then crop. If you use Scout + Meilisearch/Algolia, their _formatted hits already contain <em> markers. Don't re-match — just crop around them with matching tags:

$cropper = new Cropper(
    cropLength: config('matcher.crop_length'),
    cropMarker: config('matcher.crop_marker'),
    highlightStartTag: '<em>',   // must match what the engine emits
    highlightEndTag: '</em>',
);

$snippet = $cropper->cropHighlightedText($hit['_formatted']['body']);

The same Cropper is useful for stored/pre-rendered content (e.g. a cached rich preview) where re-tokenizing is wasteful or impossible.

Multi-field results. Titles want tight crops and body text wants wide ones. Loop fields with different options rather than forcing one configuration:

$result = [
    'title' => $snippets->for($post->title, $q, titleOptions()),
    'body'  => $snippets->for($post->body,  $q, bodyOptions()),
];

Livewire / search-as-you-type. Put SearchSnippet::for() in a computed property, debounce the input (wire:model.live.debounce.300ms), and cache matches per query as above. Tokenization is cheap for 10–20 rows; it is not cheap for 500, so paginate before formatting, not after.

Locale-aware tokenization. Construct Tokenizer with an ICU locale (en_US, de_DE, pt_BR). Laravel's pt-BR → replace - with _. If your locale is just de, be explicit anyway; you get correct word boundaries and diacritics folding — uber will match über while spans still point at the original text (0.2.2 fixed position mapping). Compound-word locales (de_DE, nl_NL, the Scandinavian languages) are also where the decompounder lives, so that's the configuration the 0.4.0 cache work actually touches.

Query syntax as a UI feature. Because the tokenizer exposes all(), phraseGroups(), and allNegated(), you can render "chips" for the parsed query: quoted phrases as one chip, -draft as an exclusion chip. This keeps your UI honest about what the matcher (and, ideally, your database search) is actually doing.

Testing. Pest/PHPUnit on the adapter, not the library:

it('highlights and crops around a term', function () {
    $out = app(SearchSnippet::class)->for('The quick brown fox jumps', 'brown');

    expect($out)->toContain('<mark>brown</mark>')
        ->not->toContain('jumps'); // crop is doing its job
});

Add a test for the empty-query fallback, an accented match (uber vs über), and a quoted phrase — those are the three cases most likely to regress across 0.x releases. If you upgrade to 0.4.x on a compound-word locale, add a fourth: a real German/Nordic compound term asserting both the match and the span positions, since the decompounder is exactly what changed.

Gotchas and Tips

The library does not escape your text. getFormattedText() returns the original string with tags injected — any existing HTML/JS in the source passes straight through, and {!! !!} will happily execute it. Two safe patterns: (a) strip_tags() (or e()) the input before calling format(), since the only tags in the output are then your own highlight tags; or (b) sanitize the output with strip_tags($snippet, '<mark><em>') / a purifier. Never pass raw user-authored HTML bodies into the formatter.

Strip tags before matching, not after. Matching inside <a href="search-words"> produces broken markup and nonsense highlights. Format a plain-text column or strip_tags($html). This library is not a DOM highlighter — for HTML/XML, use a DOM-based approach.

Guard empty and unusable queries. An empty query, a query made only of stop words, or a query made only of negated terms can yield zero matches. Decide the behaviour explicitly (fallback to Str::limit) instead of shipping an empty snippet. This also avoids accidental "highlight everything" surprises.

Don't expect stop words in all(). Since 0.2.0 stop words are separated from tokens. If you're building query chips or counting terms, use the right collection rather than assuming every word appears.

Negation is display-only unless you mirror it. -draft prevents draft from being highlighted, but your SQL/Scout query must implement the same exclusion — otherwise results will contain "draft" documents with no visible reason for matching. Same for quoted phrases: exact-phrase highlighting looks wrong if the engine did an OR search.

Locale format is ICU, not Laravel. app()->getLocale() can be en or pt-BR; the tokenizer wants en_US / pt_BR. Normalize in one place (the provider) and log the resolved locale once during bring-up — a wrong locale is a subtle bug that only shows up in accented or apostrophe-heavy text (l'été, don't).

Crop and truncation are different tools. withEnableCrop() + withCropLength() chooses context around matches; withEnableTruncation() + withTruncationLength()/withTruncationMarker() caps total length. Enabling both is legitimate, but verify with real data whether truncation runs before or after cropping and where the markers land — the README doesn't specify ordering, and the truncation feature landed late (0.2.4). Write one test with a 5,000-character body and both flags on. Also keep cropLength comfortably above the longest expected match, or a single long term gets cut.

Set highlight tags explicitly. Defaults exist, but if they ever change, your CSS breaks. Configure <mark>, <em>, or a custom <span class="hl"> in config. If you crop pre-highlighted text from Meilisearch (which emits <em>), your Cropper tags must match exactly or the cropper won't detect highlight boundaries.

Cache invalidation on matches: — the parameter takes a TokenCollection, not spans. Cache that collection, never a FormatterOptions-dependent string. When content changes, updated_at in the key handles it; if you use a soft-delete or a revision table, key on the revision id instead.

Performance envelope. Formatting 20–50 rows per request is fine. Formatting thousands, or re-formatting the same document for every request, is not. Paginate first; cache matches second; push long-document formatting to a queue and store the snippet third. If you're formatting inside a ->map() over an Eloquent collection, you're probably re-tokenizing the same query string per row — hoist the tokenization or the cache.

Don't buy the 0.4.0 benchmark for your own workload. The 2.45× / −14.5 MB numbers come from the project's bench-movies corpus with a warmed decompounder cache on a decompounding locale. If you run en_US with short bodies, this release is close to a no-op — upgrade for the maintenance, not for the speedup. Measure with your own texts and your own locale before you rewrite anything around it.

Warmed vs. cold, and per-process memory. The decompounder cache is process-local state: the first requests after a deploy (or after an Octane worker restart) are cold, and that's when your p95 lives. Conversely, under Octane/queue workers the cache stays warm between requests — which means the ~68 MB peak in the release table is per worker. Multiply by your worker count when sizing containers, and if you have long-lived workers, remember the old substring cache's memory profile is what the LRU redesign fixed; don't carry stale memory_limit headroom assumptions from 0.3 across to 0.4, re-measure instead.

Debugging spans. When a highlight looks misplaced (off-by-one, split words), dump the raw positions before blaming the formatter:

$matches = $matcher->calculateMatches($text, $q);
foreach ($matcher->calculateMatchSpans($text, $q, $matches) as $span) {
    dump($span->getStartPosition(), $span->getEndPosition(), substr($text, $span->getStartPosition(), $span->getEndPosition() - $span->getStartPosition()));
}

Byte vs. character offsets are the usual culprit — substr vs mb_substr on multibyte text; use mb_substr when you slice manually. On compound-word locales, a span that lands mid-word after a 0.4.x bump points at the decompounder, not your code: pin, then bisect.

Extension point for codes and SKUs. SKU-1234, v2.1.0, emails, and hyphenated identifiers often tokenize into pieces. Implement TokenizerInterface (and matches()) for those fields, or pre-normalize before formatting. This is also the right hook for CJK content or domain-specific stop words. Bind your custom tokenizer in the provider and keep Matcher/Formatter untouched. Note that a custom tokenizer sidesteps the decompounder entirely, so 0.4.0's cache work won't help or hurt those code paths.

Vendor risk is real. Small star count, few dependents, 0.x versioning, and a release history with late-landing features and internal redesigns. That's acceptable because the package is small and MIT, but only if you've isolated it behind SearchSnippet. If it stalls, replacing the implementation behind that interface is an afternoon, not a quarter. Keep the adapter free of library types in its public signature.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
se7enxweb/flysystem
lampwick/laravel
jeslage/statamic-emojis
micahdshackelford/rmap-laravel
draftsmaninc/draftsman
square-bit/laravel-volition
himito/laravel-mailman
htinlynn/genius-helper
maco-studios/hephaestus
elmogy/larafast
xeviant/laravel-paystack
gorilladash/laravel-website-sdk
martialbe/flysystem-oss
chondal/3312client
jeroen-g/flickr
opscale-co/nova-genbi
itsjustvita/laravel-retention
tequilarapido/trackit
gregsphotography/site-builder-filament
cabaoctavio/laravel-query-log