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

shopsys/postgres-search-bundle

Shopsys Postgres Search Bundle brings native full-text search to Shopsys projects using PostgreSQL, letting you index and query products quickly without running a separate search engine.

View on GitHub
Deep Wiki
Context7

Getting Started

Read this first: you are not installing this package. It is a Symfony bundle (DI extension, console command, Doctrine DBAL mapping types, Doctrine event subscribers) for the Shopsys Framework. A Laravel app has no service provider, no Scout driver, and no Eloquent equivalent — composer require shopsys/postgres-search-bundle gives you dead weight plus a pin to an administrative fork (0.1) of intaro/postgres-search-bundle with no upstream release history. The package's real value to you is its ~200–400 lines of SQL logic. Open the source, find the search manager and query builder, read how they build tsvector/tsquery and ts_rank, then own that logic.

Where to look in the source, in order:

  1. The console command that creates the GIN index — it tells you the exact expression it indexes (this is the contract you must reproduce).
  2. The query-building search manager — plainto_tsquery/ts_rank_cd, weight arrays, normalization flags.
  3. The Doctrine subscribers — useful only to understand which write paths they cover (persist/update via ORM), and therefore which ones they miss (upsert, DB::statement, queue workers).

Then check your environment before writing code:

psql -c "show server_version;"                # need 11+ for websearch_to_tsquery, 12+ for generated columns
psql -c "select name from pg_available_extensions where name in ('unaccent','pg_trgm');"

Managed Postgres (RDS, Cloud SQL, Supabase) may block extension creation. Confirm in every environment, including the ones you don't control.

First use case: product search over name, SKU, and description, ranked. One migration, one backfill, one Eloquent scope.

// database/migrations/xxxx_add_search_vector_to_products.php
public $withinTransaction = false; // CREATE INDEX CONCURRENTLY cannot run in a transaction

public function up(): void
{
    DB::statement("ALTER TABLE products ADD COLUMN search_vector tsvector
        GENERATED ALWAYS AS (
            setweight(to_tsvector('simple', coalesce(name, '')),        'A') ||
            setweight(to_tsvector('simple', coalesce(sku, '')),         'A') ||
            setweight(to_tsvector('simple', coalesce(description, '')), 'B')
        ) STORED");

    DB::statement('CREATE INDEX CONCURRENTLY products_search_vector_idx
                   ON products USING GIN (search_vector)');
}

Note the index expression must match the query expression exactly — same regconfig literal ('simple'), same concatenation order. A mismatch silently degrades to a sequential scan.

Implementation Patterns

The search scope. Bind the user's raw input; never interpolate into to_tsquery.

// app/Models/Product.php
public function scopeSearch(Builder $q, string $term): Builder
{
    return $q
        ->selectRaw(
            "products.*, ts_rank_cd(search_vector, websearch_to_tsquery('simple', ?), 32) AS rank",
            [$term]
        )
        ->whereRaw(
            "search_vector @@ websearch_to_tsquery('simple', ?)",
            [$term]
        );
}

websearch_to_tsquery handles quoted phrases, or, and stray punctuation without raising syntax errors — the reason to_tsquery should never see raw user input. Flag 32 in ts_rank_cd divides by rank + 1, which is the sane default for product listings; drop it if you want raw score ordering.

Search behind your own interface. This is the pattern that makes the package's idea safe to adopt:

interface ProductSearch
{
    public function search(string $term, array $filters = [], int $perPage = 24): LengthAwarePaginator;
}

Bind PostgresProductSearch in a service provider. Everything above the interface is framework-neutral, so the day relevance stops being good enough, you swap the binding to Meilisearch and delete one class instead of rewriting controllers.

Laravel Scout as the integration point. If you want Product::search('foo') DX preserved, write a custom Scout engine whose search() runs the query above. Scout's engine contract (search, update, delete, flush) maps closely onto what the bundle's search manager plus Doctrine listeners do — and update/delete being explicit calls is arguably safer than lifecycle listeners, because it is obvious that bulk writes bypass them.

Sync strategy: prefer the database, not the app. Generated columns (as above) are the strongest option — they cannot go stale. On PG 11, or where unaccent is involved (which is not IMMUTABLE), use a trigger instead:

CREATE FUNCTION products_search_vector_refresh() RETURNS trigger AS $$
BEGIN
  NEW.search_vector :=
      setweight(to_tsvector('simple', unaccent(coalesce(NEW.name,''))), 'A') ||
      setweight(to_tsvector('simple', coalesce(NEW.description,'')),   'B');
  RETURN NEW;
END $$ LANGUAGE plpgsql;

CREATE TRIGGER products_search_vector_trg
BEFORE INSERT OR UPDATE ON products
FOR EACH ROW EXECUTE FUNCTION products_search_vector_refresh();

A BEFORE trigger covers insertOrIgnore, upsert, DB::table()->update(), queue jobs, and COPY — every path an Eloquent observer misses. If you go the observer route anyway, add a nightly reconciliation job:

SELECT count(*) FROM products
WHERE search_vector IS DISTINCT FROM <recomputed expression>;

Multilingual. Index per-locale: either one search_vector_<locale> column per market, or a search_vector plus a locale column and a composite index. Store the regconfig you used alongside the row so backfills and reindexes can reuse it. Never mix configs — an index built with 'simple' queried with 'english' returns zero rows, quietly.

Typeahead. Prefix matching needs to_tsquery('simple', 'foo:*') (sanitize the term first — strip everything but alphanumerics), or pg_trgm with a separate gin (name gin_trgm_ops) index for fuzzy/typo tolerance. Do not point typeahead at the same ranked query as the search page; it will be slower and worse.

Pagination and counts. paginate()'s count(*) on a ranked FTS query is often the most expensive part of the request. Use simplePaginate(), or a keyset cursor over (rank DESC, id DESC) — rank is non-unique, so id must be in the tiebreaker.

Rollout. Put the new query behind a config flag in the existing search service, log both result sets for a release cycle, diff top-N overlap on a fixed query set, then cut over per surface (search page, typeahead, admin).

Gotchas and Tips

  • Provenance. The fork's only delta is the 0.1 tag. If you genuinely need the code in vendor/, diff against intaro/postgres-search-bundle at the pinned commit and prefer a VCS repositories entry over the fork. Either way, write down who patches it when Doctrine or Postgres moves underneath it.
  • CREATE INDEX CONCURRENTLY cannot run inside a transaction. Laravel wraps migrations in one by default; set public $withinTransaction = false; on the migration. If the build fails or is cancelled, it leaves an INVALID index — check pg_index.indisvalid, DROP INDEX, and retry. On large tables, do it out-of-band with psql rather than in deploy.
  • to_tsvector(col) vs to_tsvector('english', col). The single-argument form uses default_text_search_config at runtime, so Laravel's built-in whereFullText() (which emits to_tsvector('english', "name")) will not match a generated column built with 'simple'. The expression in the index and the expression in the WHERE must be textually identical.
  • unaccent() is not IMMUTABLE, so it is illegal inside a generated column. Wrap it in an IMMUTABLE SQL function or move to a trigger.
  • to_tsquery raises syntax error in tsquery on &, |, !, :, or a trailing space. Use websearch_to_tsquery (PG 11+) or plainto_tsquery. This is the most common production 500 in FTS integrations.
  • Stopword-only or blank queries produce an empty tsquery that matches nothing — the page renders "no results" for "the". Short-circuit: if the sanitized term is empty, fall back to ILIKE/trigram or return an unfiltered listing.
  • ts_headline is expensive. It re-parses the document. Only call it for the current page's rows, cap MaxWords/MaxFragments, and pass HighlightAll=false.
  • GIN index write amplification. Every product update rewrites index entries. Tune gin_pending_list_limit and fastupdate, and watch autovacuum — bloat on a busy catalog shows up as slow search long before row count does.
  • Search shares the database with checkout. One unbounded FTS query on a cold cache competes with transactional traffic. Set statement_timeout on the search connection, or route reads to a replica that carries the same index.
  • Debugging: EXPLAIN (ANALYZE, BUFFERS) — you want Bitmap Index Scan on products_search_vector_idx. A Seq Scan means index/query expression mismatch, a non-IMMUTABLE predicate, or a filter column not covered by the index.
  • Instrument three numbers: p95 latency, zero-result rate, and click-through on position 1–3. Relevance regressions after a dictionary or unaccent change are silent — they don't error, they just stop selling. Keep a golden query set with expected top-5 results asserted in a feature test.
  • Where this stops. No faceting, no synonyms UI, no learning-to-rank, no per-tenant tuning. If faceted navigation appears on the roadmap, say so now — the migration away is a project, not a refactor, and the ProductSearch interface is what keeps it contained.
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.
skyraptor/chatter
edlugz/sasapay
abbasudo/artisan-route
mirvan/ippanel
ignibyte/aic-framework
glavweb/data-schema-bundle
glavweb/datagrid-bundle
tacticmedia/scim-server
uni-method/frame
neika-ai/sdk
adachsoft/php-code-style
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
adachsoft/api-version-bundle
cgv/localized-resource