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

Postgres Search Bundle Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  • On Symfony + Doctrine: install, register, use the DQL functions. The bundle is thin glue over tsvector/tsquery — its real value is that you don't hand-write SQL for every ranked query.
  • On Laravel (the more likely case here): you cannot use the bundle. There is no 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):

  1. composer require alsatian/postgres-search-bundle
  2. Register in config/bundles.php — confirm the exact FQCN from the README (Alsatian\PostgresSearchBundle\AlsatianPostgresSearchBundle is the expected shape).
  3. Verify your Doctrine connection is genuinely PostgreSQL. The bundle emits PG-only SQL; SQLite/MySQL CI will fail.
  4. Look first at the DQL function classes. The bundle's public surface is its registered DQL functions (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.

Implementation Patterns

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.

Gotchas and Tips

  • Adoption is effectively zero. No stars, no dependents, no community. Treat this as a reference implementation you fork and own, not a dependency you inherit. It's a few hundred lines — read all of it before adopting, and plan to vendor it in-repo.
  • PostgreSQL only, loudly. SQLite test suites (common in Symfony and Laravel) will blow up on any tsvector branch. Either run Postgres in CI (Testcontainers / docker-compose) or gate full-text paths behind a driver check.
  • Doctrine version drift breaks DQL functions. Custom function classes implement 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.
  • Ranking scores are not normalized. 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.
  • Debug in 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.
  • Prefix search is a UX trap. Users typing "pho" won't match "phone" under 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')
    
  • What you will not get for free: typo tolerance, synonyms, faceting, semantic/hybrid retrieval, multi-field relevance tuning. This is keyword search done well — set expectations in acceptance criteria before launch, not after.
  • Extension points are deliberately thin: custom DQL functions, entity mapping, and raw DBAL SQL. That's the whole surface. If the bundle doesn't reach a feature, drop to SQL — you're a DB::raw() (or getEntityManager()->getConnection()->executeQuery()) away from anything PostgreSQL supports.
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.
gmostafa/php-graphql-client
xeviant/paystack
snowsoft/faker
meditationsteps/blogetc
animalyoung/laravel-influxdb
onix-solutions/sms-telera
lonely-lights/prosetta
oxygen/data
oxygen/core
jengo/base
xiloryx/log-collector
a21ns1g4ts/filament-stripe
la-php/laravel-admin
artisan-build/community-prompts
vleroy/smart
ayles-software/laravel-australian-public-holidays
cboxdk/laravel-id-spatie
cboxdk/laravel-id-billing
cboxdk/laravel-id-billing-bridge
simple-as-fuck/php-performance-log