fab2s / searchable
Laravel searchable models based on FullText indexes with phonetic matching
Requires
- php: ^8.1
- ext-intl: *
- fab2s/strings: ^1.0
Requires (Dev)
- ext-pdo: *
- laravel/pint: ^1.27
- orchestra/testbench: ^8.0|^9.0|^10.0
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^10.0|^11.0
This package is auto-updated.
Last update: 2026-02-21 17:43:25 UTC
README
Add fulltext search to your Eloquent models in minutes — no external services, no Scout driver, just your existing database.
This package keeps things simple: it concatenates model fields into a single indexed column and uses native fulltext capabilities (MATCH...AGAINST on MySQL, tsvector/tsquery on PostgreSQL) for fast prefix-based search, ideal for autocomplete.
Why Searchable?
If you need fast autocomplete or simple search and already run MySQL/MariaDB or PostgreSQL, you don't need a separate search engine.
| Searchable | Laravel Scout + Driver | |
|---|---|---|
| Infrastructure | Your existing database | External service (Algolia, Meilisearch, Typesense, ...) |
| Setup | Add a trait, run one command | Install driver, configure credentials, manage process/service |
| Sync | Automatic on Eloquent save |
Queue workers, manual imports |
| Query integration | Standard Eloquent scopes & builder — composes with where, join, orderBy, etc. |
Separate ::search() API with limited query builder support |
| Phonetic matching | Built-in, pluggable algorithms (also provides typo tolerance) | Depends on the external service |
| Scalability | Performs well even with millions of rows thanks to single-column native fulltext indexes | Designed for very large-scale, multi-field search |
| Best for | Autocomplete, name/title/email search, up to millions of rows | Multi-field search, weighted ranking, facets, advanced typo tolerance |
Searchable is not a replacement for a dedicated search engine — it's a lightweight alternative for the many cases where one isn't needed. The single-column approach is what makes it fast: native fulltext indexes on one column scale well, whereas indexing many columns separately (especially on MySQL) is where dedicated engines pull ahead.
Requirements
- PHP 8.1+
- Laravel 10.x / 11.x / 12.x
- MySQL / MariaDB or PostgreSQL
ext-intlPHP extension
Installation
composer require fab2s/searchable
The service provider is auto-discovered.
Quick start
Implement SearchableInterface on your model, use the Searchable trait, and list the fields to index:
use fab2s\Searchable\SearchableInterface; use fab2s\Searchable\Traits\Searchable; class Contact extends Model implements SearchableInterface { use Searchable; protected $searchables = [ 'first_name', 'last_name', 'email', ]; }
Then run the artisan command to add the column and fulltext index:
php artisan searchable:enable
That's it. The searchable column is automatically populated on every save.
Choosing fields wisely: The quality of matching depends directly on which fields you index. This package is designed for fast, simple autocomplete — not complex full-text search. Keep
$searchablesfocused on the few fields users actually type into a search box (names, titles, emails). Adding large or numerous fields dilutes relevance and increases storage. If you need weighted fields, facets, or advanced ranking, consider a dedicated search engine instead.
Searching
The trait provides a search scope that handles everything automatically:
$results = Contact::search($request->input('q'))->get();
It composes with other query builder methods:
$results = Contact::search('john') ->where('active', true) ->limit(10) ->get();
Results are ordered by relevance (DESC) by default. Pass null to disable:
$results = Contact::search('john', null)->latest()->get();
The driver is detected automatically from the query's connection. The scope picks up the model's tsConfig and phonetic settings.
For IDE autocompletion, add a
@methodannotation to your model:/** * @method static Builder<static> search(string|array $search, ?string $order = 'DESC') */ class Contact extends Model implements SearchableInterface
Empty search terms
When the search input is empty or contains only operators/whitespace, the search scope is a no-op — no WHERE or ORDER BY clause is added. This means you can safely pass user input without checking for empty strings:
// Safe — returns all contacts (unfiltered) when $q is empty $results = Contact::search($request->input('q', '')) ->where('active', true) ->get();
Advanced usage with SearchQuery
For more control (table aliases in joins, custom field name), use SearchQuery directly:
use fab2s\Searchable\SearchQuery; $search = new SearchQuery('DESC', 'searchable', 'english', phonetic: true); $query = Contact::query(); $search->addMatch($query, $request->input('q'), 'contacts'); $results = $query->get();
This is particularly useful when searching across joined tables. The third argument to addMatch is a table alias that prefixes the searchable column, preventing ambiguity:
$search = new SearchQuery; $query = Contact::query() ->join('companies', 'contacts.company_id', '=', 'companies.id') ->select('contacts.*'); // search in contacts $search->addMatch($query, $request->input('q'), 'contacts'); // you could also search in companies with a second SearchQuery instance // (new SearchQuery)->addMatch($query, $request->input('q'), 'companies'); $results = $query->get();
Configuration
Every option can be set by declaring a property on your model. The trait picks them up automatically and falls back to sensible defaults when omitted:
| Property | Type | Default | Description |
|---|---|---|---|
$searchableField |
string |
'searchable' |
Column name for the searchable content |
$searchableFieldDbType |
string |
'string' |
Migration column type (string, text) |
$searchableFieldDbSize |
int |
500 |
Column size (applies to string type) |
$searchables |
array<string> |
[] |
Model fields to index |
$searchableTsConfig |
string |
'english' |
PostgreSQL text search configuration |
$searchablePhonetic |
bool |
false |
Enable phonetic matching |
$searchablePhoneticAlgorithm |
class-string<PhoneticInterface> |
— (metaphone) | Custom phonetic encoder class |
class Contact extends Model implements SearchableInterface { use Searchable; protected array $searchables = ['first_name', 'last_name', 'email']; protected string $searchableTsConfig = 'french'; protected bool $searchablePhonetic = true; protected int $searchableFieldDbSize = 1000; }
Each property has a corresponding getter method (getSearchableField(), getSearchableFieldDbType(), etc.) defined in SearchableInterface. You can override those methods instead if you need computed values.
Custom content
Override getSearchableContent() to control what gets indexed. The $additional parameter lets you inject extra data (decrypted fields, computed values, etc.):
public function getSearchableContent(string $additional = ''): string { $extra = implode(' ', [ $this->decrypt('phone'), $this->some_computed_value, ]); return parent::getSearchableContent($extra); }
PostgreSQL text search configuration
By default, PostgreSQL uses the english text search configuration. Set $searchableTsConfig to change it:
protected string $searchableTsConfig = 'french';
The search scope picks this up automatically. When using SearchQuery directly, pass the same value:
$search = new SearchQuery('DESC', 'searchable', 'french');
Phonetic matching
Enable phonetic matching to find results despite spelling variations (eg. "jon" matches "john", "smyth" matches "smith"). This uses PHP's metaphone() to append phonetic codes to the same searchable field — no extra column or extension needed.
protected bool $searchablePhonetic = true;
That's all — both storage and the search scope handle it automatically. Stored content becomes john smith jn sm0, and a search for jon produces the term jn which matches.
When using SearchQuery directly, pass the phonetic flag:
$search = new SearchQuery('DESC', 'searchable', 'english', phonetic: true);
Custom phonetic algorithm
The default metaphone() works well for English. For other languages, set $searchablePhoneticAlgorithm to any class implementing PhoneticInterface:
use fab2s\Searchable\Phonetic\PhoneticInterface; class MyEncoder implements PhoneticInterface { public static function encode(string $word): string { // your encoding logic } }
Then reference it on your model:
use fab2s\Searchable\Phonetic\Phonetic; class Contact extends Model implements SearchableInterface { use Searchable; protected array $searchables = ['first_name', 'last_name']; protected bool $searchablePhonetic = true; protected string $searchablePhoneticAlgorithm = Phonetic::class; }
The trait resolves the class to a closure internally — no method override needed.
When using SearchQuery directly, pass the encoder as a closure:
$search = new SearchQuery('DESC', 'searchable', 'french', phonetic: true, phoneticAlgorithm: Phonetic::encode(...));
Built-in French encoders
Two French phonetic algorithms are included, optimized PHP ports from Talisman (MIT):
| Class | Algorithm | Description |
|---|---|---|
Phonetic |
Phonetic Français | Comprehensive French phonetic algorithm by Edouard Berge. Handles ligatures, silent letters, nasal vowels, and many French-specific spelling rules. |
Soundex2 |
Soundex2 | French adaptation of Soundex. Simpler and faster than Phonetic, produces 4-character codes. |
Both implement PhoneticInterface and handle Unicode normalization (accents, ligatures like œ and æ) internally.
use fab2s\Searchable\Phonetic\Phonetic; use fab2s\Searchable\Phonetic\Soundex2; Phonetic::encode('jean'); // 'JAN' Soundex2::encode('dupont'); // 'DIPN'
Phonetic encoder benchmarks
Measured on a set of 520 French words, 1000 iterations each (PHP 8.4):
| Encoder | Per word | Throughput |
|---|---|---|
| metaphone | ~2 µs | ~500k/s |
| Soundex2 | ~35 µs | ~28k/s |
| Phonetic | ~51 µs | ~20k/s |
PHP's native metaphone() is a C extension and unsurprisingly the fastest. Both French encoders are pure PHP with extensive regex-based rule sets, yet fast enough for typical use — encoding 1000 words takes under 50ms.
Automatic setup after migrations
The package listens to Laravel's MigrationsEnded event and automatically runs searchable:enable after every successful up migration. This means:
- After
php artisan migrate, the searchable column and fulltext index are added to any new Searchable model. - After
php artisan migrate:fresh, they are recreated along with the rest of your schema. - Rollbacks (
down) and pretended migrations (--pretend) are ignored.
This is fully automatic — no configuration needed. If you need to re-index existing records, run the command manually with --index.
The Enable command
# Add searchable column + index to all models using the Searchable trait php artisan searchable:enable # Target a specific model php artisan searchable:enable --model=App/Models/Contact # Also (re)index existing records php artisan searchable:enable --model=App/Models/Contact --index # Scan a custom directory for models php artisan searchable:enable --root=app/Domain/Models
The command detects the database driver and creates the appropriate index:
- MySQL:
ALTER TABLE ... ADD FULLTEXT - PostgreSQL:
CREATE INDEX ... USING GIN(to_tsvector(...))
Adding Searchable to an existing model
You can add the Searchable feature to a model with pre-existing data at any time. After implementing SearchableInterface and using the Searchable trait, run the enable command with --index to set up the column, create the fulltext index, and populate it for all existing records:
php artisan searchable:enable --model=App/Models/Contact --index
You can also run it without --model to process all Searchable models at once. Indexing is optimized with batch processing to handle large tables efficiently.
When to re-index
The searchable column is automatically kept in sync on every Eloquent save. Manual re-indexing is only needed when:
- Adding Searchable to a model with existing data — existing rows have no searchable content yet.
- Changing
$searchables— after adding or removing fields from the index, existing rows still contain the old content. - Mass imports that bypass Eloquent — raw SQL inserts,
DB::insert(), or bulk imports that skip model events won't populate the searchable column.
In all these cases, run:
# re-index a specific model php artisan searchable:enable --model=App/Models/Contact --index # or re-index all Searchable models php artisan searchable:enable --index
Contributing
Contributions are welcome. Feel free to open issues and submit pull requests.
# fix code style composer fix # run tests composer test # run tests with coverage composer cov # static analysis (src, level 9) composer stan # static analysis (tests, level 5) composer stan-tests
License
Searchable is open-sourced software licensed under the MIT license.