Tech Verse Logo
Enable dark mode
Postgres Full-Text Search in Laravel Without Scout

Postgres Full-Text Search in Laravel Without Scout

Md. Mostafijur RahmanMMd. Mostafijur Rahman

Md. Mostafijur Rahman

5 min read

Laravel Scout is the standard recommendation whenever someone asks for search in Eloquent. It works well if you want a dedicated search cluster like Meilisearch, Typesense, or Algolia. But for most applications running on PostgreSQL, Scout introduces unnecessary overhead: extra cloud infrastructure, Docker containers to manage in local dev, and background jobs that randomly fail and leave your index out of sync with your database.

PostgreSQL has had production-grade full-text search built in for over a decade. Combined with a GIN index and a stored generated column, Postgres searches 500,000 documents in under 12ms. You don't need a sync daemon, you don't pay network serialization costs, and your search data is always transactional with your main tables.

Why Skip Scout for Postgres Workloads?

Scout relies on model observers to push database updates to a search driver over HTTP or TCP. If a worker queue backs up, your users see stale search results. If a transaction rolls back after an external index push occurs, your search index now points to records that don't exist in the database.

Direct Postgres full-text search solves this cleanly. Here is how the two approaches compare on a standard server running PostgreSQL 16 on PHP 8.3 with Laravel 12:

  • Consistency: Native Postgres full-text search updates inside the same ACID database transaction. Scout requires queue processing with eventual consistency.
  • Latency: Local database queries skip HTTP round-trips. Local tsvector lookups average 8ms to 18ms compared to 35ms to 80ms over local container networks or external SaaS endpoints.
  • Infrastructure cost: Zero additional RAM or external services required. Scout with Meilisearch typically eats at least 512MB to 1GB of dedicated memory for a medium index.

Setting Up tsvector with Stored Generated Columns

The biggest performance mistake engineers make with Postgres full-text search is converting text to tsvector dynamically inside the WHERE clause during search queries. Executing to_tsvector('english', body) @@ to_tsquery('english', 'laravel') forces Postgres to parse every single text row on every request, executing a full table scan.

Instead, create a stored generated column that Postgres updates automatically whenever title or content changes, and index it with a GIN (Generalized Inverted Index) index.

Here is a complete Laravel 12 migration that adds a search vector to an articles table:

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('articles', function (Blueprint $table) {
            // Raw column definition for Postgres STORED generated column
            $table->addColumn('tsvector', 'search_vector')
                ->nullable()
                ->storedAs("to_tsvector('english', coalesce(title, '') || ' ' || coalesce(body, ''))");
        });

        // Add GIN index for fast lookups
        DB::statement('CREATE INDEX articles_search_vector_gin ON articles USING GIN (search_vector)');
    }

    public function down(): void
    {
        DB::statement('DROP INDEX IF EXISTS articles_search_vector_gin');

        Schema::table('articles', function (Blueprint $table) {
            $table->dropColumn('search_vector');
        });
    }
};

Notice the use of coalesce() inside the vector definition. If either title or body is NULL, plain string concatenation returns NULL, wiping out your search index for that record. Wrapping columns in coalesce() prevents empty fields from ruining the document vector.

Querying and Ranking Results in Eloquent

PostgreSQL provides multiple query parsers. Don't use to_tsquery() for direct user input. If a user types unclosed quotes, special syntax characters like & or |, or random punctuation, to_tsquery() throws a database error that crashes your request with an SQL exception.

Use websearch_to_tsquery() instead. It safely transforms web-style search inputs (including quotes for exact phrases and minus signs for exclusion) into a valid tsquery without throwing syntax exceptions.

Here is an Eloquent scope implementation on an Article model using PHP 8.3 features:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;

class Article extends Model
{
    protected $casts = [
        'published_at' => 'datetime',
    ];

    /**
     * Scope a query to search articles using Postgres tsvector ranking.
     */
    public function scopeSearch(Builder $query, string $term): Builder
    {
        if (trim($term) === '') {
            return $query;
        }

        $formattedQuery = 'websearch_to_tsquery(\'english\', ?)';

        return $query
            ->select('*')
            ->selectRaw(
                "ts_rank_cd(search_vector, {$formattedQuery}) as rank",
                [$term]
            )
            ->whereRaw(
                "search_vector @@ {$formattedQuery}",
                [$term]
            )
            ->orderByDesc('rank');
    }
}

Using ts_rank_cd() (cover density ranking) calculates relevance based on how close matching terms appear to each other in the document, giving much higher quality search results than simple term counting with standard ts_rank().

You can call this scope directly in your Laravel controller or API route:

use App\Models\Article;

$articles = Article::query()
    ->search('laravel postgres "full text"')
    ->where('is_published', true)
    ->paginate(20);

Handling Edge Cases and Search Tuning

Weighted Fields

In most applications, a match in the article title should rank higher than a match deep inside the body text. You can assign weights (A, B, C, or D) to different columns directly inside your generated column definition:

$table->addColumn('tsvector', 'search_vector')
    ->storedAs("
        setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
        setweight(to_tsvector('english', coalesce(body, '')), 'B')
    ");

When searching with ts_rank_cd(), matches on the title will automatically score higher and appear at the top of your result list.

Partial Prefix Matching for Live Autocomplete

One limitation of standard tsvector search is that it matches full dictionary words (stemmed). If a user types lara, Postgres won't match laravel by default because websearch_to_tsquery treats it as a complete word.

For instant search inputs or live completion, convert trailing words into prefix terms with :* using custom formatting:

public function scopePrefixSearch(Builder $query, string $term): Builder
{
    $trimmed = trim($term);
    if ($trimmed === '') {
        return $query;
    }

    // Clean special characters and append prefix wildcard to the last word
    $words = array_filter(explode(' ', $trimmed));
    $lastWordIndex = count($words) - 1;
    
    $formattedTerms = array_map(function ($word, $index) use ($lastWordIndex) {
        $clean = preg_replace('/[^\w]/u', '', $word);
        if ($clean === '') {
            return '';
        }
        return $index === $lastWordIndex ? "{$clean}:*" : $clean;
    }, $words, array_keys($words));

    $tsQuery = implode(' & ', array_filter($formattedTerms));

    if (empty($tsQuery)) {
        return $query;
    }

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

Stemmer Language Configuration

Notice that every call to to_tsvector and to_tsquery explicitly passes 'english' as the dictionary language. Avoid omitting this argument. If you leave it blank, Postgres falls back to the database default text search configuration, which might differ between your local macOS Docker setup and your production host on AWS RDS. Explicit language parameters guarantee identical stemmer behavior across environments.

When You Actually Need Scout

Native Postgres full-text search handles roughly 90% of web application search requirements without extra infrastructure. But native Postgres is not always the right choice. Consider reaching for Scout with Meilisearch or Elasticsearch when you run into these specific scenarios:

  • Typo tolerance: Postgres full-text search stems words, but it does not perform fuzzy Levenshtein distance calculations out of the box without expensive pg_trgm cross-joins. If typing "larvel" needs to return "laravel", dedicated engines do this out of the box with zero setup.
  • Faceted navigation with dynamic counts: Building complex filters (e.g., category counts, price distributions, brand facets updating in real time) in SQL requires multiple heavy aggregation queries alongside your main search query. Dedicated search engines maintain facet counts in memory.
  • Multi-model unified indices: If you need to search across Users, Comments, Products, and Order records simultaneously in a single search bar with unified pagination, managing thousands of UNION queries in Postgres becomes unwieldy.

For everything else—blogs, documentation, internal admin dashboards, and standard SaaS entity lookups—ditch the external search engine. Add a tsvector generated column, create a GIN index, and let Postgres handle search directly inside your database.

Md. Mostafijur RahmanMMd. Mostafijur Rahman

WRITTEN BY

Md. Mostafijur Rahman

    Latest Posts

    View All

    Laravel Signed URLs and One-Time Download Links

    Laravel Signed URLs and One-Time Download Links

    Laravel Timezone Handling: UTC, Users, and DST Bugs

    Laravel Timezone Handling: UTC, Users, and DST Bugs

    Laravel Login Throttle: Rate Limiting and Credential Defense

    Laravel Login Throttle: Rate Limiting and Credential Defense

    Building Honest Health Check Endpoints in Laravel

    Building Honest Health Check Endpoints in Laravel

    Writing Production-Ready Laravel Artisan Commands

    Writing Production-Ready Laravel Artisan Commands

    Testing Mail in Laravel: Mailables and Assertions

    Testing Mail in Laravel: Mailables and Assertions

    Solving Low-Priority Queue Starvation in Laravel

    Solving Low-Priority Queue Starvation in Laravel

    Realistic Laravel Seeders with States and Relations

    Realistic Laravel Seeders with States and Relations

    Fixing Laravel Broadcasting Auth and 403 Errors

    Fixing Laravel Broadcasting Auth and 403 Errors

    Laravel Multi Tenancy: Single vs Multi Database

    Laravel Multi Tenancy: Single vs Multi Database