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

whatwedo/search-bundle

whatwedo/search-bundle adds configurable full-text search to Symfony applications. It indexes Doctrine entities, supports filters and pagination, and returns relevance-ranked results so you can build fast search pages and list views with minimal setup.

View on GitHub
Deep Wiki
Context7

Getting Started

This is a Symfony bundle, not a Laravel package — there's no service provider or config/search.php. The mental model is closest to Laravel Scout with the database driver: full-text index kept in your own database, no Meilisearch/Elastic/Algolia sidecar. For a Laravel shop, treat it as a reference implementation or as something you'd only touch on a Symfony service.

Minimal path to a working search:

composer require whatwedo/search-bundle
# register in config/bundles.php
# run the bundle's migration / schema tool for the search table
bin/console doctrine:migrations:diff && bin/console doctrine:migrations:migrate

Then, in order:

  1. Read the README's entity-opt-in example first. The whole bundle hangs off one contract — you mark an entity as searchable (interface or PHP attribute, depending on version) and tell it which fields contribute to the index. Everything else is plumbing around that.
  2. Find the search table schema (src/Resources/config/doctrine/ or the shipped migration). Look at the columns — typically entityClass, entityId, title, content, and a fulltext/tags column. That tells you exactly what you can query and how MySQL will tokenize it.
  3. Locate the Doctrine subscriber (src/EventSubscriber or src/EventListener). This is where indexing happens on postPersist/postUpdate/postRemove. Knowing this is synchronous changes how you think about writes.
  4. First use case: pick one entity (a Page or Article), implement the searchable contract, return title + stripped body text, run the reindex command, then query the search manager from a controller and render the hydrated entities.
// controller — verify exact class names against src/Search/
public function search(Request $request, SearchManager $search): Response
{
    $results = $search->search($request->query->get('q', ''))
        ->setLimit(20)
        ->getResult();

    return $this->render('search/results.html.twig', ['results' => $results]);
}

Implementation Patterns

Opt-in per entity, explicit field list. Don't make every entity searchable. Implement the contract on 2–3 entities you actually want in a global search box, and curate the indexed text — concatenate title, excerpt, tag names. The quality of results is decided here, not in the query layer.

class Article implements SearchableInterface
{
    public function getSearchTitle(): string { return $this->title; }

    public function getSearchContent(): string
    {
        return strip_tags($this->body) . ' ' . $this->category->getName();
    }
}

Wrap the bundle in your own service. The package is thin (11 stars) and you may outgrow it. Put a SearchService in front so swapping to Scout, Meilisearch, or a hand-rolled MATCH ... AGAINST query is a one-file change:

final class SearchService
{
    public function __construct(private SearchManager $search) {}

    public function find(string $q, int $page = 1): Paginator
    {
        return $this->search->search($q)->setPage($page)->getPaginator();
    }
}

Reindex on deploy, not on user request. Treat the index like a materialized view: an app:search:reindex command wired into your deploy script or a scheduled job. Use it as the source of truth after any schema change or backfill.

// command: batch + clear to avoid OOM on large tables
foreach ($entityManager->getRepository(Article::class)->iterate() as $i => $article) {
    $indexer->index($article);
    if ($i % 100 === 0) { $entityManager->flush(); $entityManager->clear(); }
}

Hydrate carefully. Multi-entity search stores (entityClass, entityId). Hydrating 20 results across three entity types is fine; hydrating via EntityManager::find() in a loop for a 200-result page is N+1. Batch by class (WHERE id IN (...)) or cap the page size.

Keep it out of the hot write path where you can. If indexing is event-driven and synchronous, heavy entities (large bodies, many relations) will slow every save. Consider indexing only on a queued job / kernel.terminate listener rather than inside the flush.

Gotchas and Tips

  • Indexing rides the Doctrine flush. If the subscriber hooks onFlush/postFlush, a failure in the search table rolls back (or breaks) the business write. Test what happens when the index insert fails mid-transaction — you don't want a user's article save to 500 because the search row was too long.
  • Drift is the default state. DQL UPDATE, raw DBAL statements, doctrine:fixtures:load with --no-events, and manual SQL all bypass the subscriber. Budget for a nightly reindex or a "reindex this entity" admin action.
  • Relation changes don't propagate. Editing a Category name won't reindex its Articles. Either denormalize less, or explicitly reindex dependents in the category's postUpdate.
  • MySQL FULLTEXT is opinionated. innodb_ft_min_token_size (default 3) silently drops short words; stopwords are ignored; InnoDB doesn't handle CJK without the ngram parser. If your dev box is SQLite and prod is MySQL, your tests will pass and prod will return nothing — run fulltext tests against MySQL.
  • Strip HTML before indexing. Twig-rendered bodies bloat the table and pollute the index with   and class names. Store plain text, highlight on render.
  • Soft deletes. If you use SoftDeletable/@Gedmo\SoftDeleteable, postRemove never fires — deleted rows stay searchable. Add a listener or filter at query time.
  • Watch the table size. The index duplicates content. On a 100k-row content table with long bodies, this is real disk and backup cost. Consider indexing a truncated excerpt and fetching full content on click.
  • Extension points worth knowing: override the bundle's Twig templates for result rendering; decorate the search manager if you need to add filters (locale, published-only) — do it by wrapping, not by patching vendor/.
  • Small package, small surface. 11 stars, zero dependents, one maintainer. Pin an exact version in composer.json, read src/ end-to-end once (it's small), and keep your own SearchService boundary. The upside is you can fork it in an afternoon if you need a feature.
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.
coleus/frame
thtg88/journalism
filagora/filagora-ai-forums
rlacerda83/query-parser
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings