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 many use-cases. Learn how to wire PostgreSQL full-text search directly into Laravel using tsvector columns, GIN indexes, ts\_rank, and phrase queries — with zero external services.

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

ShareCopy linkCopied

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

  On this page +1. [Why PostgreSQL Full-Text Search Is Underused in Laravel](#why-postgresql-full-text-search-is-underused-in-laravel)
2. [Setting Up the tsvector Column](#setting-up-the-tsvector-column)
3. [Querying from Eloquent](#querying-from-eloquent)
4. [Phrase Queries and Prefix Matching](#phrase-queries-and-prefix-matching)
5. [Multilingual Configuration](#multilingual-configuration)
6. [Highlighting Snippets](#highlighting-snippets)
7. [Key Takeaways](#key-takeaways)

 Why PostgreSQL Full-Text Search Is Underused in Laravel
-------------------------------------------------------

Most Laravel projects reach for Algolia or Meilisearch the moment a client says "search". Both are excellent, but they add operational cost, sync complexity, and eventual-consistency headaches. PostgreSQL's built-in full-text search handles millions of rows with sub-10ms queries when set up correctly. This article shows you the exact migration, model wiring, and query patterns to make it production-ready.

---

Setting Up the tsvector Column
------------------------------

Store a pre-computed search vector alongside your data. A generated column keeps it in sync automatically — no triggers, no observers.

```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(body, '')), 'B')
        ) STORED
    ");

    DB::statement(
        'CREATE INDEX articles_search_vector_gin ON articles USING GIN (search_vector)'
    );
}

```

The `GENERATED ALWAYS AS … STORED` syntax (PostgreSQL 12+) means the column is recomputed on every `INSERT` or `UPDATE` with no application-level code. `setweight` assigns priority: title matches outrank body matches during ranking.

---

Querying from Eloquent
----------------------

Wrap the raw SQL in a clean local scope so callers never see the plumbing.

```php
// app/Models/Article.php
use Illuminate\Database\Eloquent\Builder;

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

    return $query
        ->whereRaw("search_vector @@ {$tsQuery}", [$term])
        ->selectRaw(
            "*, ts_rank(search_vector, {$tsQuery}) AS rank",
            [$term]
        )
        ->orderByDesc('rank');
}

```

Usage is clean:

```php
$results = Article::search('event sourcing laravel')
    ->with('author')
    ->paginate(20);

```

`plainto_tsquery` tokenises a plain string safely — no need to sanitise operators. Use `phraseto_tsquery` when word order matters (e.g. "event sourcing" as a phrase).

---

Phrase Queries and Prefix Matching
----------------------------------

```php
// Exact phrase
DB::raw("search_vector @@ phraseto_tsquery('english', ?)")

// Prefix (autocomplete-style) — note the :* operator
DB::raw("search_vector @@ to_tsquery('english', ? || ':*')")

```

Prefix matching is useful for live search inputs. Combine it with a `LIMIT 10` and a covering index on `(search_vector, id, title)` to avoid a heap fetch.

---

Multilingual Configuration
--------------------------

PostgreSQL ships with text-search configurations for dozens of languages. Store the user's locale and pass the matching configuration name:

```php
public function scopeSearch(Builder $query, string $term, string $lang = 'english'): Builder
{
    // Allowlist to prevent SQL injection via the config name
    $allowed = ['english', 'french', 'german', 'spanish', 'portuguese'];
    $config  = in_array($lang, $allowed, true) ? $lang : 'english';

    return $query
        ->whereRaw(
            "search_vector @@ plainto_tsquery('{$config}', ?)",
            [$term]
        );
}

```

For truly multilingual content in a single table, store multiple vectors — one per language — and query the appropriate column based on the request locale.

---

Highlighting Snippets
---------------------

Return highlighted excerpts without a second round-trip:

```php
->selectRaw(
    "ts_headline(
        'english',
        body,
        plainto_tsquery('english', ?),
        'MaxWords=35, MinWords=15, StartSel=, StopSel='
    ) AS excerpt",
    [$term]
)

```

Bind the result to a virtual attribute on the model:

```php
protected $appends = ['excerpt'];

public function getExcerptAttribute(): ?string
{
    return $this->attributes['excerpt'] ?? null;
}

```

---

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

- **Generated stored columns** keep `tsvector` in sync with zero application code.
- **GIN indexes** make `@@` queries fast even on millions of rows.
- **`ts_rank`** gives relevance ordering; `setweight` lets you tune title vs. body priority.
- **`plainto_tsquery`** is safe for user input; `to_tsquery` with `:*` enables prefix search.
- **`ts_headline`** returns highlighted snippets in the same query, avoiding extra round-trips.
- Allowlist language config names before interpolating them into SQL.

- [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)
- [performance](https://www.msaied.com/public/articles?search=performance)

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

  Does a generated tsvector column work with Laravel's Eloquent update methods?Yes. Because the column is `GENERATED ALWAYS AS … STORED`, PostgreSQL recomputes it automatically on every INSERT or UPDATE regardless of how the write originates — Eloquent, raw queries, or migrations. You cannot manually set the column value; PostgreSQL will reject it.

   When should I still choose Algolia or Meilisearch over PostgreSQL FTS?Reach for a dedicated search engine when you need faceted filtering with real-time index updates across distributed replicas, typo-tolerance out of the box, or when your search index must span multiple databases or microservices. For a single PostgreSQL database with straightforward keyword and phrase search, the built-in FTS is usually sufficient and simpler to operate.

   How do I handle accented characters and case folding in multilingual search?Install the `unaccent` PostgreSQL extension and add it to your text-search configuration: `ALTER TEXT SEARCH CONFIGURATION english ALTER MAPPING FOR hword, hword\_part, word WITH unaccent, english\_stem;`. This normalises accented characters at index and query time so 'café' matches 'cafe'.

   ![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 articlePrivacy Filter: Detect and Redact PII in Text from Laravel](https://www.msaied.com/public/articles/privacy-filter-detect-and-redact-pii-in-text-from-laravel) [Next articleMySQL EXPLAIN and Index Optimization for Laravel Developers](https://www.msaied.com/public/articles/mysql-explain-and-index-optimization-for-laravel-developers)  

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

1. [Why PostgreSQL Full-Text Search Is Underused in Laravel](#why-postgresql-full-text-search-is-underused-in-laravel)
2. [Setting Up the tsvector Column](#setting-up-the-tsvector-column)
3. [Querying from Eloquent](#querying-from-eloquent)
4. [Phrase Queries and Prefix Matching](#phrase-queries-and-prefix-matching)
5. [Multilingual Configuration](#multilingual-configuration)
6. [Highlighting Snippets](#highlighting-snippets)
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)
