alsatian/postgres-search-bundle
A Symfony bundle that brings PostgreSQL full-text search to Doctrine applications, enabling native indexing and querying so you can add fast, relevant search without an external engine. It provides convenient integration for building search features directly on Postgres.
First, reconcile the stack. alsatian/postgres-search-bundle is a Symfony bundle on Doctrine ORM/DBAL, not a Laravel/Eloquent package. Day-to-day leverage looks completely different depending on which side you're on:
tsvector/tsquery — its real value is that you don't hand-write SQL for every ranked query.ServiceProvider, no Eloquent integration, no Query\Builder macro. What you can leverage is the pattern — the same tsvector columns, GIN indexes, weighted fields, and ts_rank_cd ordering — implemented with DB::raw() and query scopes. Budget an afternoon to port it, not a composer require.Minimum viable Symfony setup (for reference / for evaluating the source):
composer require alsatian/postgres-search-bundleconfig/bundles.php — confirm the exact FQCN from the README (Alsatian\PostgresSearchBundle\AlsatianPostgresSearchBundle is the expected shape).TO_TSVECTOR, TO_TSQUERY, PLAINTO_TSQUERY, TS_RANK, etc.). Read those classes before reading anything else — they map 1:1 to PostgreSQL functions and tell you exactly what's available.First use case — search one table. In Symfony:
$qb->andWhere("TO_TSVECTOR('english', a.title) @@ PLAINTO_TSQUERY('english', :q)")
->setParameter('q', $term)
->orderBy("TS_RANK(TO_TSVECTOR('english', a.title), PLAINTO_TSQUERY('english', :q))", 'DESC');
The Laravel equivalent (what you'd actually ship):
$q = Article::query()
->whereRaw("search_vector @@ websearch_to_tsquery('english', ?)", [$term])
->orderByRaw("ts_rank_cd(search_vector, websearch_to_tsquery('english', ?)) DESC", [$term]);
Same SQL, same index, same ranking — just no bundle.
Store the vector; don't compute it per query. This is the single most important pattern. An inline to_tsvector(...) in your WHERE clause forces a sequential scan unless you have a byte-identical functional index. Instead:
ALTER TABLE articles ADD COLUMN search_vector tsvector;
CREATE INDEX articles_search_idx ON articles USING GIN (search_vector);
Populate it in a Doctrine lifecycle callback (Symfony) or a model observer (Laravel) — or better, a database trigger:
CREATE TRIGGER articles_search_vector_update
BEFORE INSERT OR UPDATE ON articles
FOR EACH ROW EXECUTE FUNCTION
tsvector_update_trigger(search_vector, 'pg_catalog.english', title, body);
The trigger approach wins because bulk updates skip Doctrine listeners and Eloquent observers. $em->createQuery(...)->execute() or Article::where(...)->update([...]) will leave a listener-maintained column stale; the trigger will not.
Weighted fields. Title should outrank body. Build the vector with setweight:
setweight(to_tsvector('english', coalesce(title,'')), 'A') ||
setweight(to_tsvector('english', coalesce(body,'')), 'B')
coalesce is not optional — to_tsvector(NULL) returns NULL, and NULL @@ tsquery is NULL (not false), which silently drops rows from results. Nullable columns are the #1 cause of "why isn't this row matching?"
Compose with normal filters. Full-text is just another predicate — mix it with status filters, tenant scoping, and pagination exactly as you would any other WHERE:
$qb->andWhere('a.status = :status')
->andWhere('a.searchVector @@ websearch_to_tsquery(:lang, :q)')
->setParameter('status', 'published')
->setParameter('lang', 'english')
->setParameter('q', $term);
Doctrine's Paginator, KnpPaginator, and Laravel's paginate() all work untouched. Don't overengineer — the full-text predicate doesn't change how paging works.
Pick a parsing function deliberately:
websearch_to_tsquery — default for user input. Handles quotes, OR, and -exclusion gracefully, never throws on malformed input.plainto_tsquery — ANDs all words, ignores punctuation/syntax. Safe, dumb.to_tsquery — strict syntax; throws on foo & or stray quotes. Only use for input you construct yourself (e.g., prefix search: 'pho:*').Language consistency. Every to_tsvector / to_tsquery call takes a dictionary/language argument. It must be identical across the column definition, the index, and the query. A mismatch ('english' column, 'simple' query) produces zero results with no error — a silent, miserable debugging session.
Cross-entity search → materialized view. If you need to search articles + products + docs in one result set, build a materialized view with a single tsvector column, map it to a read-only entity/Model, and query it. The bundle's DQL functions (or your raw SQL) work against the view identically. Refresh the view on a schedule or via trigger-driven invalidation.
tsvector branch. Either run Postgres in CI (Testcontainers / docker-compose) or gate full-text paths behind a driver check.FunctionNode and depend on its AST signatures; major Doctrine upgrades have historically changed these. Pin and re-test on every upgrade. This is the most likely reason you'd end up forking.ts_rank floats depend on document length and query. Don't compare scores across queries or use them as "match percentage" in UI — use them for ordering only, or switch to ts_rank_cd for cover-density ranking, which behaves better on longer documents.psql, not in the ORM. Dump the generated SQL (Doctrine profiler / Laravel's DB::listen), paste it into psql, and run EXPLAIN ANALYZE. Confirm you see a Bitmap Index Scan on your GIN index. A Seq Scan means the expression didn't match the index — usually a language/coalesce mismatch.plainto_tsquery or websearch_to_tsquery. If you need autocomplete-style prefix matching, you're in to_tsquery('english', 'pho:*') territory, which means sanitizing input yourself — or falling back to ILIKE 'pho%' alongside the ranked query.ts_headline for snippets, and expect to write it yourself. Neither the bundle nor a straightforward Laravel scope gives you highlighted excerpts. That's a raw SQL addition:
ts_headline('english', body, websearch_to_tsquery('english', $term),
'StartSel=<mark>, StopSel=</mark>, MaxFragments=1')
DB::raw() (or getEntityManager()->getConnection()->executeQuery()) away from anything PostgreSQL supports.How can I help you explore Laravel packages today?