thehome/statamic-elasticsearch
Statamic addon that connects your site to Elasticsearch, letting you index entries and other content and run fast, flexible full-text searches. Configure indexes and queries, then surface relevant results in templates for scalable site search.
1. Install the package
composer require thehome/statamic-elasticsearch
The package is a Statamic search driver — it slots into Statamic's existing search abstraction (config/search.php) rather than exposing its own query API. That single fact should shape everything you do with it.
2. Have Elasticsearch reachable before you install
Statamic will boot fine without a cluster and fail at query time, which is a miserable first-run experience. Bring up the cluster first:
# docker-compose.yml (snippet)
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:7.17.18
environment:
- discovery.type=single-node
- xpack.security.enabled=false
- ES_JAVA_OPTS=-Xms512m -Xmx512m
ports: ["9200:9200"]
3. Publish and inspect the config
php artisan vendor:publish --provider="TheHome\StatamicElasticsearch\ServiceProvider"
Then read three things, in this order:
ServiceProvider — it will call Search::extend('elasticsearch', ...), which tells you the exact driver key to useIndex/Driver implementation) — this is where result marshalling and any query-DSL extension points live4. Point Statamic at it
// config/search.php
return [
'driver' => 'elasticsearch',
'indexes' => [
'articles' => [
'driver' => 'elasticsearch',
'searchables' => 'collection:articles',
'fields' => ['title', 'text', 'teaser'],
],
],
];
5. Build the index and prove it works
php artisan statamic:search:update articles # `search:update` on older Statamic
First use case: replace Statamic's default local driver for a content-heavy site (a blog, docs, or a large product catalogue) where you want relevance ranking, fuzziness, and the ability to search across tens of thousands of entries without loading them into PHP. If your site has 200 entries, the local driver is genuinely better — do not reach for this package out of novelty.
Query through Statamic's API, not a bespoke one. The idiomatic call is identical to the local/algolia drivers, which is the whole point:
use Statamic\Facades\Search;
$results = Search::index('articles')
->search($request->input('q'))
->get(); // Collection of Search\Result
Because it obeys the standard contract, you can develop locally against the local driver and swap to elasticsearch in staging/production. Keep that parity in mind when reviewing results — pagination, Result shape, and augmentation behaviour should be identical.
Keep the index fresh with events, not cron. Statamic already updates the search index on EntrySaved/EntryDeleted for searchable items. Verify that the package's write path is wired to that; if it is only rebuilt by search:update, it will go stale the moment an editor hits publish. A safe belt-and-braces pattern:
// app/Providers/EventServiceProvider.php
Event::listen(\Statamic\Events\EntrySaved::class, function ($event) {
if ($event->entry->collection()->handle() === 'articles') {
Search::index('articles')->insert($event->entry);
}
});
Rebuilds are a deploy step, not a request step. Schedule full rebuilds off-hours and use aliases for zero-downtime reindexing:
# build into articles_20240601_v1, flip the `articles` alias, drop the old index
curl -X POST "localhost:9200/_aliases" -H 'Content-Type: application/json' -d'
{ "actions": [
{ "remove": { "index": "articles_20240501_v1", "alias": "articles" } },
{ "add": { "index": "articles_20240601_v1", "alias": "articles" } }
]}'
Escape the abstraction only when you need it. Faceted or filtered search (price ranges, date buckets, multi_match across boosted fields) often isn't expressible through Search::index()->search(). Two options, in order of preference: bind a custom index class that extends the package's driver and adds a raw()/query() method, or resolve the underlying Elasticsearch client from the container and hit the _search endpoint directly. Keeping that escape hatch confined to one service class (App\Search\ArticleSearch) prevents Elasticsearch DSL from smearing across controllers.
Multi-site / multi-locale: run one index per site (articles_en, articles_da) rather than stuffing locales into one document. Statamic's Searchable items carry locale, and per-locale indices make ranking and field mappings behave predictably.
Testing: point the test suite at a real ES container via Testcontainers or a CI service, and always use a per-run index prefix — otherwise parallel test runs will clobber each other's index and produce phantom failures.
Maintenance risk is the headline. Last release August 2023, four stars, zero dependents. That release predates Statamic 5 and the Elasticsearch 8 client defaults. Before committing: check composer.json constraints against your statamic/cms version, and budget a fork. This is a vendored dependency you may inherit, not a community-backed one. Pin it exactly and add a note to your upgrade runbook.
Index name collisions will bite you. If the config has no environment-derived prefix, a local dev machine and staging can share an index and silently destroy each other's data. Force it:
'prefix' => env('APP_ENV') . '_' . env('ELASTICSEARCH_PREFIX'),
Mapping conflicts are permanent until reindex. ES infers types on first write. The day you change teaser from a short string to a rich-text field, you get a mapper_parsing_exception and no results, with a stack trace that points at Statamic, not Elasticsearch. Always check mappings before touching fields:
curl "localhost:9200/articles/_mapping?pretty"
Debugging checklist, in order:
curl localhost:9200/_cat/indices?v — does the index exist, how many docs?curl localhost:9200/articles/_count — right document count? Zero means the write path is broken, not the query.storage/logs/laravel.log — many drivers swallow transport exceptions and return empty result sets, so "no results" frequently means "connection refused."_search with the same body) to see what DSL is actually sent.yellow with replicas on a single node is normal; set number_of_replicas: 0 in dev or health checks will nag forever.Elasticsearch 8 changed the security defaults. http://localhost:9200 with no auth no longer works out of the box — you need TLS + an API key, or an explicit xpack.security.enabled=false. A 2023-era package will almost certainly assume ES 7 semantics. If you're on ES 8, expect to configure the client (CA cert, api_key) yourself.
search:update is O(n) over every entry and will hammer PHP memory on large collections. Watch php -d memory_limit=-1 and the package's chunk size; if there's no chunking, cap it with --collection runs per batch and build a shell loop.
Elasticsearch is never the source of truth. Treat the index as a disposable projection of the database. Any code that reads content only from search results will lose data during a rebuild window. Hydrate results back into entries/entries-by-ID and fall back to the database when the index returns nothing.
Extension points worth knowing: the driver registration (Search::extend), the published config, and the Searchable interface on your own models / custom Entry subclasses — override toSearchableArray() to control exactly what gets indexed, including computed fields (reading time, category names, related-item titles) that never exist as raw content fields.
How can I help you explore Laravel packages today?