PostgreSQL Full-Text Search in Laravel | Mohamed Said        [  ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MH.png)   Mohamed Said Laravel Backend Engineer  ](https://www.msaied.com) [ Home ](https://www.msaied.com) [ Projects ](https://www.msaied.com/projects) [ Articles  ](https://www.msaied.com/articles) [ Certificates ](https://www.msaied.com/certificates) [ Contact ](https://www.msaied.com#contact-section) 

       [  ](https://github.com/EG-Mohamed)       

 [ Home ](https://www.msaied.com) [ Projects ](https://www.msaied.com/projects) [ Articles ](https://www.msaied.com/articles) [ Certificates ](https://www.msaied.com/certificates) [ Contact ](https://www.msaied.com#contact-section) 

  [ home ](https://www.msaied.com)    [ articles ](https://www.msaied.com/articles)    PostgreSQL Full-Text Search in Laravel: Indexes, Ranking, and Multilingual Queries        On this page       1. [  Why Reach for PostgreSQL FTS Before a Search Service ](#why-reach-for-postgresql-fts-before-a-search-service)
2. [  Storing a tsvector Column ](#storing-a-tsvector-column)
3. [  Querying from Eloquent ](#querying-from-eloquent)
4. [  Encapsulating the Logic in a Scope ](#encapsulating-the-logic-in-a-scope)
5. [  Multilingual Dictionaries ](#multilingual-dictionaries)
6. [  Keeping the Vector Fresh on Updates ](#keeping-the-vector-fresh-on-updates)
7. [  Trigram Fuzzy Matching as a Complement ](#trigram-fuzzy-matching-as-a-complement)
8. [  Key Takeaways ](#key-takeaways)

  ![PostgreSQL Full-Text Search in Laravel: Indexes, Ranking, and Multilingual Queries](https://cdn.msaied.com/328/aeca4a50362c2e891a402fedf4635382.png)

  #laravel   #postgresql   #full-text-search   #eloquent   #performance  

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

     30 Jun 2026      4 min read    ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MJ.jpg)  Mohamed Said  

       Table of contents

1. [  01   Why Reach for PostgreSQL FTS Before a Search Service  ](#why-reach-for-postgresql-fts-before-a-search-service)
2. [  02   Storing a tsvector Column  ](#storing-a-tsvector-column)
3. [  03   Querying from Eloquent  ](#querying-from-eloquent)
4. [  04   Encapsulating the Logic in a Scope  ](#encapsulating-the-logic-in-a-scope)
5. [  05   Multilingual Dictionaries  ](#multilingual-dictionaries)
6. [  06   Keeping the Vector Fresh on Updates  ](#keeping-the-vector-fresh-on-updates)
7. [  07   Trigram Fuzzy Matching as a Complement  ](#trigram-fuzzy-matching-as-a-complement)
8. [  08   Key Takeaways  ](#key-takeaways)

 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:

```sql
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:

```php
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:

```php
// 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]);
    }
}

```

```php
// 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:

```php
$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 &lt; 12 or need cross-table data in the vector, use a trigger or a queued observer:

```php
// 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:

```sql
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX articles_title_trgm ON articles USING GIN (title gin_trgm_ops);

```

```php
->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 (`A`–`D`) 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?

          [  ](https://twitter.com/intent/tweet?url=https%3A%2F%2Fwww.msaied.com%2Farticles%2Fpostgresql-full-text-search-in-laravel-indexes-ranking-and-multilingual-queries-1&text=PostgreSQL+Full-Text+Search+in+Laravel%3A+Indexes%2C+Ranking%2C+and+Multilingual+Queries) [  ](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fwww.msaied.com%2Farticles%2Fpostgresql-full-text-search-in-laravel-indexes-ranking-and-multilingual-queries-1) 

 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    ](https://www.msaied.com/articles) 

 [ ![Filament v5 Preview: Schema Unification, Performance Shifts, and How to Prepare](https://cdn.msaied.com/340/1a05ca68637b898b676efb66f22e627f.png) filament laravel php 

### Filament v5 Preview: Schema Unification, Performance Shifts, and How to Prepare

Filament v5 is reshaping how panels, forms, and tables are composed. This deep-dive covers the confirmed archi...

  ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MJ.jpg)  Mohamed Said 

 1 Jul 2026     4 min read  

  Read    

 ](https://www.msaied.com/articles/filament-v5-preview-schema-unification-performance-shifts-and-how-to-prepare) [ ![Laravel 13: New Features, Helpers, and Practical Upgrade Notes](https://cdn.msaied.com/339/58c4fa6fe9b6d25a2dac17c621b6f4c6.png) laravel laravel-13 upgrade 

### Laravel 13: New Features, Helpers, and Practical Upgrade Notes

Laravel 13 ships with async-first defaults, a leaner bootstrapping layer, and several quality-of-life helpers....

  ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MJ.jpg)  Mohamed Said 

 1 Jul 2026     3 min read  

  Read    

 ](https://www.msaied.com/articles/laravel-13-new-features-helpers-and-practical-upgrade-notes) [ ![Laravel 12: Structured Route Files, Slim Skeletons, and the New Application Bootstrapping](https://cdn.msaied.com/337/05b39d16d0f88a5fb94d0cf74049b88b.png) laravel laravel-12 upgrade 

### Laravel 12: Structured Route Files, Slim Skeletons, and the New Application Bootstrapping

Laravel 12 ships with a leaner skeleton, first-class route file organisation, and a revised application bootst...

  ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MJ.jpg)  Mohamed Said 

 1 Jul 2026     3 min read  

  Read    

 ](https://www.msaied.com/articles/laravel-12-structured-route-files-slim-skeletons-and-the-new-application-bootstrapping) 

   [  ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MH.png)   Mohamed Said Laravel Backend Engineer  ](https://www.msaied.com)Senior Backend Engineer specializing in Laravel, scalable SaaS platforms, APIs, and cloud infrastructure. I build secure, high-performance web applications that help businesses grow.

Explore

- [Home](https://www.msaied.com)
- [Projects](https://www.msaied.com/projects)
- [Articles](https://www.msaied.com/articles)
- [Certificates](https://www.msaied.com/certificates)
- [Contact](https://www.msaied.com#contact-section)

Connect

- [   hello@msaied.com ](mailto:hello@msaied.com)
- [   +20 109 461 9204 ](tel:+201094619204)

© 2026 Mohamed Said. All rights reserved.

 [  ](https://github.com/EG-Mohamed) [  ](https://www.linkedin.com/in/msaiedm/) [  ](https://wa.me/201094619204) [  ](mailto:hello@msaied.com) [  ](https://drive.google.com/file/u/0/d/1MF20IPRJyzfy32mhEutjL5EpSls0w2Q8/view)
