PostgreSQL Full-Text Search in Laravel | Mohamed Said       [Skip to content](#main)  [ ![](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MH.png) Mohamed SaidLaravel Backend Engineer ](https://www.msaied.com/public) - [Home](https://www.msaied.com/public)
- [Projects](https://www.msaied.com/public/projects)
- [Articles](https://www.msaied.com/public/articles)
- [Certificates](https://www.msaied.com/public/certificates)
- [About](https://www.msaied.com/public#about)

           [  Contact](https://www.msaied.com/public#contact) Menu 

Menu
----

Close 

 - [HomeStart here](https://www.msaied.com/public)
- [ProjectsCase studies](https://www.msaied.com/public/projects)
- [ArticlesEngineering notes](https://www.msaied.com/public/articles)
- [CertificatesCredentials](https://www.msaied.com/public/certificates)
- [AboutHow I work](https://www.msaied.com/public#about)
- [ContactGet in touch](https://www.msaied.com/public#contact)

  [Start a conversation](https://www.msaied.com/public#contact) [WhatsApp](https://wa.me/201094619204) [Email](mailto:hello@msaied.com) 

 1. [Home](https://www.msaied.com/public)
2. /
3. [Articles](https://www.msaied.com/public/articles)
4. /
5. PostgreSQL Full-Text Search in Laravel: Indexes, Ranking, and Multilingual Queries

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

 Skip Algolia for most projects. PostgreSQL's built-in full-text search handles ranked queries, stemming, and multilingual dictionaries — all wired into Eloquent without a third-party service.

 ![](https://cdn.msaied.com/01M22N44A70A5MC2S599JP0MPH.webp) [Mohamed Said](https://www.msaied.com/public#person) Published 30 Jun 2026 · Updated 30 Jun 2026 · 4 min read

ShareCopy linkCopied

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

  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)

 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.

- [laravel](https://www.msaied.com/public/articles?search=laravel)
- [postgresql](https://www.msaied.com/public/articles?search=postgresql)
- [full-text-search](https://www.msaied.com/public/articles?search=full-text-search)
- [eloquent](https://www.msaied.com/public/articles?search=eloquent)
- [performance](https://www.msaied.com/public/articles?search=performance)

 Frequently asked questions 
---------------------------

  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.

   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.

   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.

   ![Mohamed Said](https://cdn.msaied.com/01M22N44A70A5MC2S599JP0MPH.webp)About the author
----------------

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

[About](https://www.msaied.com/public#about) [GitHub ↗](https://github.com/EG-Mohamed) [LinkedIn ↗](https://www.linkedin.com/in/msaiedm/) [WhatsApp ↗](https://wa.me/201094619204) [Email Address ↗](mailto:hello@msaied.com) [My CV ↗](https://drive.google.com/file/u/0/d/1MF20IPRJyzfy32mhEutjL5EpSls0w2Q8/view)  

   [Previous articleMySQL Optimization for Laravel: Covering Indexes, EXPLAIN ANALYZE, and Query Profiling](https://www.msaied.com/public/articles/mysql-optimization-for-laravel-covering-indexes-explain-analyze-and-query-profiling) [Next articleLaravel Queues: Horizon Metrics, Supervisor Tuning, and Reliable Job Throughput](https://www.msaied.com/public/articles/laravel-queues-horizon-metrics-supervisor-tuning-and-reliable-job-throughput)  

   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)

 ###  Have a technical challenge?

 Tell me what you’re building. I reply within two working days.

[Start a conversation](https://www.msaied.com/public#contact) 

   Related articles
-----------------

 [ ![](https://cdn.msaied.com/740/cce86edc21eddcbdd2f2454fadaf9c70.png)  · 3 min read### The Pipeline Pattern in Laravel: Custom Pipelines Beyond Middleware

5 Oct 2026 ](https://www.msaied.com/public/articles/the-pipeline-pattern-in-laravel-custom-pipelines-beyond-middleware-1) [ ![](https://cdn.msaied.com/739/2d6897fdcdcf090613f96f72a64b8a78.png)  · 4 min read### MySQL Full-Text Search in Laravel: Indexes, Relevance Scoring, and Boolean Mode

4 Oct 2026 ](https://www.msaied.com/public/articles/mysql-full-text-search-in-laravel-indexes-relevance-scoring-and-boolean-mode) [ ![](https://cdn.msaied.com/738/073696a3fefe18bec825beec5ac658f5.png)  · 4 min read### Laravel Queue Rate-Limited Middleware: Throttling Jobs Without Losing Work

4 Oct 2026 ](https://www.msaied.com/public/articles/laravel-queue-rate-limited-middleware-throttling-jobs-without-losing-work) 

  Have a technical challenge?
----------------------------

Tell me what you’re building. I reply within two working days.

 [Discuss your project ↗](https://www.msaied.com/public#contact) 

  © 2026 Mohamed Said · Built with Laravel, meant to last.Senior Backend Engineer specializing in Laravel, scalable SaaS platforms, APIs, and cloud infrastructure. I build secure, high-performance web applications that help businesses grow.

 - [Home](https://www.msaied.com/public)
- [Articles](https://www.msaied.com/public/articles)
- [Certificates](https://www.msaied.com/public/certificates)
- [GitHub](https://github.com/EG-Mohamed)
- [LinkedIn](https://www.linkedin.com/in/msaiedm/)
- [WhatsApp](https://wa.me/201094619204)
- [Email Address](mailto:hello@msaied.com)
- [My CV](https://drive.google.com/file/u/0/d/1MF20IPRJyzfy32mhEutjL5EpSls0w2Q8/view)
- [Sitemap](https://www.msaied.com/public/sitemap.xml)
