aternovtsii/search-bundle
Aternovtsii Search Bundle is a lightweight search package for PHP applications. It provides a simple way to index content and run flexible queries, with easy configuration and integration into existing projects. Designed for developers who need straightforward search with minimal setup.
Install the Package Add the package via Composer in a Laravel 10+ project:
composer require aternovtsii/search-bundle
Note: If using Laravel <10, pin Symfony dependencies to avoid conflicts (e.g., symfony/dependency-injection:^7.4).
Note (v1.4.0+): The new EXISTS operator and null filter values require ^1.4.0. Check your lock file before using them — older versions silently ignore unknown operators rather than throwing.
Configure the Bundle Publish the bundle's configuration:
php artisan vendor:publish --provider="ATernovtsii\SearchBundle\SearchBundle" --tag="config"
Edit config/search.php to define:
enabled: true (default).client: elasticsearch or opensearch.index_name: Your Elastic/OpenSearch index name.hosts: Array of search engine URLs (e.g., ['http://localhost:9200']).default_locale: en (or your app's default).Enable Search for an Entity
Annotate a Doctrine entity (e.g., Post) with searchable fields:
use ATernovtsii\SearchBundle\Annotation\Searchable;
use ATernovtsii\SearchBundle\Annotation\Field;
/**
* @Searchable(index="posts")
*/
class Post
{
/**
* @Field(type="text")
*/
private string $title;
/**
* @Field(type="date")
*/
private ?\DateTimeInterface $publishedAt = null;
/**
* @Field(type="relation", targetEntity="User", field="username")
*/
private User $author;
}
Note the nullable publishedAt — as of v1.4.0 you can explicitly query for documents where this field is missing (see the filtering pattern below).
First Search Query
Inject the SearchService into a controller or command:
use ATernovtsii\SearchBundle\Service\SearchService;
class PostSearchController extends Controller
{
public function __construct(private SearchService $searchService) {}
public function index()
{
$results = $this->searchService->search('Post', 'query=laravel');
return view('posts.index', compact('results'));
}
}
First Existence Filter (v1.4.0+)
The headline feature of v1.4.0: filter on whether a field is indexed at all, and negate it with LogicalNot.
use ATernovtsii\SearchBundle\Search\Filter\FieldFilter;
use ATernovtsii\SearchBundle\Search\Filter\LogicalNot;
use ATernovtsii\SearchBundle\Search\Operator;
// Posts that HAVE a publishedAt value
$hasDate = new FieldFilter('publishedAt', Operator::EXISTS);
// Posts that have NEVER been published (drafts)
$drafts = new LogicalNot(new FieldFilter('publishedAt', Operator::EXISTS));
Reindex Data Run the reindex command to populate your search engine:
php artisan search:reindex Post
Optional: Enable event-based reindexing in config/search.php:
'auto_reindex' => true,
After upgrading to v1.4.0, reindex before relying on EXISTS — the operator queries the index mapping/data as stored, so stale documents that were never reindexed may not report existence correctly.
Pattern: Use dependency injection for SearchService to decouple search logic from business logic.
class ProductController extends Controller
{
public function search(Request $request, SearchService $search)
{
$query = $request->input('q');
$results = $search->search('Product', [
'query' => $query,
'sort' => ['price' => 'asc'],
'limit' => 20,
]);
return response()->json($results);
}
}
Pattern: Extend field types for custom logic (e.g., nested objects, computed fields).
use ATernovtsii\SearchBundle\Annotation\Field;
use ATernovtsii\SearchBundle\Field\FieldInterface;
class CustomField implements FieldInterface
{
public function getType(): string { return 'custom'; }
public function getValue($entity): mixed
{
return $entity->getComputedValue();
}
}
/**
* @Field(type="custom", customClass="App\Search\CustomField")
*/
private string $computedField;
Tip: If a custom field can legitimately return null, return null rather than an empty string — that distinction is what makes Operator::EXISTS filters meaningful on it.
Pattern: Leverage Laravel's pagination helpers with search results.
use Illuminate\Pagination\LengthAwarePaginator;
$results = $this->searchService->search('Article', [
'query' => 'tutorial',
'from' => $request->input('page') * 10,
'size' => 10,
]);
$paginated = new LengthAwarePaginator(
$results['hits'],
$results['total'],
10,
$request->input('page', 1),
['path' => LengthAwarePaginator::resolveCurrentPath()]
);
Pattern: Trigger reindexing on entity updates via Doctrine events.
// config/search.php
'auto_reindex' => [
'enabled' => true,
'events' => ['prePersist', 'preUpdate', 'preRemove'],
],
Manual override: Disable for specific entities:
/**
* @Searchable(autoReindex=false)
*/
class Newsletter
{
// ...
}
Gotcha: When a field is set to null, preUpdate still fires and the document is reindexed — but the old field value may linger in the index unless the bundle is deleting the key. Verify with an EXISTS query after upgrading if you rely on "unset" semantics.
Pattern: Search across translated fields using the locale parameter.
$results = $search->search('Product', [
'query' => 'shirt',
'locale' => 'fr', // French translations
]);
Tip: Combine with EXISTS to find entities missing a translation — the single most common use case this operator unlocks:
$untranslated = new LogicalNot(new FieldFilter('title.fr', Operator::EXISTS));
Pattern: Use EXISTS + LogicalNot as a first-class "is this record complete?" filter, rather than sentinel values like 0, '', or 1970-01-01.
use ATernovtsii\SearchBundle\Search\Filter\FieldFilter;
use ATernovtsii\SearchBundle\Search\Filter\LogicalNot;
use ATernovtsii\SearchBundle\Search\Filter\LogicalAnd;
use ATernovtsii\SearchBundle\Search\Operator;
// Incomplete listings: no price AND no image
$incomplete = new LogicalAnd(
new LogicalNot(new FieldFilter('price', Operator::EXISTS)),
new LogicalNot(new FieldFilter('image', Operator::EXISTS)),
);
$results = $this->searchService->search('Product', [
'filter' => $incomplete,
'size' => 50,
]);
This is a behavioural simplification worth refactoring toward: previously you'd have to index sentinel values and filter price = 0, which conflates "free" with "unpriced".
Pattern: Filter values now accept null, so equality checks against genuinely-null fields are expressible.
$unpublished = new FieldFilter('publishedAt', Operator::EQ, null);
In most mappings this is approximate — see the gotchas below. Prefer LogicalNot(Operator::EXISTS) when you mean "field absent", and Operator::EQ, null only when your mapping explicitly stores nulls (null_value).
Service Container Binding
Override the default SearchService binding in AppServiceProvider:
$this->app->bind(SearchService::class, function ($app) {
return new CustomSearchService($app->get('search.client'));
});
Artisan Command Aliases Create Laravel-style commands for search management:
php artisan search:reindex Post
php artisan search:analyze Post
Blade Directives Extend Blade to render search forms:
// app/Providers/BladeServiceProvider.php
Blade::directive('searchForm', function ($expression) {
return "<?php echo ATernovtsii\SearchBundle\Blade::searchForm($expression); ?>";
});
Usage:
{!! searchForm('Post', ['fields' => ['title', 'description']]) !!}
API Resource Integration Format search results as API resources:
use ATernovtsii\SearchBundle\Resource\SearchResource;
return SearchResource::collection($results['hits']);
Queue-Based Reindexing Offload reindexing to queues for large datasets:
// config/search.php
'queue_reindex' => true,
// In a command:
$this->searchService->queueReindex('Product');
Request-to-Filter Mapping for exists
Expose the new operator through a query-string contract so front-end filters don't need bespoke controller code:
// ?has=image&has_not=publishedAt
$filters = [];
foreach ((array) $request->input('has', []) as $field) {
$filters[] = new FieldFilter($field, Operator::EXISTS);
}
foreach ((array) $request->input('has_not', []) as $field) {
$filters[] = new LogicalNot(new FieldFilter($field, Operator::EXISTS));
}
Allow-list the field names — filters are string-built, so untrusted input here is an easy way to probe your mapping.
Symfony Version Conflicts
symfony/dependency-injection, http-client, or event-dispatcher.composer.json:
"extra": {
"laravel": {
"dont-discover": ["vendor/aternovtsii/search-bundle"]
}
},
"require": {
"symfony/dependency-injection": "8.0.0",
"symfony/http-client": "8.0.0"
}
Doctrine Event Overhead
auto_reindex) triggers on every prePersist/preUpdate, which can slow down bulk operations.$this->searchService->reindex('Product', ['batch_size' => 50]);
Field Type Mismatches
FieldFloat for non-numeric values).if (!is_numeric($entity->getPrice())) {
throw new \InvalidArgumentException('Price must be numeric');
}
Index Template Conflicts
config/search.php:
'template' => [
'generate' => false,
],
Then manually define the index mapping.Translation Loading Bug
$subSelects are misconfigured (fixed in v0.1.5).default_locale is set and verify the locale parameter in queries.Max Result Window
max_result_window limit (default: 10,000).config/search.php:'client' => [
'max_result_window' => 5000,
],
EXISTS Is Not NOT NULL (v1.4.0)
Operator::EXISTS compiles to the engine's exists query, which asks "is there an indexed value for this field path" — it is not a SQL-style IS NOT NULL. Two consequences:
null_value always report as existing (the null sentinel is indexed as a real value).exists check on a text field with a .keyword subfield, or on an object/nested path, evaluates against the whole path, not the leaf you had in mind.LogicalNot(FieldFilter($f, Operator::EXISTS)). "Stored null" → FieldFilter($f, Operator::EQ, null) plus a mapping with null_value. Document the choice in the entity annotation so the next developer doesn't guess.LogicalNot on a Filtered Query Doesn't Mean "Match Everything Else"
new LogicalNot(new FieldFilter('date', Operator::EXISTS)) inside a bool clause is fine, but wrapping it at the top level together with a must text query is easy to get wrong — you end up with a must_not that still has to satisfy scoring, and results can look arbitrarily ordered.filter (non-scoring) context, not must. If results seem to be missing, dump the generated query body (enable the bundle's query logging / client.debug in config/search.php) and confirm which bool clause the filter landed in.Silent No-Op on Older Versions
Operator::EXISTS. Depending on how the operator is resolved, an unknown operator can be dropped from the query rather than throwing — you get all documents back and no error, which reads like a mapping bug.EXISTS filter, and pin aternovtsii/search-bundle:^1.4 in composer.json. Add a smoke test per environment rather than trusting the deployed version.Reindex After Upgrading to v1.4.0
EXISTS returns true for "empty" fields — your draft bucket silently comes back empty.php artisan search:reindex (or queue it) as part of the upgrade, and refactor the serializers to emit null/omit the key instead of ''/0. Do the reindex after the serializer change, not before.How can I help you explore Laravel packages today?