directorytree/opensearch-scout-driver
An OpenSearch driver for Laravel Scout that indexes, deletes, flushes, and searches your models through the configured OpenSearch client. Includes publishable client and Scout configs, optional document refreshing, and cursor pagination for deep, stable sorts.
Install the adapter and its client dependency:
composer require directorytree/opensearch-scout-driver
This pulls in directorytree/opensearch-client, which owns the actual HTTP transport, credentials, retries, and timeouts. You are working with two layers, not one.
Publish configs in the right order:
php artisan vendor:publish --provider="Laravel\Scout\ScoutServiceProvider"
php artisan vendor:publish --provider="DirectoryTree\OpenSearchClient\OpenSearchClientServiceProvider"
php artisan vendor:publish --provider="DirectoryTree\OpenSearchScoutDriver\OpenSearchScoutServiceProvider"
Flip the driver in config/scout.php (or .env):
'driver' => env('SCOUT_DRIVER', 'opensearch'),
Point the client at your cluster in config/opensearch-client.php:
'default' => env('OPENSEARCH_CONNECTION', 'default'),
'connections' => [
'default' => [
'hosts' => [env('OPENSEARCH_HOST', 'localhost:9200')],
],
],
Driver-level behavior lives in config/opensearch-scout.php:
'refresh_documents' => env('OPENSEARCH_SCOUT_REFRESH_DOCUMENTS', false),
First use case: bring up a local OpenSearch container (docker run -p 9200:9200 -e "discovery.type=single-node" opensearchproject/opensearch:latest), add Searchable to one small model, run php artisan scout:import "App\Models\Post", then try:
$posts = Post::search('laravel')->get();
If that round-trips on a local container, the adapter is wired correctly. Everything after that is index design and relevance tuning, not glue code.
Where to look first in the source: the Engine class. The README is thin; the implementation is small enough to read in one sitting, and it tells you exactly which Scout features (e.g. where(), shouldBeSearchable(), soft deletes, bulk partial-failure reporting) are actually supported before you rely on them.
Model integration — plain Scout, no OpenSearch-specific code. Keep models boring so the driver stays swappable:
class Post extends Model
{
use Searchable;
public function searchableAs(): string
{
return 'posts';
}
public function toSearchableArray(): array
{
return [
'id' => $this->id,
'title' => $this->title,
'body' => $this->body,
'published_at' => $this->published_at?->toIso8601String(),
'status' => $this->status,
];
}
public function shouldBeSearchable(): bool
{
return $this->status === 'published';
}
}
Because the driver translates Scout builders into OpenSearch requests, the model shape and the index mapping are the two levers you actually control. Design toSearchableArray() with your mapping (keyword vs text, date formats) in mind.
Scout's queue path is your friend. Configure SCOUT_QUEUE=true in production so indexing is offloaded. In local/dev, set OPENSEARCH_SCOUT_REFRESH_DOCUMENTS=true so documents become searchable immediately after save — this is a development convenience, not a production setting (see Gotchas).
Deep pagination via cursorPaginate. Added in v1.1.0 and mapped to OpenSearch's search_after. It requires an explicit, stable sort with a unique tie-breaker as the final clause:
$posts = Post::search('laravel')
->orderBy('published_at', 'desc')
->orderBy('id', 'desc') // unique tie-breaker, ideally the model key
->cursorPaginate(25);
The returned object is Laravel's standard CursorPaginator, so next_cursor / prev_cursor and your existing Blade/Inertia pagination components keep working. Treat the tie-breaker as a hard contract enforced in code review for any surface that uses cursor pagination.
Filtering with Scout where() clauses. Scout where() on indexed fields translates into OpenSearch filters. For anything beyond the basics, keep a custom builder that extends Scout's builder and returns a typed request payload — v1.0.0 explicitly advertises "typed search request payload support for custom builders," so this is the intended extension seam rather than a hack.
Index lifecycle is yours, not the package's. The adapter does not manage mappings, analyzers, aliases, or reindex. The day-to-day pattern that works well:
resources/opensearch/posts.json.posts_v2) plus an alias (posts) pointing at it.scout:import into the new index, validate, then flip the alias atomically.Rollback is a config swap. Because this is a Scout driver, reverting to database, collection, or another engine is a one-line .env change plus a config:cache. Structure your rollouts so that fact stays true — no OpenSearch-specific query code leaking into controllers.
Keep operations in version control. Docker-compose (or local run scripts) for the cluster, the mapping files, the scout:import and scout:flush commands, and the cutover runbook. The driver install is an afternoon; the reproducible index lifecycle is the actual deliverable.
Two packages, two configs, two providers. The most common install mistake is publishing only the Scout and driver configs and missing OpenSearchClientServiceProvider. If config/opensearch-client.php does not exist, nothing connects. Publish both.
refresh_documents is a trap in production. Setting OPENSEARCH_SCOUT_REFRESH_DOCUMENTS=true forces an index refresh after each write, which is exactly what you want locally and exactly what will crush index throughput on a real cluster. Leave it false in production and rely on OpenSearch's normal refresh interval (typically 1s), or refresh explicitly where eventual consistency is unacceptable.
Cursor pagination silently breaks without a stable sort. OpenSearch only returns reusable search_after values for sorted searches, and the driver will not stop you from issuing a query without an explicit sort. If you forget the tie-breaker, you can get silently skipped or duplicated rows as documents shift. Enforce a unique final sort (model key) in every code path that calls cursorPaginate.
AWS SigV4 / IAM is unproven. The README documents hosts only — no signing config. If your target is Amazon OpenSearch Service with IAM auth, verify this in the spike before committing. The realistic fallback is request-signing middleware in front of the OpenSearch client, or an IAM-authenticated proxy. That is a design decision, not a config flag.
The README does not document the version matrix. Check composer.json for the supported Laravel / Scout / PHP versions before upgrading your app, and check the OpenSearch cluster version you are targeting. This is a new package (v1.1.0 at time of writing) with a small maintainer footprint.
Bulk partial failures may not surface loudly. When scout:import indexes many documents, per-item failures inside a bulk response are easy to miss. Verify behavior in the Engine source and, if needed, wrap imports in a command that inspects the raw response. Do not assume a zero exit code means every document landed.
shouldBeSearchable() returning false does not retroactively delete. Scout skips indexing, but existing documents stay in the index. When a model transitions to a non-searchable state, ensure the document is removed (e.g. by implementing the removal path you rely on and testing it), otherwise stale hits linger.
Soft deletes need explicit validation. Whether delete() soft-deletes or removes the document from the index is a behavior worth confirming against the Engine source for your Scout version, since it affects what users see in search results.
Extension points to reach for, in order of preference:
toSearchableArray() and searchableAs() — cheapest, driver-agnostic.Builder returning a typed request payload — the intended seam for advanced queries.Engine — this package is small; treat a fork as a plausible outcome, not a failure. Budget for reading it either way.When debugging, reach for the raw client first. Because the driver is a thin translation layer, most surprises are actually mapping or query-DSL problems. curl or the OpenSearch Dev Tools console against _search / _cat/indices will tell you far more than dumping the Scout builder.
How can I help you explore Laravel packages today?