Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Search Bundle Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

Minimal Setup in Laravel

  1. 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.

  2. 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).
  3. 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).

  4. 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'));
        }
    }
    
  5. 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));
    
  6. 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.


Implementation Patterns

Workflows

1. Search Integration in Controllers

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);
    }
}

2. Dynamic Field Mapping

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.

3. Pagination with Search

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()]
);

4. Event-Driven Reindexing

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.

5. Multi-Locale Search

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));

6. Draft / Incomplete-Record Buckets (v1.4.0)

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".

7. Nullable Values in Filters (v1.4.0)

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).


Integration Tips

Laravel-Specific Adaptations

  1. Service Container Binding Override the default SearchService binding in AppServiceProvider:

    $this->app->bind(SearchService::class, function ($app) {
        return new CustomSearchService($app->get('search.client'));
    });
    
  2. Artisan Command Aliases Create Laravel-style commands for search management:

    php artisan search:reindex Post
    php artisan search:analyze Post
    
  3. 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']]) !!}
    
  4. API Resource Integration Format search results as API resources:

    use ATernovtsii\SearchBundle\Resource\SearchResource;
    
    return SearchResource::collection($results['hits']);
    
  5. Queue-Based Reindexing Offload reindexing to queues for large datasets:

    // config/search.php
    'queue_reindex' => true,
    
    // In a command:
    $this->searchService->queueReindex('Product');
    
  6. 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.


Gotchas and Tips

Pitfalls

  1. Symfony Version Conflicts

    • Issue: Laravel 10 bundles Symfony 6.4, but the package requires Symfony 8.0+. Conflicts may arise in symfony/dependency-injection, http-client, or event-dispatcher.
    • Fix: Pin Symfony dependencies in composer.json:
      "extra": {
          "laravel": {
              "dont-discover": ["vendor/aternovtsii/search-bundle"]
          }
      },
      "require": {
          "symfony/dependency-injection": "8.0.0",
          "symfony/http-client": "8.0.0"
      }
      
  2. Doctrine Event Overhead

    • Issue: Event-based reindexing (auto_reindex) triggers on every prePersist/preUpdate, which can slow down bulk operations.
    • Fix: Disable for high-frequency entities or use batch reindexing:
      $this->searchService->reindex('Product', ['batch_size' => 50]);
      
  3. Field Type Mismatches

    • Issue: Custom field types may not serialize correctly (e.g., FieldFloat for non-numeric values).
    • Fix: Validate field types in annotations:
      if (!is_numeric($entity->getPrice())) {
          throw new \InvalidArgumentException('Price must be numeric');
      }
      
  4. Index Template Conflicts

    • Issue: Manually created Elastic/OpenSearch indices may conflict with the bundle's auto-generated templates.
    • Fix: Disable template generation in config/search.php:
      'template' => [
          'generate' => false,
      ],
      
      Then manually define the index mapping.
  5. Translation Loading Bug

    • Issue: Search results for translatable fields may return empty translations if $subSelects are misconfigured (fixed in v0.1.5).
    • Fix: Ensure default_locale is set and verify the locale parameter in queries.
  6. Max Result Window

    • Issue: Large datasets may hit Elastic/OpenSearch's max_result_window limit (default: 10,000).
    • Fix: Configure in config/search.php:
    'client' => [
        'max_result_window' => 5000,
    ],
    
  7. EXISTS Is Not NOT NULL (v1.4.0)

    • Issue: 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:
      • Fields mapped with null_value always report as existing (the null sentinel is indexed as a real value).
      • An 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.
    • Fix: Decide explicitly which semantics you want. "Field absent from document" → 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.
  8. LogicalNot on a Filtered Query Doesn't Mean "Match Everything Else"

    • Issue: 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.
    • Fix: Keep existence filters in 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.
  9. Silent No-Op on Older Versions

    • Issue: Pre-1.4.0 installs have no 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.
    • Fix: Guard in CI with a test that asserts result counts for an EXISTS filter, and pin aternovtsii/search-bundle:^1.4 in composer.json. Add a smoke test per environment rather than trusting the deployed version.
  10. Reindex After Upgrading to v1.4.0

    • Issue: Existence semantics are computed at query time from whatever is in the index. If documents were written by pre-1.4.0 code with empty-string placeholders, EXISTS returns true for "empty" fields — your draft bucket silently comes back empty.
    • Fix: Run a full 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.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
htinlynn/genius-helper
maco-studios/hephaestus
elmogy/larafast
xeviant/laravel-paystack
gorilladash/laravel-website-sdk
martialbe/flysystem-oss
chondal/3312client
jeroen-g/flickr
opscale-co/nova-genbi
itsjustvita/laravel-retention
tequilarapido/trackit
gregsphotography/site-builder-filament
cabaoctavio/laravel-query-log
gregsphotography/site-builder-components
io-digital/clickatell
snowsoft/laravel-admin
oxygen/auth
relicsoftware/barakah-payment-gateway
tobya/work-with-composer
equipc/laravel-email-validator