loupe/matcher
PHP library for search term highlighting and contextual snippet generation. Tokenize queries (phrases, negation, locale-aware rules), match terms with stop-word filtering and span positions, then format results with highlights and cropped excerpts for user-friendly search output.
composer require loupe/matcher
use Loupe\Matcher\Tokenizer\Tokenizer;
use Loupe\Matcher\Matcher;
use Loupe\Matcher\Formatter;
use Loupe\Matcher\FormatterOptions;
public function search(Request $request)
{
$tokenizer = new Tokenizer();
$matcher = new Matcher($tokenizer);
$formatter = new Formatter($matcher);
$query = $request->input('q');
$text = "Your long document text here...";
$options = (new FormatterOptions())
->withEnableHighlight()
->withHighlightStartTag('<mark>')
->withHighlightEndTag('</mark>')
->withEnableCrop()
->withCropLength(100);
$result = $formatter->format($text, $query, $options);
return response()->json(['snippet' => $result->getFormattedText()]);
}
For a Laravel search feature (e.g., Algolia/Scout results), use this to generate snippets:
// In your search result view or Livewire component
$snippet = $formatter->format($product->description, $request->query, $options);
echo $snippet->getFormattedText();
Tokenizer, Matcher, Formatter (in src/).FormatterOptions for customizing output.tests/ for usage patterns (e.g., FormatterTest.php for cropping logic).$tokenizer = new Tokenizer('en_US');
$queryTokens = $tokenizer->tokenize($request->query);
$matcher = new Matcher($tokenizer, ['the', 'and']);
$matches = $matcher->calculateMatches($documentText, $queryTokens);
$options = (new FormatterOptions())
->withEnableHighlight()
->withEnableCrop()
->withCropLength(150);
$result = $formatter->format($documentText, $queryTokens, $options);
// In your Scout model
public function toSearchableArray()
{
return [
'title' => $this->title,
'description' => $this->description,
'snippet' => function () {
$tokenizer = new Tokenizer();
$matcher = new Matcher($tokenizer);
$formatter = new Formatter($matcher);
return $formatter->format($this->description, $this->searchQuery, $options);
}
];
}
// app/Http/Livewire/SearchResults.php
public function render()
{
$snippets = collect($this->results)
->map(fn ($result) => $this->generateSnippet($result->text, $this->query));
return view('livewire.search-results', ['snippets' => $snippets]);
}
protected function generateSnippet(string $text, string $query): string
{
$formatter = resolve(Formatter::class);
$options = (new FormatterOptions())
->withEnableHighlight()
->withCropLength(200);
return $formatter->format($text, $query, $options)->getFormattedText();
}
// Cache matches for 1 hour
$cacheKey = "matches:{$documentId}:{$query}";
$matches = Cache::remember($cacheKey, now()->addHours(1), function () use ($document, $query) {
$tokenizer = new Tokenizer();
$matcher = new Matcher($tokenizer);
return $matcher->calculateMatches($document->text, $query);
});
// Use cached matches in formatter
$result = $formatter->format($document->text, $query, $options, $matches);
// Example: Tokenizer for product attributes (e.g., "color:red", "size:large")
class ProductAttributeTokenizer implements TokenizerInterface
{
public function tokenize(string $text): TokenCollection
{
preg_match_all('/([a-z]+):([a-z]+)/i', $text, $matches);
return new TokenCollection(array_map(
fn($attr, $value) => new Token("{$attr}:{$value}"),
$matches[1],
$matches[2]
));
}
public function matches(Token $token, TokenCollection $tokens): bool
{
return $tokens->contains(fn($t) => $t->getValue() === $token->getValue());
}
}
Locale-Specific Tokenization:
'en_US') for accurate word boundaries. Defaults to C (ASCII), which may split words incorrectly (e.g., "lorem ipsum" → ["lorem", "ipsum"] vs. ["lorem ipsum"]).Tokenizer:
$tokenizer = new Tokenizer('en_US');
Phrase Matching Quirks:
"exact phrase") must match exactly, including case and punctuation. Use lowercase: true in FormatterOptions for case-insensitive matching:
$options->withLowercase(true);
-exclude) are treated as stop words for the entire query. Avoid mixing them with other terms if unintended exclusions occur.HTML/Tag Handling:
<b>bold</b>), they’ll be treated as literal text. For HTML-aware cropping, use the standalone Cropper:
$cropper = new \Loupe\Matcher\Formatting\Cropper(
cropLength: 100,
highlightStartTag: '<mark>',
highlightEndTag: '</mark>'
);
$cropped = $cropper->cropHighlightedText($htmlText);
Performance with Long Texts:
$matches = Cache::remember("matches:{$documentId}", now()->addDays(1), function () {
return $matcher->calculateMatches($longDocumentText, $query);
});
Diacritics and Unicode:
é → e), but ensure ext-intl is enabled in your PHP config (php.ini):
extension=intl
"café" or "naïve".Formatter Options Overrides:
// Wrong: This won't work
$options->withCropLength(200)->withHighlightStartTag('<span>');
// Correct: Chain or create new
$newOptions = $options->withCropLength(200);
$tokens = $tokenizer->tokenize($text);
dd($tokens->all()); // Debug tokenized output
$spans = $matcher->calculateMatchSpans($text, $query, $matches);
foreach ($spans as $span) {
echo "Match at {$span->getStartPosition()}-{$span->getEndPosition()}: "
. substr($text, $span->getStartPosition(), $span->getLength()) . "\n";
}
$options = (new FormatterOptions())->withEnableCrop(false);
$options->withHighlightStartTag('<span class="highlight">')
->withHighlightEndTag('</span>');
Result::getFormattedText() and apply additional transformations (e.g., strip tags, sanitize):
$formatted = $result->getFormattedText();
$sanitized = strip_tags($formatted, '<mark><em>');
Formatter to add callbacks for pre/post-formatting:
class CustomFormatter extends Formatter
{
protected function beforeFormat(string $text, TokenCollection $query
How can I help you explore Laravel packages today?