Skip to content

Zero-config fuzzy search for Laravel with 8 algorithms, typo tolerance, relevance scoring, and scales to 10M records. No external dependencies.

License

Notifications You must be signed in to change notification settings

ashiqfardus/laravel-fuzzy-search

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

17 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Laravel Fuzzy Search

Latest Version on Packagist Total Downloads License PHP Version Laravel Version

A powerful, zero-config fuzzy search package for Laravel with fluent API. Works with all major databases without external services.

🚀 Demo: laravel-fuzzy-search-demo - See the package in action!

📚 Documentation: Getting Started • Performance Guide • Comparisons

✨ Features

Category Features
Core Zero-config search • Fluent API • Eloquent & Query Builder support
Algorithms Multiple fuzzy algorithms • Typo tolerance • Multi-word token search
Scoring Field weighting • Relevance scoring • Prefix boosting • Partial match • Recency boost
Text Processing Stop-word filtering • Synonym support • Language/locale awareness
Internationalization Unicode support • Accent insensitivity • Multi-language
Results Highlighted results • Custom scoring hooks • Debug/explain-score mode
Performance Search index table • Async indexing (queue) • Redis/cache support
Pagination Stable ranking • Cursor pagination • Offset pagination
Reliability Fallback search strategy • DB-agnostic • Rate-limit friendly • SQL-injection safe
Configuration Config file support • Per-model customization
Developer Tools CLI indexing • Benchmark tools • Built-in test suite • Performance utilities
Smart Search Autocomplete suggestions • "Did you mean" spell correction • Multi-model federation • Search analytics

Installation

composer require ashiqfardus/laravel-fuzzy-search

That's it! Zero configuration required. Start searching immediately.

Optionally publish the config file:

php artisan vendor:publish --tag=fuzzy-search-config

Quick Start

Zero-Config Search

// Just add the trait and search!
use Ashiqfardus\LaravelFuzzySearch\Traits\Searchable;

class User extends Model
{
    use Searchable;
}

// Search immediately - auto-detects searchable columns
$users = User::search('john')->get();

How auto-detection works: The package automatically detects common column names in this priority order:

  • name, title (weight: 10)
  • email, username (weight: 8)
  • first_name, last_name (weight: 7)
  • description, content, body (weight: 5)
  • bio, summary, excerpt (weight: 3)
  • slug, sku, code (weight: 2-6)

If none of these exist, it falls back to the model's $fillable columns.

Manual Column Configuration

You can manually specify which columns to search and their weights:

class User extends Model
{
    use Searchable;

    // Option 1: Define in $searchable property
    protected array $searchable = [
        'columns' => [
            'name' => 10,        // Highest priority
            'email' => 5,        // Medium priority  
            'bio' => 1,          // Lowest priority
        ],
        'algorithm' => 'fuzzy',
        'typo_tolerance' => 2,
    ];
}

// Option 2: Specify at query time (overrides $searchable)
$users = User::search('john')
    ->searchIn(['name' => 10, 'email' => 5])
    ->get();

// Option 3: Simple array without weights (all get weight of 1)
$users = User::search('john')
    ->searchIn(['name', 'email', 'username'])
    ->get();

Fluent API

$users = User::search('john doe')
    ->searchIn(['name' => 10, 'email' => 5, 'bio' => 1])  // Field weighting
    ->typoTolerance(2)                                      // Allow 2 typos
    ->withSynonyms(['john' => ['jon', 'johnny']])          // Synonym support
    ->ignoreStopWords(['the', 'and', 'or'])                // Stop-word filtering
    ->accentInsensitive()                                   // Unicode/accent handling
    ->highlight('mark')                                     // Highlighted results
    ->withRelevance()                                       // Relevance scoring
    ->prefixBoost(2.0)                                      // Prefix boosting
    ->debugScore()                                          // Explain scoring
    ->paginate(15);

Eloquent & Query Builder Support

// Eloquent
User::whereFuzzy('name', 'john')->get();
User::whereFuzzyMultiple(['name', 'email'], 'john')->get();

// Query Builder
DB::table('users')->whereFuzzy('name', 'john')->get();
DB::table('products')->fuzzySearch(['title', 'description'], 'laptop')->get();

Search Algorithms

Available Algorithms

Algorithm Best For Typo Tolerance Speed
fuzzy General purpose âś… High Fast
levenshtein Strict typo matching âś… Configurable Medium
soundex Phonetic matching (English names) âś… Phonetic Fast
metaphone Phonetic matching (more accurate) âś… Phonetic Fast
trigram Similarity matching âś… High Medium
similar_text Percentage similarity âś… Medium Medium
simple / like Exact substring (LIKE) ❌ None Fastest
// Use specific algorithm
User::search('john')->using('levenshtein')->get();
User::search('stephen')->using('soundex')->get();  // Finds "Steven"
User::search('stephen')->using('metaphone')->get(); // More accurate phonetic
User::search('laptop')->using('similar_text')->get(); // Percentage match

Typo Tolerance

// Auto typo tolerance based on word length
User::search('jonh')->get();  // Finds "John"

// Configure tolerance level
User::search('john')
    ->typoTolerance(1)  // Allow 1 typo
    ->get();

// Disable typo tolerance
User::search('john')
    ->typoTolerance(0)
    ->get();

Multi-Word Token Search

// Searches each word independently and combines results
User::search('john doe developer')
    ->tokenize()        // Split into tokens
    ->matchAll()        // All tokens must match (AND)
    ->get();

User::search('john doe developer')
    ->tokenize()
    ->matchAny()        // Any token can match (OR)
    ->get();

Field Weighting & Scoring

Weighted Columns

User::search('john')
    ->searchIn([
        'name' => 10,       // Highest priority
        'username' => 8,
        'email' => 5,
        'bio' => 1,         // Lowest priority
    ])
    ->get();

Relevance Scoring

$users = User::search('john')
    ->withRelevance()
    ->get();

foreach ($users as $user) {
    echo "{$user->name}: {$user->_score}";
}

Prefix Boosting

// Boost results that start with the search term
User::search('john')
    ->prefixBoost(2.5)  // 2.5x score for prefix matches
    ->get();

Partial Match Support

User::search('joh')
    ->partialMatch()    // Matches "john", "johnny", "johanna"
    ->minMatchLength(2) // Minimum 2 characters
    ->get();

Custom Scoring Hooks

User::search('john')
    ->customScore(function ($item, $baseScore) {
        // Boost verified users
        if ($item->is_verified) {
            return $baseScore * 1.5;
        }
        // Penalize inactive users
        if (!$item->is_active) {
            return $baseScore * 0.5;
        }
        return $baseScore;
    })
    ->get();

Recency Boost

Boost newer records in search results:

// Recent records (within 30 days) get 1.5x score boost
User::search('john')
    ->boostRecent(1.5, 'created_at', 30)
    ->get();

// With defaults: 1.5x boost, created_at column, 30 days
User::search('john')
    ->boostRecent()
    ->get();

Search Suggestions / Autocomplete

Get autocomplete suggestions based on search term:

$suggestions = User::search('joh')
    ->searchIn(['name', 'email'])
    ->suggest(5);

// Returns: ['John', 'Johnny', 'Johanna', ...]

"Did You Mean" Spell Correction

Get alternative spellings when search has typos:

$alternatives = User::search('jonh')  // Typo
    ->searchIn(['name'])
    ->didYouMean(3);

// Returns: [
//     ['term' => 'john', 'distance' => 1, 'confidence' => 0.8],
//     ['term' => 'jon', 'distance' => 2, 'confidence' => 0.6],
// ]

Multi-Model Federation Search

Search across multiple models simultaneously:

use Ashiqfardus\LaravelFuzzySearch\FederatedSearch;

$results = FederatedSearch::across([User::class, Product::class, Post::class])
    ->search('laptop')
    ->using('fuzzy')
    ->limit(20)
    ->get();

// Each result includes _model_type and _model_class
foreach ($results as $result) {
    echo $result->_model_type;  // 'User', 'Product', or 'Post'
}

// Get grouped results
$grouped = FederatedSearch::across([User::class, Product::class])
    ->search('test')
    ->getGrouped();

// Get counts per model
$counts = FederatedSearch::across([User::class, Product::class])
    ->search('test')
    ->getCounts();  // ['User' => 5, 'Product' => 3]

Search Analytics

Get detailed analytics about your search configuration:

$analytics = User::search('john')
    ->searchIn(['name' => 10, 'email' => 5])
    ->using('levenshtein')
    ->typoTolerance(2)
    ->getAnalytics();

// Returns: [
//     'search_term' => 'john',
//     'algorithm' => 'levenshtein',
//     'columns_searched' => ['name', 'email'],
//     'typo_tolerance' => 2,
//     'tokenized' => false,
//     'stop_words_active' => false,
//     ...
// ]

Text Processing

Stop-Word Filtering

// Use default stop words
User::search('the quick brown fox')
    ->ignoreStopWords()
    ->get();

// Custom stop words
User::search('the quick brown fox')
    ->ignoreStopWords(['the', 'a', 'an', 'and', 'or', 'but'])
    ->get();

// Language-specific stop words
User::search('der schnelle braune fuchs')
    ->ignoreStopWords('de')  // German stop words
    ->get();

Synonym Support

User::search('laptop')
    ->withSynonyms([
        'laptop' => ['notebook', 'computer', 'macbook'],
        'phone' => ['mobile', 'cell', 'smartphone'],
    ])
    ->get();

// Or use synonym groups
User::search('laptop')
    ->synonymGroup(['laptop', 'notebook', 'computer'])
    ->get();

Language / Locale Awareness

User::search('john')
    ->locale('en')      // English
    ->get();

User::search('mĂĽnchen')
    ->locale('de')      // German - handles umlauts
    ->get();

Unicode & Accent Insensitivity

// Matches "café", "cafe", "Café"
User::search('cafe')
    ->accentInsensitive()
    ->get();

// Matches "naĂŻve", "naive"
User::search('naive')
    ->unicodeNormalize()
    ->get();

Result Presentation

Highlighted Results

$users = User::search('john')
    ->highlight('em')  // Wrap matches in <em> tags
    ->get();

foreach ($users as $user) {
    echo $user->_highlighted['name'];  // "Hello <em>John</em> Doe"
}

// Custom highlight
$users = User::search('john')
    ->highlight('<mark class="highlight">', '</mark>')
    ->get();

Debug / Explain-Score Mode

$users = User::search('john')
    ->debugScore()
    ->get();

foreach ($users as $user) {
    print_r($user->_debug);
    // [
    //     'term' => 'john',
    //     'column_scores' => ['name' => 100, 'email' => 25],
    //     'multipliers' => ['prefix_boost' => 2.0, 'weight' => 10],
    //     'final_score' => 250,
    //     'matched_algorithm' => 'fuzzy',
    // ]
}

Performance & Indexing

Search Index Table

# Create search index
php artisan fuzzy-search:index User

# Rebuild index
php artisan fuzzy-search:index User --fresh

# Index specific columns
php artisan fuzzy-search:index User --columns=name,email
// Use indexed search (faster for large tables)
User::search('john')
    ->useIndex()
    ->get();

Async Indexing (Queue Support)

// In config/fuzzy-search.php
'indexing' => [
    'async' => true,
    'queue' => 'search-indexing',
    'chunk_size' => 500,
],

// Dispatch indexing job
User::reindex();  // Queued automatically

Redis / Cache Support

// Cache search results
User::search('john')
    ->cache(minutes: 60)
    ->get();

// Cache with custom key
User::search('john')
    ->cache(60, 'user-search-john')
    ->get();

// Use Redis for pattern storage
// In config/fuzzy-search.php
'cache' => [
    'enabled' => true,
    'driver' => 'redis',
    'ttl' => 3600,
],

Pagination

Stable Ranking

// Results maintain consistent order across pages
$page1 = User::search('john')->stableRanking()->paginate(10, page: 1);
$page2 = User::search('john')->stableRanking()->paginate(10, page: 2);

Pagination Methods

// Offset pagination
$users = User::search('john')->paginate(15);

// Simple pagination (no total count - faster)
$users = User::search('john')->simplePaginate(15);

// Cursor pagination (best for infinite scroll)
$users = User::search('john')->cursorPaginate(15);

// Manual pagination
$users = User::search('john')
    ->take(10)
    ->skip(20)
    ->get();

Reliability & Safety

Fallback Search Strategy

// Automatically falls back to simpler algorithm if primary fails
User::search('john')
    ->using('trigram')
    ->fallback('fuzzy')      // First fallback
    ->fallback('simple')     // Second fallback
    ->get();

Database Compatibility

Database Full Support Native Functions
MySQL 5.7+ âś… SOUNDEX
PostgreSQL 9.6+ âś… pg_trgm, fuzzystrmatch
SQLite 3.x âś… Pattern-based
SQL Server 2016+ âś… Pattern-based
MariaDB 10.2+ âś… SOUNDEX

Rate-Limit Friendliness

// Built-in debouncing for real-time search
User::search($query)
    ->debounce(300)  // 300ms debounce
    ->get();

// Query complexity limits
User::search($query)
    ->maxPatterns(50)  // Limit pattern generation
    ->get();

SQL Injection Safety

All queries use parameterized bindings. Search terms are automatically sanitized.

// Safe - input is sanitized
User::search("'; DROP TABLE users; --")->get();

Exception Handling

The package provides custom exceptions for better error handling:

use Ashiqfardus\LaravelFuzzySearch\Exceptions\LaravelFuzzySearchException;
use Ashiqfardus\LaravelFuzzySearch\Exceptions\EmptySearchTermException;
use Ashiqfardus\LaravelFuzzySearch\Exceptions\InvalidAlgorithmException;

// Catch all fuzzy search exceptions
try {
    $results = User::search($term)->get();
} catch (LaravelFuzzySearchException $e) {
    // Handle any fuzzy search error
    Log::error('Search failed', $e->toArray());
}

// Catch specific exceptions
try {
    $results = User::search('')->get();  // Empty search
} catch (EmptySearchTermException $e) {
    return response()->json(['error' => 'Please enter a search term']);
}

try {
    $results = User::search('test')->using('invalid')->get();
} catch (InvalidAlgorithmException $e) {
    return response()->json(['error' => $e->getMessage()]);
}

Available Exceptions:

  • LaravelFuzzySearchException - Base exception (catch all)
  • EmptySearchTermException - Search term is empty
  • InvalidAlgorithmException - Invalid algorithm specified
  • InvalidConfigException - Configuration error
  • SearchableColumnsNotFoundException - No searchable columns found

Configuration

Config File

// config/fuzzy-search.php

return [
    'default_algorithm' => 'fuzzy',
    
    'typo_tolerance' => [
        'enabled' => true,
        'max_distance' => 2,
        'min_word_length' => 4,  // No typo tolerance for short words
    ],
    
    'scoring' => [
        'exact_match' => 100,
        'prefix_match' => 50,
        'contains' => 25,
        'fuzzy_match' => 10,
    ],
    
    'stop_words' => [
        'en' => ['the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at'],
        'de' => ['der', 'die', 'das', 'und', 'oder', 'aber'],
    ],
    
    'synonyms' => [
        // Global synonyms
    ],
    
    'indexing' => [
        'enabled' => false,
        'table' => 'search_index',
        'async' => true,
        'queue' => 'default',
    ],
    
    'cache' => [
        'enabled' => false,
        'driver' => 'redis',
        'ttl' => 3600,
    ],
    
    'performance' => [
        'max_patterns' => 100,
        'chunk_size' => 1000,
    ],
];

Config Presets

Presets are predefined search configurations for common use cases. Instead of manually configuring multiple options every time, use a single preset name.

Why Use Presets?

Without preset (verbose):

Post::search('laravel')
    ->searchIn(['title' => 10, 'body' => 5, 'excerpt' => 3])
    ->using('fuzzy')
    ->typoTolerance(2)
    ->ignoreStopWords('en')
    ->accentInsensitive()
    ->get();

With preset (clean):

Post::search('laravel')->preset('blog')->get();

Available Presets

Preset Best For Algorithm Typo Tolerance Features
blog Blog posts, articles fuzzy 2 Stop words, accent-insensitive
ecommerce Product search fuzzy 1 Partial match, no stop words
users User/contact search levenshtein 2 Accent-insensitive
phonetic Name pronunciation soundex 0 Phonetic matching
exact SKUs, codes, IDs simple 0 Partial match only

Preset Configuration Reference

// config/fuzzy-search.php
'presets' => [
    'blog' => [
        'columns' => ['title' => 10, 'body' => 5, 'excerpt' => 3],
        'algorithm' => 'fuzzy',
        'typo_tolerance' => 2,
        'stop_words_enabled' => true,
        'accent_insensitive' => true,
    ],
    
    'ecommerce' => [
        'columns' => ['name' => 10, 'description' => 5, 'sku' => 8, 'brand' => 6],
        'algorithm' => 'fuzzy',
        'typo_tolerance' => 1,
        'partial_match' => true,
        'stop_words_enabled' => false,
    ],
    
    'users' => [
        'columns' => ['name' => 10, 'email' => 8, 'username' => 9],
        'algorithm' => 'levenshtein',
        'typo_tolerance' => 2,
        'accent_insensitive' => true,
    ],
    
    'phonetic' => [
        'columns' => ['name' => 10],
        'algorithm' => 'soundex',
        'typo_tolerance' => 0,
    ],
    
    'exact' => [
        'algorithm' => 'simple',
        'typo_tolerance' => 0,
        'partial_match' => true,
    ],
],

Using Presets

// Use a preset
User::search('john')->preset('users')->get();
Post::search('laravel')->preset('blog')->get();
Product::search('laptop')->preset('ecommerce')->get();

// Phonetic search for names
Contact::search('steven')->preset('phonetic')->get();  // Finds "Stephen"

// Exact search for SKUs
Product::search('SKU-12345')->preset('exact')->get();

Override Preset Settings

Presets can be combined with other methods - later calls override preset values:

// Use blog preset but with higher typo tolerance
Post::search('laravel')
    ->preset('blog')
    ->typoTolerance(3)  // Override preset's default of 2
    ->get();

// Use ecommerce preset but search different columns
Product::search('laptop')
    ->preset('ecommerce')
    ->searchIn(['name' => 10, 'category' => 5])  // Override columns
    ->get();

Create Custom Presets

Add your own presets in config/fuzzy-search.php:

'presets' => [
    // ... existing presets ...
    
    'documents' => [
        'columns' => ['title' => 10, 'content' => 8, 'tags' => 5],
        'algorithm' => 'trigram',
        'typo_tolerance' => 2,
        'stop_words_enabled' => true,
        'locale' => 'en',
    ],
    
    'multilingual' => [
        'columns' => ['title' => 10, 'body' => 5],
        'algorithm' => 'fuzzy',
        'accent_insensitive' => true,
        'unicode_normalize' => true,
    ],
],

Then use your custom preset:

Document::search('report')->preset('documents')->get();

Per-Model Customization

class Product extends Model
{
    use Searchable;

    protected array $searchable = [
        'columns' => [
            'title' => 10,
            'description' => 5,
            'sku' => 8,
        ],
        'algorithm' => 'fuzzy',
        'typo_tolerance' => 2,
        'stop_words' => ['the', 'a', 'an'],
        'synonyms' => [
            'laptop' => ['notebook', 'computer'],
        ],
        'accent_insensitive' => true,
    ];

    // Custom scoring logic
    public function getSearchScore($baseScore): float
    {
        // Boost featured products
        return $this->is_featured ? $baseScore * 1.5 : $baseScore;
    }
}

CLI Tools

Indexing Commands

# Index a model
php artisan fuzzy-search:index User

# Index with fresh rebuild
php artisan fuzzy-search:index User --fresh

# Index all searchable models
php artisan fuzzy-search:index --all

# Clear index
php artisan fuzzy-search:clear User

Benchmark Tools

# Benchmark search performance
php artisan fuzzy-search:benchmark User --term="john" --iterations=100

# Output:
# Algorithm: fuzzy
# Average time: 12.5ms
# Min: 8ms, Max: 25ms
# Queries/second: 80
# Memory usage: 2.1MB

Debug Commands

# Explain a search query
php artisan fuzzy-search:explain User --term="john"

# Output query analysis, patterns generated, scoring breakdown

Testing

# Run tests
composer test

# Run with coverage
composer test-coverage

# Run benchmarks
composer benchmark

Performance Tips

  1. Use Indexing for tables with 10k+ rows
  2. Enable Caching for repeated searches
  3. Limit Columns - only search relevant fields
  4. Use Simple Algorithm when typo tolerance isn't needed
  5. Set Max Patterns to prevent query explosion
User::search('john')
    ->useIndex()
    ->cache(60)
    ->searchIn(['name', 'email'])  // Not all columns
    ->maxPatterns(50)
    ->get();

Requirements

  • PHP 8.0 or higher
  • Laravel 9.x, 10.x, 11.x, or 12.x
  • Any supported database

License

MIT License. See LICENSE for more information.

Credits

Contributing

Contributions are welcome! Please see CONTRIBUTING for details.

About

Zero-config fuzzy search for Laravel with 8 algorithms, typo tolerance, relevance scoring, and scales to 10M records. No external dependencies.

Topics

Resources

License

Contributing

Stars

Watchers

Forks

Packages

No packages published

Languages