adriballa/symfony-search-bundle
Symfony bundle that abstracts Elasticsearch: define indexes with two PHP classes, get auto-generated routes for index/document CRUD, validation, and a powerful search API (full-text, filters, sorting, pagination, aggregations). Optional client interfaces for programmatic use.
Installation: Add the package via Composer:
composer require vendor/package-name
Publish the package config (if needed) with:
php artisan vendor:publish --provider="Vendor\PackageName\PackageServiceProvider"
Define Indexes: Create two classes per index:
IndexDefinitionInterface (e.g., ProductIndexDefinition) to define schema and settings.IndexMappingInterface (e.g., ProductIndexMapping) to specify field mappings (e.g., text, keyword, date).Example:
class ProductIndexDefinition implements IndexDefinitionInterface {
public function getName(): string { return 'products'; }
public function getSettings(): array { return ['number_of_shards' => 1]; }
}
Register Indexes: Bind your index definitions to the container in a service provider:
$this->app->bind(IndexDefinitionInterface::class, ProductIndexDefinition::class);
Run Migrations: Use the built-in CLI command to create indexes:
php artisan elastic:index:create ProductIndexDefinition
First Query: Index a document and search:
// Index a document
$client = app(DocumentClientInterface::class);
$client->index('products', $productData);
// Search
$searchClient = app(SearchClientInterface::class);
$results = $searchClient->search('products', 'query', [
'filters' => ['category' => 'electronics'],
'sort' => ['price' => 'asc']
]);
Bulk Indexing: Use the DocumentClientInterface to index multiple documents at once:
$client->bulkIndex('products', [$doc1, $doc2, $doc3]);
CRUD Operations:
DocumentClientInterface.$client->update('products', $id, ['price' => 99.99]);
$client->delete('products', $id);
// or
$client->deleteByQuery('products', ['category' => 'electronics']);
Search Patterns:
$results = $searchClient->search('products', 'laptop', ['page' => 2, 'perPage' => 10]);
$results = $searchClient->search('products', '', [
'filters' => [
new ExactMatch('category', 'electronics'),
new Range('price', ['gte' => 500])
]
]);
$results = $searchClient->search('products', '', [
'aggregations' => ['categories' => ['terms' => ['field' => 'category']]]
]);
Lifecycle Management:
$indexClient = app(IndexClientInterface::class);
$indexClient->create('products'); // Uses your IndexDefinitionInterface
$indexClient->delete('products');
IndexClientInterface to rebuild an index from a data source (e.g., database).Validation-Driven Development:
IndexMappingInterface to enforce document structure:
class ProductIndexMapping implements IndexMappingInterface {
public function getMappings(): array {
return [
'properties' => [
'name' => ['type' => 'text', 'analyzer' => 'english'],
'price' => ['type' => 'float'],
'category' => ['type' => 'keyword']
]
];
}
}
Laravel Eloquent:
class ProductObserver {
public function saved(Product $product) {
$client = app(DocumentClientInterface::class);
$client->index('products', $product->toArray());
}
}
Product::observe(ProductObserver::class);
API Resources:
JsonResource to include Elasticsearch metadata:
public function toArray($request) {
return [
'id' => $this->id,
'elasticsearch_id' => $this->elasticsearch_id,
'name' => $this->name,
];
}
Caching:
$cacheKey = 'products_search_' . md5($query);
return Cache::remember($cacheKey, now()->addMinutes(5), function() use ($searchClient, $query) {
return $searchClient->search('products', $query);
});
Testing:
IndexClientInterface to reset indexes in tests:
public function tearDown(): void {
$indexClient = app(IndexClientInterface::class);
$indexClient->delete('products_test');
parent::tearDown();
}
Validation Errors:
errors array with field-specific issues.IndexMappingInterface matches your data structure. Use getMappings() to define required fields:
'name' => ['type' => 'text', 'required' => true]
Index Naming Conflicts:
products_${date}) unless explicitly managed, as the package assumes static names for lifecycle operations.products_v1) and update mappings manually if the schema evolves.Bulk Operations Timeouts:
bulk API limits are hit.http.max_content_length in elasticsearch.yml.Case Sensitivity in Keyword Fields:
category) are case-sensitive by default. Use normalizer or lowercase in mappings for case-insensitive searches:
'category' => [
'type' => 'keyword',
'normalizer' => 'lowercase'
]
Pagination Offsets:
page=1000) with from/size queries, as Elasticsearch may return incomplete or slow results.search_after for deep pagination or implement cursor-based pagination in your application.Enable Logging:
'logging' => [
'enabled' => true,
'channel' => 'single',
],
storage/logs/laravel.log for query details.Inspect Index Mappings:
IndexClientInterface to fetch mappings for debugging:
$mappings = $indexClient->getMapping('products');
Test Locally with Docker:
docker run -p 9200:9200 -e "discovery.type=single-node" docker.elastic.co/elasticsearch/elasticsearch:7.15.0
http://localhost:9200.Handle Rate Limiting:
try {
$client->index('products', $data);
} catch (RateLimitExceededException $e) {
sleep(1);
retry();
}
Custom Filters:
FilterInterface to create reusable filters (e.g., IpRangeFilter):
class IpRangeFilter implements FilterInterface {
public function __construct(private string $field, private string $ipRange) {}
public function toArray(): array {
return ['range' => [$this->field => ['gte' => $this->ipRange]]];
}
}
Field Types:
FieldTypeInterface and registering them in the service provider:
$this->app->bind(FieldTypeInterface::class, CustomFieldType::class);
Search Highlighting:
How can I help you explore Laravel packages today?