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.













