{"id":17191653,"url":"https://github.com/fab2s/searchable","last_synced_at":"2026-02-27T22:12:42.654Z","repository":{"id":64262815,"uuid":"573370243","full_name":"fab2s/Searchable","owner":"fab2s","description":"Laravel searchable models based on FullText indexes","archived":false,"fork":false,"pushed_at":"2025-03-30T19:24:18.000Z","size":37,"stargazers_count":1,"open_issues_count":1,"forks_count":0,"subscribers_count":1,"default_branch":"main","last_synced_at":"2025-08-19T03:32:53.079Z","etag":null,"topics":["fulltext","laravel","search"],"latest_commit_sha":null,"homepage":"","language":"PHP","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/fab2s.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null}},"created_at":"2022-12-02T09:55:39.000Z","updated_at":"2024-07-04T16:26:38.000Z","dependencies_parsed_at":"2025-04-11T09:06:54.273Z","dependency_job_id":"c63ff3f6-c1fd-442f-bae6-8e207571a474","html_url":"https://github.com/fab2s/Searchable","commit_stats":null,"previous_names":[],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/fab2s/Searchable","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/fab2s%2FSearchable","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/fab2s%2FSearchable/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/fab2s%2FSearchable/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/fab2s%2FSearchable/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/fab2s","download_url":"https://codeload.github.com/fab2s/Searchable/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/fab2s%2FSearchable/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":273926306,"owners_count":25192316,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","status":"online","status_checked_at":"2025-09-06T02:00:13.247Z","response_time":2576,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["fulltext","laravel","search"],"created_at":"2024-10-15T01:26:59.051Z","updated_at":"2026-02-27T22:12:42.648Z","avatar_url":"https://github.com/fab2s.png","language":"PHP","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Searchable\n\n[![CI](https://github.com/fab2s/Searchable/actions/workflows/ci.yml/badge.svg)](https://github.com/fab2s/Searchable/actions/workflows/ci.yml)\n[![QA](https://github.com/fab2s/Searchable/actions/workflows/qa.yml/badge.svg)](https://github.com/fab2s/Searchable/actions/workflows/qa.yml)\n[![codecov](https://codecov.io/gh/fab2s/Searchable/graph/badge.svg?token=DKFT4Z9AML)](https://codecov.io/gh/fab2s/Searchable)\n[![PHPStan](https://img.shields.io/badge/PHPStan-level%209-brightgreen.svg?style=flat)](https://phpstan.org/)\n[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat)](http://makeapullrequest.com)\n[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)\n\n**Add fulltext search to your Eloquent models in minutes — no external services, no Scout driver, just your existing database.**\n\nThis 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.\n\n## Why Searchable?\n\nIf you need fast autocomplete or simple search and already run MySQL/MariaDB or PostgreSQL, you don't need a separate search engine.\n\n|  | Searchable | Laravel Scout + Driver |\n|---|---|---|\n| Infrastructure | Your existing database | External service (Algolia, Meilisearch, Typesense, ...) |\n| Setup | Add a trait, run one command | Install driver, configure credentials, manage process/service |\n| Sync | Automatic on Eloquent `save` | Queue workers, manual imports |\n| Query integration | Standard Eloquent scopes \u0026 builder — composes with `where`, `join`, `orderBy`, etc. | Separate `::search()` API with limited query builder support |\n| Phonetic matching | Built-in, pluggable algorithms (also provides typo tolerance) | Depends on the external service |\n| Scalability | Performs well even with millions of rows thanks to single-column native fulltext indexes | Designed for very large-scale, multi-field search |\n| Best for | Autocomplete, name/title/email search, up to millions of rows | Multi-field search, weighted ranking, facets, advanced typo tolerance |\n\nSearchable 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.\n\n## Requirements\n\n- PHP 8.1+\n- Laravel 10.x / 11.x / 12.x\n- MySQL / MariaDB or PostgreSQL\n- `ext-intl` PHP extension\n\n## Installation\n\n```shell\ncomposer require fab2s/searchable\n```\n\nThe service provider is auto-discovered.\n\n## Quick start\n\nImplement `SearchableInterface` on your model, use the `Searchable` trait, and list the fields to index:\n\n```php\nuse fab2s\\Searchable\\SearchableInterface;\nuse fab2s\\Searchable\\Traits\\Searchable;\n\nclass Contact extends Model implements SearchableInterface\n{\n    use Searchable;\n\n    protected $searchables = [\n        'first_name',\n        'last_name',\n        'email',\n    ];\n}\n```\n\nThen run the artisan command to add the column and fulltext index:\n\n```shell\nphp artisan searchable:enable\n```\n\nThat's it. The `searchable` column is automatically populated on every save.\n\n\u003e **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 `$searchables` focused 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.\n\n## Searching\n\nThe trait provides a `search` scope that handles everything automatically:\n\n```php\n$results = Contact::search($request-\u003einput('q'))-\u003eget();\n```\n\nIt composes with other query builder methods:\n\n```php\n$results = Contact::search('john')\n    -\u003ewhere('active', true)\n    -\u003elimit(10)\n    -\u003eget();\n```\n\nResults are ordered by relevance (DESC) by default. Pass `null` to disable:\n\n```php\n$results = Contact::search('john', null)-\u003elatest()-\u003eget();\n```\n\nThe driver is detected automatically from the query's connection. The scope picks up the model's `tsConfig` and `phonetic` settings.\n\n\u003e For IDE autocompletion, add a `@method` annotation to your model:\n\u003e ```php\n\u003e /**\n\u003e  * @method static Builder\u003cstatic\u003e search(string|array $search, ?string $order = 'DESC')\n\u003e  */\n\u003e class Contact extends Model implements SearchableInterface\n\u003e ```\n\n### Empty search terms\n\nWhen 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:\n\n```php\n// Safe — returns all contacts (unfiltered) when $q is empty\n$results = Contact::search($request-\u003einput('q', ''))\n    -\u003ewhere('active', true)\n    -\u003eget();\n```\n\n### Advanced usage with SearchQuery\n\nFor more control (table aliases in joins, custom field name), use `SearchQuery` directly:\n\n```php\nuse fab2s\\Searchable\\SearchQuery;\n\n$search = new SearchQuery('DESC', 'searchable', 'english', phonetic: true);\n$query  = Contact::query();\n\n$search-\u003eaddMatch($query, $request-\u003einput('q'), 'contacts');\n\n$results = $query-\u003eget();\n```\n\nThis is particularly useful when searching across joined tables. The third argument to `addMatch` is a table alias that prefixes the searchable column, preventing ambiguity:\n\n```php\n$search = new SearchQuery;\n$query  = Contact::query()\n    -\u003ejoin('companies', 'contacts.company_id', '=', 'companies.id')\n    -\u003eselect('contacts.*');\n\n// search in contacts\n$search-\u003eaddMatch($query, $request-\u003einput('q'), 'contacts');\n\n// you could also search in companies with a second SearchQuery instance\n// (new SearchQuery)-\u003eaddMatch($query, $request-\u003einput('q'), 'companies');\n\n$results = $query-\u003eget();\n```\n\n## Configuration\n\nEvery 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:\n\n| Property                | Type           | Default         | Description                              |\n|-------------------------|----------------|-----------------|------------------------------------------|\n| `$searchableField`      | `string`       | `'searchable'`  | Column name for the searchable content   |\n| `$searchableFieldDbType`| `string`       | `'string'`      | Migration column type (`string`, `text`)  |\n| `$searchableFieldDbSize`| `int`          | `500`           | Column size (applies to `string` type)   |\n| `$searchables`          | `array\u003cstring\u003e`| `[]`            | Model fields to index                    |\n| `$searchableTsConfig`   | `string`       | `'english'`     | PostgreSQL text search configuration     |\n| `$searchablePhonetic`   | `bool`         | `false`         | Enable phonetic matching                 |\n| `$searchablePhoneticAlgorithm` | `class-string\u003cPhoneticInterface\u003e` | — (metaphone) | Custom phonetic encoder class |\n\n```php\nclass Contact extends Model implements SearchableInterface\n{\n    use Searchable;\n\n    protected array $searchables = ['first_name', 'last_name', 'email'];\n    protected string $searchableTsConfig = 'french';\n    protected bool $searchablePhonetic = true;\n    protected int $searchableFieldDbSize = 1000;\n}\n```\n\nEach property has a corresponding getter method (`getSearchableField()`, `getSearchableFieldDbType()`, etc.) defined in `SearchableInterface`. You can override those methods instead if you need computed values.\n\n### Custom content\n\nOverride `getSearchableContent()` to control what gets indexed. The `$additional` parameter lets you inject extra data (decrypted fields, computed values, etc.):\n\n```php\npublic function getSearchableContent(string $additional = ''): string\n{\n    $extra = implode(' ', [\n        $this-\u003edecrypt('phone'),\n        $this-\u003esome_computed_value,\n    ]);\n\n    return parent::getSearchableContent($extra);\n}\n```\n\n### PostgreSQL text search configuration\n\nBy default, PostgreSQL uses the `english` text search configuration. Set `$searchableTsConfig` to change it:\n\n```php\nprotected string $searchableTsConfig = 'french';\n```\n\nThe `search` scope picks this up automatically. When using `SearchQuery` directly, pass the same value:\n\n```php\n$search = new SearchQuery('DESC', 'searchable', 'french');\n```\n\n### Phonetic matching\n\nEnable 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.\n\n```php\nprotected bool $searchablePhonetic = true;\n```\n\nThat'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.\n\nWhen using `SearchQuery` directly, pass the phonetic flag:\n\n```php\n$search = new SearchQuery('DESC', 'searchable', 'english', phonetic: true);\n```\n\n### Custom phonetic algorithm\n\nThe default `metaphone()` works well for English. For other languages, set `$searchablePhoneticAlgorithm` to any class implementing `PhoneticInterface`:\n\n```php\nuse fab2s\\Searchable\\Phonetic\\PhoneticInterface;\n\nclass MyEncoder implements PhoneticInterface\n{\n    public static function encode(string $word): string\n    {\n        // your encoding logic\n    }\n}\n```\n\nThen reference it on your model:\n\n```php\nuse fab2s\\Searchable\\Phonetic\\Phonetic;\n\nclass Contact extends Model implements SearchableInterface\n{\n    use Searchable;\n\n    protected array $searchables = ['first_name', 'last_name'];\n    protected bool $searchablePhonetic = true;\n    protected string $searchablePhoneticAlgorithm = Phonetic::class;\n}\n```\n\nThe trait resolves the class to a closure internally — no method override needed.\n\nWhen using `SearchQuery` directly, pass the encoder as a closure:\n\n```php\n$search = new SearchQuery('DESC', 'searchable', 'french', phonetic: true, phoneticAlgorithm: Phonetic::encode(...));\n```\n\n### Built-in French encoders\n\nTwo French phonetic algorithms are included, optimized PHP ports from [Talisman](https://github.com/Yomguithereal/talisman) (MIT):\n\n| Class | Algorithm | Description |\n|-------|-----------|-------------|\n| `Phonetic` | [Phonetic Français](http://www.roudoudou.com/phonetic.php) | Comprehensive French phonetic algorithm by Edouard Berge. Handles ligatures, silent letters, nasal vowels, and many French-specific spelling rules. |\n| `Soundex2` | [Soundex2](http://sqlpro.developpez.com/cours/soundex/) | French adaptation of Soundex. Simpler and faster than `Phonetic`, produces 4-character codes. |\n\nBoth implement `PhoneticInterface` and handle Unicode normalization (accents, ligatures like œ and æ) internally.\n\n```php\nuse fab2s\\Searchable\\Phonetic\\Phonetic;\nuse fab2s\\Searchable\\Phonetic\\Soundex2;\n\nPhonetic::encode('jean');   // 'JAN'\nSoundex2::encode('dupont'); // 'DIPN'\n```\n\n### Phonetic encoder benchmarks\n\nMeasured on a set of 520 French words, 1000 iterations each (PHP 8.4):\n\n| Encoder    | Per word | Throughput |\n|------------|----------|------------|\n| metaphone  | ~2 µs   | ~500k/s    |\n| Soundex2   | ~35 µs  | ~28k/s     |\n| Phonetic   | ~51 µs  | ~20k/s     |\n\nPHP'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.\n\n## Automatic setup after migrations\n\nThe package listens to Laravel's `MigrationsEnded` event and automatically runs `searchable:enable` after every successful `up` migration. This means:\n\n- After `php artisan migrate`, the searchable column and fulltext index are added to any new Searchable model.\n- After `php artisan migrate:fresh`, they are recreated along with the rest of your schema.\n- Rollbacks (`down`) and pretended migrations (`--pretend`) are ignored.\n\nThis is fully automatic — no configuration needed. If you need to re-index existing records, run the command manually with `--index`.\n\n## The Enable command\n\n```shell\n# Add searchable column + index to all models using the Searchable trait\nphp artisan searchable:enable\n\n# Target a specific model\nphp artisan searchable:enable --model=App/Models/Contact\n\n# Also (re)index existing records\nphp artisan searchable:enable --model=App/Models/Contact --index\n\n# Scan a custom directory for models\nphp artisan searchable:enable --root=app/Domain/Models\n```\n\nThe command detects the database driver and creates the appropriate index:\n- **MySQL**: `ALTER TABLE ... ADD FULLTEXT`\n- **PostgreSQL**: `CREATE INDEX ... USING GIN(to_tsvector(...))`\n\n### Adding Searchable to an existing model\n\nYou 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:\n\n```shell\nphp artisan searchable:enable --model=App/Models/Contact --index\n```\n\nYou can also run it without `--model` to process all Searchable models at once. Indexing is optimized with batch processing to handle large tables efficiently.\n\n### When to re-index\n\nThe searchable column is automatically kept in sync on every Eloquent `save`. Manual re-indexing is only needed when:\n\n- **Adding Searchable to a model with existing data** — existing rows have no searchable content yet.\n- **Changing `$searchables`** — after adding or removing fields from the index, existing rows still contain the old content.\n- **Mass imports that bypass Eloquent** — raw SQL inserts, `DB::insert()`, or bulk imports that skip model events won't populate the searchable column.\n\nIn all these cases, run:\n\n```shell\n# re-index a specific model\nphp artisan searchable:enable --model=App/Models/Contact --index\n\n# or re-index all Searchable models\nphp artisan searchable:enable --index\n```\n\n## Contributing\n\nContributions are welcome. Feel free to open issues and submit pull requests.\n\n```shell\n# fix code style\ncomposer fix\n\n# run tests\ncomposer test\n\n# run tests with coverage\ncomposer cov\n\n# static analysis (src, level 9)\ncomposer stan\n\n# static analysis (tests, level 5)\ncomposer stan-tests\n```\n\n## License\n\n`Searchable` is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Ffab2s%2Fsearchable","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Ffab2s%2Fsearchable","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Ffab2s%2Fsearchable/lists"}