gkovrey/postgres-search-bundle
gkovrey/postgres-search-bundle integrates PostgreSQL full-text search into Symfony/PHP applications, offering database-native indexing and query support for fast, relevant search results without an external search engine. Configurable, lightweight, and easy to wire into existing projects.
Reality check first: this is a Symfony2 bundle (2016, zero stars, no dependents). It cannot be installed into a Laravel app — the bundle class extends Symfony\Component\HttpKernel\Bundle\Bundle, its service wiring lives in Resources/config/services.xml, and it assumes the Symfony DI container and DoctrineBundle. Treat the repo as reference material for the SQL, not as a dependency.
Minimal steps for a Laravel developer:
to_tsvector, plainto_tsquery, ts_rank, ts_headline) and how to bind them into a query builder.laravel-doctrine/orm. If Eloquent, skip the package entirely and copy the SQL. If Doctrine, register the functions by hand:// config/doctrine.php (laravel-doctrine)
'custom_string_functions' => [
'TO_TSVECTOR' => \App\Doctrine\ToTsVector::class,
],
'custom_numeric_functions' => [
'TS_RANK' => \App\Doctrine\TsRank::class,
],
tsvector column, backfill it, add a GIN index, and expose a single Eloquent scope. Do this before touching rank weighting or highlighting.// migration
DB::statement("ALTER TABLE posts ADD COLUMN search_vector tsvector");
DB::statement("UPDATE posts SET search_vector =
setweight(to_tsvector('english', coalesce(title,'')), 'A') ||
setweight(to_tsvector('english', coalesce(body,'')), 'B')");
DB::statement("CREATE INDEX posts_search_idx ON posts USING GIN (search_vector)");
Scope-based search. Wrap the raw SQL in a query scope so controllers never see whereRaw. This is the Laravel equivalent of the bundle's query helpers:
public function scopeSearch(Builder $q, string $term): Builder
{
return $q->whereRaw('search_vector @@ websearch_to_tsquery(?, ?)', ['english', $term])
->orderByRaw('ts_rank_cd(search_vector, websearch_to_tsquery(?, ?)) DESC', ['english', $term]);
}
websearch_to_tsquery (PG 11+) accepts user-shaped input ("quoted phrases", or, -exclude) and never throws on malformed syntax — much better than feeding raw input to to_tsquery, which is what 2016-era code did.
Keep the vector current with a trigger, not model events. Eloquent saved hooks miss bulk update(), seeders, and other services writing to the table:
CREATE TRIGGER posts_search_update BEFORE INSERT OR UPDATE ON posts
FOR EACH ROW EXECUTE FUNCTION
tsvector_update_trigger(search_vector, 'pg_catalog.english', title, body);
Note tsvector_update_trigger only handles plain columns (no setweight); if you need weights A/B, write a small custom PL/pgSQL function instead.
Weighted fields + headline. Mirror the bundle's intent: title outranks body, and return a snippet. ts_headline is expensive — apply it only to the current page of results, in PHP or in a second pass, not in the main ranked query.
Short-query fallback. FTS ignores stopwords and doesn't do prefix matching well. For queries under ~3 chars or "did you mean" behavior, branch to ILIKE '%term%' or pg_trgm with a separate GIN index — the bundle has no answer for this and you will hit it in production.
Language as configuration. Never hardcode 'english' in scopes. Inject a config value so a multi-tenant app can use 'simple' (no stemming) or 'russian' where appropriate; the regconfig must be a constant in an expression index, so this decision is baked into the schema.
laravel-doctrine, the bundle's DependencyInjection/ layer won't run; only the DQL function classes port cleanly.CREATE INDEX ... (to_tsvector(col)) fails unless the regconfig is a literal. Use to_tsvector('english'::regconfig, col) or, cleaner, a stored generated column (PG 12+): GENERATED ALWAYS AS (to_tsvector('english', title || ' ' || body)) STORED.to_tsvector is not immutable with a column-based config, so generated columns are the only way to index a per-row language safely.ts_rank vs ts_rank_cd differ noticeably; ts_rank_cd handles proximity better for long documents. Benchmark on real data, don't trust the demo.ILIKE or return an explanatory message.EXPLAIN (ANALYZE, BUFFERS). If you see Seq Scan instead of Bitmap Index Scan on the GIN index, you're either missing the index, using a mismatched regconfig, or the planner is choosing a scan because the table is tiny in dev.ts_headline is the #1 performance trap — it re-parses the document per row. Never put it in a WHERE/ORDER BY path.DB::statement in a migration, RefreshDatabase on SQLite will fail — you need a real Postgres test database (pgsql connection, separate phpunit.xml env).Searchable trait that declares searchableColumns() and renders the trigger SQL, plus a SearchResult DTO carrying rank and headline. Neither exists here, so budget an afternoon to write them.websearch_to_tsquery), PG 12+ (generated columns), or PG 15+ behavior changes. Pin your own SQL and own it.How can I help you explore Laravel packages today?