When an application's search requirements outgrow simple SQL LIKE '%query%' queries, the default reaction for many engineering teams is to provision an external search cluster: Elasticsearch, Meilisearch, or Typesense.
While dedicated search engines are indispensable for multi-node multi-terabyte search clusters, introducing them for moderate-scale datasets (under 10 million records) introduces heavy infrastructure tax: additional RAM footprint, separate JVM/Rust processes, background sync queues, and inevitable data sync drift when background workers fail.
If you are running PostgreSQL with Laravel, you already possess a world-class, production-grade search engine capable of sub-10ms queries across millions of rows: tsvector lexical tokenization, stemming dictionaries, GIN (Generalized Inverted Indexes), and multi-field weighted ranking.
In this guide, we implement native PostgreSQL Full-Text Search in Laravel from scratch: utilizing generated stored columns, weighted search vectors, prefix wildcard matching, and clean Eloquent query scopes.
How PostgreSQL Full-Text Search Works Internally
Standard B-Tree indexes cannot index arbitrary substrings in text columns. PostgreSQL solves this by decoupling full-text search into two specialized data types and an inverted index:
| Concept | PostgreSQL Component | Role in Search Pipeline |
|---|---|---|
| Document Tokenization | to_tsvector('english', text) |
Parses raw text into normalized, stemmed lexemes (e.g., "architectures" and "architecting" → 'architectur':1,2) with stop-word removal. |
| Query Representation | plainto_tsquery('english', query) |
Converts user input into a boolean search query (e.g., 'redis' & 'cache'). |
| Inverted Indexing | USING GIN (search_vector) |
Maps every unique lexeme to a list of exact row pointers (TIDs). Search time is O(log N) regardless of document length. |
| Relevance Ranking | ts_rank(search_vector, query) |
Scores results by term frequency (TF-IDF), proximity, and custom field weights (Title weight 'A' > Excerpt 'B' > Body 'C'). |
1. Database Migration: Adding Generated tsvector Column & GIN Index
Instead of calculating to_tsvector() on every search query, we create a Generated Stored Column that automatically updates whenever the post title, excerpt, or content changes:
<?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('posts', function (Blueprint $table) {
// 1. Add generated tsvector column with custom field weighting
DB::statement("
ALTER TABLE posts
ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
setweight(to_tsvector('english', coalesce(excerpt, '')), 'B') ||
setweight(to_tsvector('english', coalesce(content, '')), 'C')
) STORED;
");
// 2. Create high-performance GIN index
DB::statement("CREATE INDEX idx_posts_search_vector ON posts USING gin (search_vector);");
});
}
public function down(): void
{
Schema::table('posts', function (Blueprint $table) {
DB::statement("DROP INDEX IF EXISTS idx_posts_search_vector;");
DB::statement("ALTER TABLE posts DROP COLUMN IF EXISTS search_vector;");
});
}
};
2. Implementing the Eloquent Search Scope in Laravel
In your App\Models\Post model, add a clean, chainable scopeSearch() query scope that handles normalization, prefix wildcard matching, and relevance ranking:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
class Post extends Model
{
/**
* High-Performance PostgreSQL Full-Text Search Scope.
*/
public function scopeSearch(Builder $query, ?string $term): Builder
{
if (blank($term)) {
return $query;
}
// 1. Sanitize and prepare prefix query (e.g. "redi cach" -> "redi:* & cach:*")
$words = collect(explode(' ', trim($term)))
->filter()
->map(fn ($word) => Str::slug($word))
->filter()
->map(fn ($word) => "{$word}:*")
->implode(' & ');
if (empty($words)) {
return $query;
}
// 2. Execute GIN index lookup using the @@ match operator and order by ts_rank
return $query
->whereRaw("search_vector @@ to_tsquery('english', ?)", [$words])
->orderByRaw("ts_rank(search_vector, to_tsquery('english', ?)) DESC", [$words]);
}
}
3. Querying in Controllers & Benchmark Comparison
Using the search scope in your controller is as simple as standard Eloquent:
<?php
namespace App\Http\Controllers;
use App\Models\Post;
use Illuminate\Http\Request;
class BlogSearchController extends Controller
{
public function __invoke(Request $request)
{
$posts = Post::published()
->search($request->query('q'))
->with(['categories', 'tags'])
->paginate(12)
->withQueryString();
return view('blog.index', compact('posts'));
}
}
Benchmark on 1,000,000 Articles:
Query: "redis architecture"
-------------------------------------------------------------------------
1. SQL LIKE '%redis%': 1,480ms (Full table sequential scan O(N))
2. PostgreSQL GIN tsvector: 4.2ms (Index scan O(log N) via GIN bitmap)
3. Elasticsearch (over HTTP): 18.5ms (Network round-trip + JSON deserialization)
4. Production Hardening: Handling Typos with pg_trgm Fuzzy Matching
If you want fuzzy matching for typos (e.g. searching "redsi" matches "redis"), enable PostgreSQL's built-in Trigram extension:
-- Enable pg_trgm extension
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- Create Trigram GIN index on title
CREATE INDEX idx_posts_title_trgm ON posts USING gin (title gin_trgm_ops);
-- Fuzzy search query with similarity threshold:
SELECT * FROM posts WHERE similarity(title, 'redsi') > 0.3 ORDER BY similarity(title, 'redsi') DESC;
Production Checklist for Database Full-Text Search
- ✓ Always use
STOREDgenerated columns: Pre-calculating vectors during write time eliminates runtime CPU tokenization costs. - ✓ Use weighted vectors (
setweight): Ensures title matches rank higher than deep paragraph mentions. - ✓ Use prefix wildcard matching (
word:*): Matches incomplete search queries as the user types (e.g. "dock" matches "docker"). - ✓ Zero Sync Drift: Enjoy 100% ACID consistency—when an article is published or updated, search results update instantly with zero queue delay.
Conclusion
Before adding the architectural complexity and hosting cost of external search clusters to your cloud infrastructure, leverage PostgreSQL's native tsvector and GIN indexing engine. It delivers blazing sub-10ms response times, zero data drift, and seamless integration with Laravel Eloquent.