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.
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:
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.src/EventSubscriber or src/EventListener). This is where indexing happens on postPersist/postUpdate/postRemove. Knowing this is synchronous changes how you think about writes.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]);
}
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.
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.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.Category name won't reindex its Articles. Either denormalize less, or explicitly reindex dependents in the category's postUpdate.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. and class names. Store plain text, highlight on render.SoftDeletable/@Gedmo\SoftDeleteable, postRemove never fires — deleted rows stay searchable. Add a listener or filter at query time.vendor/.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.How can I help you explore Laravel packages today?