jeroen-g/explorer
A Laravel Scout driver for Elasticsearch and OpenSearch. Index and search Eloquent models with configurable mappings, analyzers, and settings, plus support for queues, bulk indexing, and advanced queries—ideal for scalable full‑text search.
Installation:
composer require jeroen-g/explorer
Publish the config:
php artisan vendor:publish --provider="JeroenG\Explorer\ExplorerServiceProvider" --tag="config"
Configure Elasticsearch:
Update config/explorer.php with your Elasticsearch connection details (e.g., Docker, cloud, or local instance). Example:
'connections' => [
'default' => [
'hosts' => [
'http://elasticsearch:9200' // Docker example
],
'username' => env('ELASTIC_USERNAME'),
'password' => env('ELASTIC_PASSWORD'),
],
],
Define an Index:
Create a model and define its Elasticsearch index in config/explorer.php:
'indices' => [
'posts' => [
'settings' => [
'number_of_shards' => 1,
'number_of_replicas' => 0,
],
'mappings' => [
'properties' => [
'title' => ['type' => 'text'],
'content' => ['type' => 'text'],
'published_at' => ['type' => 'date'],
],
],
],
],
Update Scout Configuration:
In config/scout.php, set the driver to explorer:
'driver' => env('SCOUT_DRIVER', 'explorer'),
First Search:
Use Scout’s search() method in your model:
$results = Post::search('laravel')->get();
Or leverage Explorer’s advanced query builder:
use JeroenG\Explorer\Query\Builder;
$query = Builder::for(Post::class)
->where('title', 'like', 'laravel')
->orderBy('published_at', 'desc')
->paginate(10);
$results = $query->get();
Initialize Index: Run the index update command:
php artisan scout:import "App\Models\Post"
Or use the Explorer-specific command for more control:
php artisan explorer:update-index posts
Define an Explorer class for a DataTable (e.g., PostExplorer):
use JeroenG\Explorer\Explorer;
class PostExplorer extends Explorer
{
public function query()
{
return Post::query();
}
public function columns()
{
return [
'id' => 'ID',
'title' => 'Title',
'published_at' => 'Published At',
];
}
public function filters()
{
return [
'search' => ['type' => 'text', 'label' => 'Search'],
'published_after' => ['type' => 'date', 'label' => 'Published After'],
];
}
public function applyFilters($query, $filters)
{
if (isset($filters['search'])) {
$query->where('title', 'like', "%{$filters['search']}%");
}
if (isset($filters['published_after'])) {
$query->where('published_at', '>=', $filters['published_after']);
}
}
}
Use it in a controller:
public function index(Request $request)
{
$explorer = new PostExplorer();
$results = $explorer->query()
->search($request->search)
->filter('published_after', $request->published_after)
->paginate($request->per_page);
return response()->json($results);
}
Basic Search:
$results = Post::search('query')->get();
Equivalent to:
$results = Builder::for(Post::class)
->where('title', 'like', 'query')
->orWhere('content', 'like', 'query')
->get();
Advanced Filtering: Use Explorer’s query builder for complex constraints:
$query = Builder::for(Post::class)
->where('published_at', '>=', '2023-01-01')
->whereNotIn('author_id', [1, 2])
->where('tags', 'contains', 'laravel')
->orderBy('views', 'desc')
->paginate(20);
Aggregations:
Group results by a field (e.g., category):
$results = Builder::for(Post::class)
->termsAggregation('categories', 'category')
->get();
Output includes aggregated buckets:
{
"aggregations": {
"categories": {
"buckets": [
{"key": "laravel", "doc_count": 42},
{"key": "php", "doc_count": 25}
]
}
}
}
Nested Aggregations: For nested objects (e.g., comments on posts):
$results = Builder::for(Post::class)
->nestedAggregation('comments', 'comments', [
'terms' => ['field' => 'comments.author']
])
->get();
Custom Sorting:
$query = Builder::for(Post::class)
->orderBy('title', 'asc')
->orderBy('published_at', 'desc')
->setMissing('published_at', 'last'); // Handle missing fields
Hybrid Search (SQL + Elasticsearch):
Use Scout’s search() for full-text queries, then join with SQL for exact matches:
$posts = Post::where('author_id', auth()->id())
->where(function ($query) {
$query->where('title', 'like', '%query%')
->orWhere('content', 'like', '%query%');
})
->orWhereHas('searchable', function ($query) {
$query->where('body', 'like', '%query%');
})
->get();
Real-Time Updates: Use Scout’s model observers to sync changes to Elasticsearch:
Post::observe(Scoutable::class);
For bulk updates, queue the scout:import command:
Post::chunk(100, function ($posts) {
Post::updateScoutModels($posts);
});
Index Management:
php artisan explorer:update-index postsphp artisan scout:delete-index postsphp artisan scout:flush "App\Models\Post"Testing: Use the fake Elasticsearch responses:
use JeroenG\Explorer\Testing\Fakes\FakeElasticsearch;
public function test_search()
{
FakeElasticsearch::fake([
'posts' => [
['title' => 'Test Post', 'content' => 'Lorem ipsum']
]
]);
$results = Post::search('test')->get();
$this->assertCount(1, $results);
}
Custom Mappings:
Extend the default mappings in config/explorer.php:
'indices' => [
'posts' => [
'mappings' => [
'properties' => [
'title' => [
'type' => 'text',
'analyzer' => 'english',
'fields' => [
'raw' => ['type' => 'keyword']
]
],
'content' => ['type' => 'text'],
'views' => ['type' => 'integer'],
]
]
]
]
Logging:
Enable PSR-3 logging in config/explorer.php:
'logging' => [
'enabled' => true,
'channel' => 'single',
'level' => 'debug',
],
Create dedicated Explorer classes for complex tables (e.g., admin panels):
class UserExplorer extends Explorer
{
public function query()
{
return User::with(['roles', 'posts']);
}
public function columns()
{
return [
'id' => 'ID',
'name' => 'Name',
'email' => 'Email',
'roles' => 'Roles',
'post_count' => 'Posts',
];
}
public function filters()
{
return [
'search' => ['type' => 'text', 'label' => 'Search'],
'role' => ['type' => 'select', 'label' => 'Role', 'options' => Role::pluck('name', 'id')],
'active' => ['type' => 'boolean', 'label' => 'Active'],
How can I help you explore Laravel packages today?