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, ts\_rank, and Weighted Queries

 PostgreSQL Full-Text Search in Laravel: Indexes, ts\_rank, and Weighted Queries
================================================================================

 Skip Algolia for many use cases. PostgreSQL's built-in full-text search handles ranked, weighted, multi-column queries well. Here's how to wire it cleanly into Laravel with proper indexes and Eloquent scopes.

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

ShareCopy linkCopied

 ![PostgreSQL Full-Text Search in Laravel: Indexes, ts_rank, and Weighted Queries](https://cdn.msaied.com/421/d1aeb29c36e4b62b103b6a2e0a534590.png) 

  On this page +1. [Why PostgreSQL Full-Text Search Is Often Enough](#why-postgresql-full-text-search-is-often-enough)
2. [Setting Up a Generated tsvector Column](#setting-up-a-generated-codetsvectorcode-column)
3. [A Composable Eloquent Scope](#a-composable-eloquent-scope)
4. [Handling Partial / Prefix Queries](#handling-partial-prefix-queries)
5. [Highlighting Matched Terms](#highlighting-matched-terms)
6. [Testing the Search Scope](#testing-the-search-scope)
7. [Key Takeaways](#key-takeaways)

 Why PostgreSQL Full-Text Search Is Often Enough
-----------------------------------------------

Before reaching for Algolia, Meilisearch, or Elasticsearch, consider what PostgreSQL already provides: ranked results, stemming, stop-word filtering, multi-language dictionaries, and weighted column importance — all inside the same ACID-compliant database your app already uses.

For datasets under a few million rows with moderate query volume, a well-indexed `tsvector` column outperforms an external service in operational simplicity and latency.

---

Setting Up a Generated `tsvector` Column
----------------------------------------

The cleanest approach is a **generated stored column** — PostgreSQL maintains it automatically on insert and update.

```php
// database/migrations/2024_06_01_000000_add_search_vector_to_articles.php

public function up(): void
{
    DB::statement("
        ALTER TABLE articles
        ADD COLUMN search_vector tsvector
        GENERATED ALWAYS AS (
            setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
            setweight(to_tsvector('english', coalesce(subtitle, '')), 'B') ||
            setweight(to_tsvector('english', coalesce(body, '')), 'C')
        ) STORED
    ");
}

```

Weights `A`–`D` influence `ts_rank` scoring. Title matches outrank body matches automatically.

Now add a GIN index — the right index type for `tsvector`:

```php
public function up(): void
{
    DB::statement(
        'CREATE INDEX articles_search_vector_gin ON articles USING GIN (search_vector)'
    );
}

```

A GiST index is an alternative but GIN is faster for read-heavy search workloads.

---

A Composable Eloquent Scope
---------------------------

Wrap the query logic in a reusable scope so callers stay expressive:

```php
// app/Models/Scopes/FullTextSearchScope.php

namespace App\Models\Scopes;

use Illuminate\Database\Eloquent\Builder;

trait FullTextSearchable
{
    public function scopeSearch(Builder $query, string $term): Builder
    {
        $tsQuery = 'plainto_tsquery(\'english\', ?)';

        return $query
            ->whereRaw("search_vector @@ {$tsQuery}", [$term])
            ->orderByRaw("ts_rank(search_vector, {$tsQuery}) DESC", [$term]);
    }
}

```

Usage in a controller or action:

```php
$results = Article::search('event sourcing laravel')
    ->where('published', true)
    ->cursorPaginate(20);

```

The `@@` operator uses the GIN index. `ts_rank` is computed only for matching rows, so it's cheap.

---

Handling Partial / Prefix Queries
---------------------------------

`plainto_tsquery` doesn't support prefix matching. For autocomplete-style input, switch to `to_tsquery` with a `:*` suffix:

```php
public function scopeSearchPrefix(Builder $query, string $term): Builder
{
    // Sanitise: strip non-word characters, append :*
    $safe = preg_replace('/[^\w\s]/u', '', $term);
    $lexemes = collect(explode(' ', trim($safe)))
        ->filter()
        ->map(fn ($w) => $w . ':*')
        ->implode(' & ');

    return $query
        ->whereRaw("search_vector @@ to_tsquery('english', ?)", [$lexemes])
        ->orderByRaw("ts_rank(search_vector, to_tsquery('english', ?)) DESC", [$lexemes]);
}

```

Always sanitise user input before constructing a `to_tsquery` expression — malformed lexemes throw a PostgreSQL error.

---

Highlighting Matched Terms
--------------------------

PostgreSQL's `ts_headline` returns a snippet with matches highlighted:

```php
$results = Article::search($term)
    ->selectRaw("
        id, title, published_at,
        ts_headline(
            'english', body,
            plainto_tsquery('english', ?),
            'MaxWords=35, MinWords=15, ShortWord=3'
        ) AS snippet
    ", [$term])
    ->cursorPaginate(20);

```

`ts_headline` is CPU-intensive — only call it on the final paginated slice, never on a full table scan.

---

Testing the Search Scope
------------------------

```php
it('ranks title matches above body matches', function () {
    $titleMatch = Article::factory()->create([
        'title' => 'Event Sourcing in Laravel',
        'body'  => 'Some unrelated content here.',
    ]);
    $bodyMatch = Article::factory()->create([
        'title' => 'Unrelated Title',
        'body'  => 'Event sourcing is a pattern for recording state changes.',
    ]);

    $results = Article::search('event sourcing')->pluck('id');

    expect($results->first())->toBe($titleMatch->id);
});

```

This test requires a real PostgreSQL connection — use a dedicated test database, not SQLite.

---

Key Takeaways
-------------

- Use a **generated stored `tsvector` column** to keep indexing automatic and queries simple.
- A **GIN index** on the `tsvector` column is mandatory for production performance.
- Weight columns (`A`–`D`) so `ts_rank` naturally promotes title matches over body matches.
- Use `plainto_tsquery` for phrase input and `to_tsquery` with `:*` for prefix/autocomplete.
- Call `ts_headline` only on the paginated result set, not during filtering.
- Keep search logic in a **trait-based Eloquent scope** for reuse across models.

- [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 
---------------------------

  Can I use PostgreSQL full-text search with Laravel's SQLite test database?No. tsvector, GIN indexes, and ts\_rank are PostgreSQL-specific. For tests that exercise full-text search, connect to a real PostgreSQL instance — a Docker service in CI works well.

   When should I choose an external search engine over PostgreSQL FTS?Consider Meilisearch or Elasticsearch when you need typo-tolerance, faceted filtering, synonyms, or real-time index replication across services. For straightforward ranked keyword search on a single database, PostgreSQL FTS is simpler and avoids an extra infrastructure dependency.

   Does the generated tsvector column update automatically when I update a row?Yes. A GENERATED ALWAYS AS ... STORED column is recomputed by PostgreSQL on every INSERT and UPDATE, so you never need application-level triggers or observers to keep it in sync.

   ![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 articleThe Pipeline Pattern in Laravel: Custom Pipelines Beyond Middleware](https://www.msaied.com/public/articles/the-pipeline-pattern-in-laravel-custom-pipelines-beyond-middleware) [Next articleLaravel Quota: Enforce Usage Budgets for Calendar Periods in Laravel](https://www.msaied.com/public/articles/laravel-quota-enforce-usage-budgets-for-calendar-periods-in-laravel)  

   On this page
-------------

1. [Why PostgreSQL Full-Text Search Is Often Enough](#why-postgresql-full-text-search-is-often-enough)
2. [Setting Up a Generated tsvector Column](#setting-up-a-generated-codetsvectorcode-column)
3. [A Composable Eloquent Scope](#a-composable-eloquent-scope)
4. [Handling Partial / Prefix Queries](#handling-partial-prefix-queries)
5. [Highlighting Matched Terms](#highlighting-matched-terms)
6. [Testing the Search Scope](#testing-the-search-scope)
7. [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)
