spine/postgres-search-bundle
PostgreSQL full-text search for Laravel: index your models, run ranked tsvector queries backed by GIN indexes, and keep results in sync automatically. A lightweight alternative to external search engines with no extra infrastructure or services to run.
Start by setting expectations: this is a Symfony bundle built on Doctrine ORM, not a Laravel/Eloquent package. It was last released in September 2016, has no stars and no dependents, and its composer.json constraints almost certainly cap at Symfony 3.x and PHP 5.5–7.0. On a modern Laravel app you will not be able to composer require it — the value here is the pattern (a tsvector column + GIN index + rank-ordered query), which you can reimplement in Eloquent with whereRaw in about 30 lines.
If you are actually on Symfony/Doctrine, the reading order is:
README.md — the author documents the DQL function names and any annotation/mapping type. This is the only source of truth; the code is small enough to read in one sitting.DependencyInjection/ and Resources/config/ — shows which services are registered and which config keys exist (function names, language/regconfig, listener toggles).Doctrine/ or DQL/ directory — the custom function classes (TO_TSQUERY, TS_RANK, PLAINTO_TSQUERY and friends) that get wired into Doctrine's parser.EventListener/ or Subscriber/ — the code that keeps the tsvector column populated from your mapped fields.Minimal integration on Doctrine:
# config.yml
doctrine:
orm:
dql:
string_functions:
to_tsquery: Spine\PostgresSearchBundle\...\ToTsquery
plainto_tsquery: Spine\PostgresSearchBundle\...\PlaintoTsquery
numeric_functions:
ts_rank: Spine\PostgresSearchBundle\...\TsRank
Then add a search_vector tsvector column (via raw SQL migration, not schema:update), build the GIN index, and query:
$qb->andWhere('to_tsquery(:q) @@ e.searchVector = true')
->setParameter('q', $tsQuery);
First real use case: a single searchable entity (articles/posts) where you want title weighted above body and results ordered by relevance rather than date.
Keep the vector in the database, not the application. The bundle's listener approach (update searchVector on prePersist/preUpdate) is convenient but fragile — it silently skips DQL bulk updates, raw SQL imports, and anything outside the ORM. Prefer a database trigger:
CREATE FUNCTION posts_tsv() RETURNS trigger AS $$
BEGIN
NEW.search_vector :=
setweight(to_tsvector('english', coalesce(NEW.title,'')), 'A') ||
setweight(to_tsvector('english', coalesce(NEW.body,'')), 'B');
RETURN NEW;
END $$ LANGUAGE plpgsql;
Use the bundle's functions only for the query side. That gives you the ranking behaviour without the sync bugs.
Two-stage ranking. Do the @@ match in a subquery with a LIMIT, then rank the survivors. ts_rank in an ORDER BY over the whole table forces a sort of every matching row; the two-stage form keeps it fast as the corpus grows.
Weight discipline. setweight(..., 'A') for title, 'B' for summary, 'D' for tags. Keep the weight assignments identical in the trigger and in any test fixtures or results will drift.
Language/regconfig consistency. The config used in to_tsvector at index time must match the one in to_tsquery at search time. The bundle's config key for this is easy to leave at a default; pin it explicitly and use 'simple' if your content is multilingual.
Laravel translation of the same pattern (if that's your target):
Post::whereRaw("search_vector @@ websearch_to_tsquery('english', ?)", [$term])
->orderByRaw("ts_rank(search_vector, websearch_to_tsquery('english', ?)) DESC", [$term])
->paginate();
Populate search_vector with a model observer and a DB trigger for bulk writes, and use websearch_to_tsquery on PG 11+ instead of the bundle's to_tsquery plumbing — it handles quoted phrases and OR without throwing syntax errors.
src/Doctrine/ and register them manually.to_tsquery throws on user input. "cat & dog" or a trailing : raises syntax error in tsquery. Always sanitise, or use plainto_tsquery / websearch_to_tsquery.to_tsquery('') is valid and @@ returns true for all rows — guard for empty/whitespace search terms before hitting the query builder.doctrine:schema:update --force. Either exclude the entity from schema management or make all migrations raw SQL and stop using schema diffing on that table.tsvector into a string property usually works with PDO but breaks on some drivers and on json-serialising entities. Select it only where you need it.$em->createQuery('UPDATE ...')->execute() and Doctrine\DBAL writes will not fire preUpdate. Recompute the vector in the same SQL statement or rely on triggers.@@, not ts_rank. If sorting is slow, restrict by published_at/tenant first, then rank.FunctionNode subclasses — override getSql() to swap in ts_rank_cd or websearch_to_tsquery without touching the bundle's registration. The listener is also a normal Doctrine event subscriber and can be disabled in config if you move to triggers.EXPLAIN (ANALYZE, BUFFERS) the generated SQL before trusting the abstraction. Half the time the complaint is "search is slow" and the plan shows a seq scan because the GIN index was never created — Doctrine's schema tool does not create index types for you, so CREATE INDEX ... USING GIN (search_vector) must be in a hand-written migration.Bottom line: treat spine/postgres-search-bundle as a reference implementation of Postgres FTS in Doctrine, not a dependency. Read the DQL function classes, lift the trigger/query pattern, and you get the benefit without inheriting a package that has been unmaintained since PHP 7.0 was current.
How can I help you explore Laravel packages today?