PostgreSQL Full-Text Search in Laravel: Indexes, Ranking, and Multilingual Queries
#laravel #postgresql #full-text-search #eloquent #performance

PostgreSQL Full-Text Search in Laravel: Indexes, Ranking, and Multilingual Queries

4 min read Mohamed Said Mohamed Said

Why Reach for PostgreSQL FTS Before a Search Service

Algolia and Meilisearch are excellent, but they add operational cost, sync lag, and a network hop. For most SaaS products under a few million rows, PostgreSQL's native full-text search is fast enough, cheaper, and transactionally consistent with your writes.

This article covers the practical path: stored tsvector columns, GIN indexes, ranked results, and a clean Eloquent integration — including multilingual dictionary selection.


Storing a tsvector Column

Computing the vector on every query is wasteful. Store it as a generated column (PostgreSQL 12+) or maintain it via a trigger. The generated column approach is cleaner:

ALTER TABLE articles
  ADD COLUMN search_vector tsvector
  GENERATED ALWAYS AS (
    setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
    setweight(to_tsvector('english', coalesce(body, '')), 'B')
  ) STORED;

CREATE INDEX articles_search_vector_gin
  ON articles USING GIN (search_vector);

Weighting title as A and body as B lets ts_rank surface title matches above body-only matches automatically.


Querying from Eloquent

Laravel's query builder doesn't have a first-class FTS method, but raw expressions compose cleanly:

use Illuminate\Support\Facades\DB;

$query = 'laravel performance';

$articles = Article::query()
    ->whereRaw(
        "search_vector @@ plainto_tsquery('english', ?)",
        [$query]
    )
    ->orderByRaw(
        "ts_rank(search_vector, plainto_tsquery('english', ?)) DESC",
        [$query]
    )
    ->paginate(20);

plainto_tsquery is forgiving with user input — it ignores operators and treats the string as an AND of lexemes. Use websearch_to_tsquery (PostgreSQL 11+) when you want Google-style syntax ("exact phrase" OR term -exclude).


Encapsulating the Logic in a Scope

Avoid scattering raw SQL across controllers. A dedicated scope keeps things testable:

// app/Models/Scopes/FullTextSearchScope.php
namespace App\Models\Scopes;

use Illuminate\Database\Eloquent\Builder;

trait FullTextSearchable
{
    public function scopeSearch(Builder $query, string $term, string $lang = 'english'): Builder
    {
        $tsQuery = "websearch_to_tsquery('{$lang}', ?)";

        return $query
            ->whereRaw("search_vector @@ {$tsQuery}", [$term])
            ->orderByRaw("ts_rank(search_vector, {$tsQuery}) DESC", [$term]);
    }
}
// Usage
Article::search($request->input('q'))->paginate(20);

Security note: The $lang parameter is interpolated directly. Validate it against an allowlist (['english', 'french', 'german']) before passing it in.


Multilingual Dictionaries

PostgreSQL ships with dictionaries for dozens of languages. Store the user's preferred language on the tenant or user record and pass it through:

$lang = in_array($user->locale, ['english','french','german','spanish'])
    ? $user->locale
    : 'simple'; // 'simple' = no stemming, safe fallback

Article::search($request->q, $lang)->paginate(20);

The simple dictionary skips stemming entirely — useful for proper nouns, product codes, or when you can't determine the language.


Keeping the Vector Fresh on Updates

Generated columns update automatically on INSERT and UPDATE. If you're on PostgreSQL < 12 or need cross-table data in the vector, use a trigger or a queued observer:

// app/Observers/ArticleObserver.php
public function saved(Article $article): void
{
    DB::statement(
        "UPDATE articles
         SET search_vector =
           setweight(to_tsvector('english', coalesce(title,'')), 'A') ||
           setweight(to_tsvector('english', coalesce(body,'')), 'B')
         WHERE id = ?",
        [$article->id]
    );
}

For high-write tables, dispatch this as a queued job to avoid blocking the HTTP response.


Trigram Fuzzy Matching as a Complement

FTS won't match partial words or typos. Add pg_trgm for fuzzy prefix search:

CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX articles_title_trgm ON articles USING GIN (title gin_trgm_ops);
->orWhereRaw('title % ?', [$query]) // similarity threshold default 0.3

Combine both: FTS for ranked semantic matches, trigram for autocomplete and typo tolerance.


Key Takeaways

  • Store tsvector as a generated column and index it with GIN — never compute it per query.
  • Use websearch_to_tsquery for user-facing input; it handles operators and is injection-safe.
  • Weight columns (AD) so ts_rank reflects content importance, not just frequency.
  • Validate language dictionary names against an allowlist before interpolating into SQL.
  • Layer pg_trgm on top for fuzzy/prefix matching without a separate search service.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Does a GIN index on tsvector slow down writes significantly?
GIN indexes have higher write overhead than B-tree, but for most content-heavy tables the impact is acceptable. If write throughput is critical, consider a GiST index instead — it's faster to update but slightly slower to query — or maintain the vector asynchronously via a queued job.
Q02 Can I use this approach with Laravel Scout?
Yes. You can write a custom Scout driver that issues the tsvector queries under the hood, giving you Scout's clean API while keeping everything in PostgreSQL. For simpler needs, a plain Eloquent scope as shown above is often sufficient and easier to debug.
Q03 What happens when a user searches in a language different from the stored dictionary?
Stemming mismatches reduce recall — e.g., a French query against an English-stemmed vector will miss inflected forms. Store the content language per row or per tenant and select the matching dictionary at query time. Fall back to 'simple' when the language is unknown.

Continue reading

More Articles

View all