SEO Metadata
SEO Title Options
- Full-Text Search in PHP: Elasticsearch, Meilisearch
- Full-Text Search in PHP: Elasticsearch: Practical 2026
- Database Playbook: Full-Text Search in PHP: Elasticsearch
Meta Description Options
- Learn Full-Text Search in PHP: Elasticsearch, Meilisearch & Laravel Scout with a practical Database framework, expert mistakes, implementation steps.
- Compares search backends, integrates each with Laravel Scout, and benchmarks relevance and performance on large datasets.
URL Slug
full-text-search-php-elasticsearch-meilisearch-laravel-scout
Focus Keyword
Full-Text Search in PHP: Elasticsearch, Meilisearch & Laravel Scout
Additional LSI Keywords
- Database
- PHP
- Laravel Scout
- Elasticsearch
- Meilisearch
- Full-Text Search
- Full-Text Search in PHP: Elasticsearch, Meilisearch & Laravel Scout
- production checklist
- implementation guide
- best practices
- architecture decisions
- testing strategy
Table of Contents
- Article overview
- What Full-Text Search in PHP: Elasticsearch, Meilisearch & Laravel Scout means
- Why it matters now
- Implementation framework
- Practical comparison
- Expert workflow
- Common mistakes
- Media and link plan
- Original technical deep dive
- FAQ
- Structured data
- Conclusion
Article overview
Full-Text Search in PHP: Elasticsearch, Meilisearch & Laravel Scout is the kind of topic that looks simple until it reaches production. Teams usually discover the real cost late: unclear boundaries, weak defaults, hidden maintenance work, and decisions that seemed harmless when the codebase was small.
The problem gets worse when the article, tutorial, or implementation guide only explains the happy path. This guide closes that gap with a practical framework, a comparison table, common mistakes, and a deep technical section you can use while planning real work.
Keep reading for the non-obvious part: the safest implementation is rarely the most impressive-looking one. It is the one your team can debug, test, document, and evolve without turning every future change into archaeology.
Key Takeaways
- Full-Text Search in PHP: Elasticsearch, Meilisearch & Laravel Scout should be evaluated as a production decision, not only as a syntax or tooling choice.
- The best implementation keeps responsibilities visible, with clear ownership, tests, documentation, and rollback paths.
- Search visibility improves when practical depth, structured answers, and expert examples live on the same page.
[IMAGE: A mobile-first technical article layout showing the main concept, decision table, implementation checklist, and FAQ blocks. Alt: Full-Text Search in PHP: Elasticsearch, Meilisearch & Laravel Scout expert guide for Database]
What Full-Text Search in PHP: Elasticsearch, Meilisearch & Laravel Scout means
Full-Text Search in PHP: Elasticsearch, Meilisearch & Laravel Scout means applying database knowledge to a concrete engineering decision, then turning that decision into reliable code, documentation, and operational behavior. In practice, it combines the topic's core concepts with trade-off analysis, implementation boundaries, testing strategy, and maintenance discipline.
This is the definition worth optimizing for featured snippets because it avoids hype. It tells the reader what the topic does and what a professional implementation must include.
Why it matters now
The technical web is more crowded than it was a few years ago. Thin tutorials can still get indexed, but they rarely earn trust from senior developers, buyers, AI answer systems, or teams that need production guidance.
For database topics, the strongest content now has three layers:
- a clear answer for fast scanning
- a practical framework for implementation
- expert context that explains what breaks later
That same structure helps search engines understand the page. It also helps readers decide whether the advice fits their project.
Implementation framework
Use this framework before adopting the approach described in this article.
- Define the user problem and the production risk.
- Identify the smallest reliable implementation boundary.
- Keep configuration, secrets, and environment-specific behavior outside the article's core logic.
- Add tests for the behavior that would hurt if it regressed.
- Document the trade-off, not only the final code.
- Measure the result with logs, metrics, or user-facing outcomes.
- Revisit the decision after real usage exposes edge cases.
The sequence is deliberately conservative. It keeps the work grounded in outcomes instead of novelty.
[IMAGE: A seven-step implementation framework with discovery, boundary design, configuration, tests, documentation, measurement, and iteration. Alt: Full-Text Search in PHP: Elasticsearch, Meilisearch & Laravel Scout implementation framework]
Practical comparison
| Decision area | Strong approach | Weak approach | Why it matters |
|---|---|---|---|
| Scope | Solve one clear problem | Mix unrelated concerns | Focus improves testing and search intent |
| Architecture | Put logic in explicit classes or documented boundaries | Hide behavior in templates or incidental callbacks | Future changes stay easier to review |
| Data flow | Pass prepared data into the view or endpoint | Query or compute in presentation code | Reduces regressions and performance surprises |
| Testing | Cover the risky behavior directly | Test only the happy path | Catches production failures earlier |
| Documentation | Explain trade-offs and limits | Repeat generic definitions | Builds E-E-A-T and reader trust |
| Operations | Track logs, metrics, and rollback steps | Ship without measurement | Makes the decision reversible |
This table is intentionally practical. It gives a reviewer something to check before the implementation becomes expensive to change.
Expert workflow
Expert tip: "Treat Full-Text Search in PHP: Elasticsearch, Meilisearch & Laravel Scout as a system boundary. If the next developer cannot find where the decision lives, how it is tested, and when it should be avoided, the implementation is not finished."
A useful workflow is simple:
- Start with the smallest working example.
- Add the constraints that exist in your real project.
- Remove anything that only demonstrates cleverness.
- Write down the failure modes.
- Add links to related decisions so future readers can navigate the topic cluster.
That last point matters for both humans and search systems. A single article can answer a question; a cluster proves authority.
Common mistakes
Mistake 1: Copying a pattern without its context
A pattern that works in a small demo can fail in a real application. The missing context is usually data volume, team experience, deployment process, security requirements, or observability.
Before copying the pattern, ask what assumption made it safe in the original example.
Mistake 2: Putting business logic in the wrong layer
This is the fastest way to make future debugging expensive. In Laravel, PHP, and server-rendered websites, presentation should receive prepared data, not discover rules on its own.
Keep decision logic in models, actions, services, policies, requests, jobs, or documented helpers where it can be tested directly.
Mistake 3: Optimizing for novelty instead of maintainability
Newer tools and language features can be valuable. They can also hide simple behavior behind unfamiliar syntax.
Use the option that makes the next production incident easier to understand.
Mistake 4: Publishing without a measurement plan
If the article describes a performance, SEO, security, or architecture improvement, define how success will be checked. Logs, tests, crawl diagnostics, analytics, and user behavior are all stronger than assumptions.
[IMAGE: A common-mistakes board with context loss, wrong layer, novelty bias, and missing measurement highlighted. Alt: Full-Text Search in PHP: Elasticsearch, Meilisearch & Laravel Scout common mistakes]
Media and link plan
Image placeholders
- [IMAGE: A concept diagram for Full-Text Search in PHP: Elasticsearch, Meilisearch & Laravel Scout with input, decision boundary, implementation, tests, and production feedback. Alt: Full-Text Search in PHP: Elasticsearch, Meilisearch & Laravel Scout concept diagram]
- [IMAGE: A mobile screenshot-style checklist for Full-Text Search in PHP: Elasticsearch, Meilisearch & Laravel Scout. Alt: Full-Text Search in PHP: Elasticsearch, Meilisearch & Laravel Scout mobile checklist]
- [IMAGE: A comparison table visualization for strong versus weak implementation choices. Alt: Full-Text Search in PHP: Elasticsearch, Meilisearch & Laravel Scout comparison table]
Video placeholder
[VIDEO: Insert a 5-8 minute YouTube walkthrough that demonstrates the main decision, the implementation boundary, the test strategy, and the production caveats for Full-Text Search in PHP: Elasticsearch, Meilisearch & Laravel Scout.]
Trustworthy outbound links
- Laravel official documentation - use this as the trust reference for current framework behavior.
- PHP manual - use this as the trust reference for language-level reference.
Internal linking opportunities
- Internal guide: PHP and PostgreSQL: Advanced Queries, JSONB - use this when readers need a related Database follow-up.
- Internal guide: SQL vs NoSQL in PHP Apps: When to Use MySQL - use this when readers need a related Database follow-up.
Original Technical Deep Dive
Search is a read model, not a nicer LIKE query.
The moment users expect typo tolerance, faceted filters, weighted fields, synonyms, relevance tuning, or fast catalog search over millions of rows, the search layer becomes its own system. It needs indexing, backfills, queues, monitoring, and benchmarks.
This guide was reviewed on May 7, 2026 against current Laravel 13 Search and Scout documentation, Meilisearch Laravel Scout documentation, and Elastic PHP client documentation.
The short version
Use this default path:
| Need | Default |
|---|---|
| Simple keyword search inside MySQL or PostgreSQL | Laravel full-text query builder or Scout database engine |
| Laravel app search with typo tolerance and facets | Meilisearch through Laravel Scout |
| Complex analyzers, nested documents, logs, aggregations, huge search clusters | Elasticsearch through the official PHP client or a custom Scout engine |
| Small tests and prototypes | Scout collection engine |
As of May 7, 2026, Laravel Scout ships first-party engines for database, collection, Algolia, Meilisearch, and Typesense. Elasticsearch is not a first-party Scout driver. If you want Elasticsearch behind Scout, use a maintained third-party package or write a custom Scout engine.
Do not pick the engine from a benchmark blog post. Pick it from your query shape:
- Do users search natural language or product codes?
- Do filters need to be exact and tenant-safe?
- Do you need typo tolerance?
- Do you need synonyms?
- Do you need per-field weights?
- Do you need aggregations?
- Can your team operate another stateful service?
- How stale can the search index be after writes?
Search performance is easy to fake. Search relevance is harder. Measure both.
Start with the database
For many PHP applications, the cheapest correct solution is still the database.
Laravel has direct full-text query support:
$articles = Article::query()
->whereFullText(['title', 'body'], $request->string('q')->toString())
->limit(20)
->get();
Migration:
Schema::create('articles', function (Blueprint $table): void {
$table->id();
$table->string('title');
$table->text('body');
$table->timestamps();
$table->fullText(['title', 'body']);
});
Use this first when:
- Search is scoped to one table.
- The dataset fits comfortably in your primary database.
- You do not need typo tolerance.
- You do not need faceted navigation.
- Ranking can be basic.
The database option starts to strain when search crosses several models, filters become product-critical, or users expect typo-tolerant results.
Scout database engine
Scout's database engine gives you the Searchable trait and a consistent search API without running another service.
Install Scout:
composer require laravel/scout
php artisan vendor:publish --provider="Laravel\Scout\ScoutServiceProvider"
.env:
SCOUT_DRIVER=database
Model:
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Laravel\Scout\Attributes\SearchUsingFullText;
use Laravel\Scout\Attributes\SearchUsingPrefix;
use Laravel\Scout\Searchable;
final class Product extends Model
{
use Searchable;
/**
* @return array<string, mixed>
*/
#[SearchUsingPrefix(['sku'])]
#[SearchUsingFullText(['name', 'description'])]
public function toSearchableArray(): array
{
return [
'sku' => $this->sku,
'name' => $this->name,
'description' => $this->description,
];
}
}
Search:
$products = Product::search($request->string('q')->toString())
->where('tenant_id', $request->user()->tenant_id)
->simplePaginate(24);
This is a good baseline. If it is fast enough and the results are good enough, stop here.
[IMAGE: Supporting visual 1 for Full-Text Search in PHP: Elasticsearch, Meilisearch & Laravel Scout, showing Full-Text Search in PHP: Elasticsearch, Meilisearch & Laravel Scout decisions, examples, and PHP, Laravel Scout, Elasticsearch. Alt: Full-Text Search in PHP: Elasticsearch, Meilisearch & Laravel Scout full-text-search-php-elasticsearch-meilisearch-laravel-scout visual 1]
[IMAGE: Supporting visual 1 for Full-Text Search in PHP: Elasticsearch, Meilisearch & Laravel Scout, showing Full-Text Search in PHP: Elasticsearch, Meilisearch & Laravel Scout decisions, examples, and PHP, Laravel Scout, Elasticsearch. Alt: Full-Text Search in PHP: Elasticsearch, Meilisearch & Laravel Scout full-text-search-php-elasticsearch-meilisearch-laravel-scout visual 1]
Meilisearch with Scout
Meilisearch is the pragmatic Laravel choice when you want typo tolerance, filters, sorting, facets, and a much simpler operations model than Elasticsearch.
Install:
composer require laravel/scout
composer require meilisearch/meilisearch-php http-interop/http-factory-guzzle
php artisan vendor:publish --provider="Laravel\Scout\ScoutServiceProvider"
.env:
SCOUT_DRIVER=meilisearch
MEILISEARCH_HOST=http://127.0.0.1:7700
MEILISEARCH_KEY=masterKey
Queue Scout writes:
// config/scout.php
'queue' => [
'connection' => 'redis',
'queue' => 'scout',
],
Run the worker:
php artisan queue:work redis --queue=scout
Configure index settings before using filters or sorts:
use App\Models\Product;
return [
'meilisearch' => [
'host' => env('MEILISEARCH_HOST', 'http://127.0.0.1:7700'),
'key' => env('MEILISEARCH_KEY'),
'index-settings' => [
Product::class => [
'searchableAttributes' => [
'name',
'sku',
'brand_name',
'description',
],
'filterableAttributes' => [
'tenant_id',
'status',
'brand_id',
'category_ids',
'price_cents',
],
'sortableAttributes' => [
'price_cents',
'published_at',
],
'rankingRules' => [
'words',
'typo',
'proximity',
'attribute',
'sort',
'exactness',
],
],
],
],
];
Sync settings:
php artisan scout:sync-index-settings
Index existing records:
php artisan scout:queue-import "App\Models\Product" --chunk=500
Product model:
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Laravel\Scout\Searchable;
final class Product extends Model
{
use Searchable;
public function searchableAs(): string
{
return 'products';
}
/**
* @return array<string, mixed>
*/
public function toSearchableArray(): array
{
return [
'id' => (int) $this->id,
'tenant_id' => (int) $this->tenant_id,
'status' => $this->status,
'name' => $this->name,
'sku' => $this->sku,
'brand_id' => (int) $this->brand_id,
'brand_name' => $this->brand_name,
'category_ids' => array_map('intval', $this->category_ids ?? []),
'price_cents' => (int) $this->price_cents,
'published_at' => $this->published_at?->timestamp,
'description' => strip_tags((string) $this->description),
];
}
public function shouldBeSearchable(): bool
{
return $this->status === 'published';
}
protected function makeAllSearchableUsing(Builder $query): Builder
{
return $query->select([
'id',
'tenant_id',
'status',
'name',
'sku',
'brand_id',
'brand_name',
'category_ids',
'price_cents',
'published_at',
'description',
]);
}
}
Search:
$products = Product::search($request->string('q')->toString())
->where('tenant_id', $request->user()->tenant_id)
->where('status', 'published')
->whereIn('brand_id', $request->array('brands'))
->orderBy('price_cents')
->paginate(24);
With Meilisearch, fields used in where() must be configured as filterable. Fields used in orderBy() must be configured as sortable. Forgetting that is the most common Scout + Meilisearch production mistake.
Elasticsearch in PHP
Elasticsearch is the right tool when you need control over analysis, mappings, query DSL, aggregations, nested documents, or cluster-level scale.
It is also more work. You own mappings, shards, refresh behavior, index lifecycle, disk pressure, JVM tuning, query tuning, and reindex strategy.
Install the official PHP client:
composer require elasticsearch/elasticsearch
Bind the client in Laravel:
declare(strict_types=1);
namespace App\Providers;
use Elastic\Elasticsearch\Client;
use Elastic\Elasticsearch\ClientBuilder;
use Illuminate\Support\ServiceProvider;
final class ElasticsearchServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(Client::class, function (): Client {
return ClientBuilder::create()
->setHosts(config('services.elasticsearch.hosts'))
->setApiKey(config('services.elasticsearch.api_key'))
->build();
});
}
}
config/services.php:
'elasticsearch' => [
'hosts' => explode(',', env('ELASTICSEARCH_HOSTS', 'http://127.0.0.1:9200')),
'api_key' => env('ELASTICSEARCH_API_KEY'),
],
Raw search:
declare(strict_types=1);
namespace App\Search;
use Elastic\Elasticsearch\Client;
final readonly class ProductSearch
{
public function __construct(private Client $client) {}
/**
* @return array<int, int>
*/
public function searchIds(string $query, int $tenantId): array
{
$response = $this->client->search([
'index' => 'products_v1',
'body' => [
'size' => 24,
'query' => [
'bool' => [
'must' => [
[
'multi_match' => [
'query' => $query,
'fields' => [
'name^4',
'sku^5',
'brand_name^2',
'description',
],
'type' => 'best_fields',
'fuzziness' => 'AUTO',
],
],
],
'filter' => [
['term' => ['tenant_id' => $tenantId]],
['term' => ['status' => 'published']],
],
],
],
],
]);
return collect($response['hits']['hits'] ?? [])
->pluck('_id')
->map(fn (string $id): int => (int) $id)
->all();
}
}
Hydrate Eloquent models in search order:
$ids = $search->searchIds($query, $tenantId);
$products = Product::query()
->whereKey($ids)
->get()
->sortBy(fn (Product $product): int => array_search($product->id, $ids, true))
->values();
This direct-client approach is often cleaner than forcing every Elasticsearch feature through Scout.
Elasticsearch as a custom Scout engine
If your application already depends on Scout conventions, write or install a Scout engine.
Laravel expects a custom engine to implement eight methods: update, delete, search, paginate, mapIds, map, getTotalCount, and flush.
Minimal engine skeleton:
declare(strict_types=1);
namespace App\Scout;
use Elastic\Elasticsearch\Client;
use Illuminate\Support\Collection;
use Laravel\Scout\Builder;
use Laravel\Scout\Engines\Engine;
final class ElasticsearchEngine extends Engine
{
public function __construct(private readonly Client $client) {}
public function update($models): void
{
if ($models->isEmpty()) {
return;
}
$body = [];
foreach ($models as $model) {
$body[] = [
'index' => [
'_index' => $model->searchableAs(),
'_id' => $model->getScoutKey(),
],
];
$body[] = $model->toSearchableArray();
}
$this->client->bulk(['body' => $body]);
}
public function delete($models): void
{
if ($models->isEmpty()) {
return;
}
$body = [];
foreach ($models as $model) {
$body[] = [
'delete' => [
'_index' => $model->searchableAs(),
'_id' => $model->getScoutKey(),
],
];
}
$this->client->bulk(['body' => $body]);
}
public function search(Builder $builder): array
{
return $this->performSearch($builder, 15, 0);
}
public function paginate(Builder $builder, $perPage, $page): array
{
return $this->performSearch(
builder: $builder,
size: (int) $perPage,
from: ((int) $page - 1) * (int) $perPage,
);
}
public function mapIds($results): Collection
{
return collect($results['hits']['hits'] ?? [])
->pluck('_id')
->values();
}
public function map(Builder $builder, $results, $model)
{
$ids = $this->mapIds($results);
if ($ids->isEmpty()) {
return $model->newCollection();
}
$models = $model->getScoutModelsByIds($builder, $ids)
->keyBy($model->getScoutKeyName());
return $model->newCollection(
$ids
->map(fn (string $id) => $models->get($id))
->filter()
->values()
->all()
);
}
public function getTotalCount($results): int
{
$total = $results['hits']['total'] ?? 0;
return is_array($total) ? (int) $total['value'] : (int) $total;
}
public function flush($model): void
{
$this->client->deleteByQuery([
'index' => $model->searchableAs(),
'body' => [
'query' => [
'match_all' => (object) [],
],
],
]);
}
private function performSearch(Builder $builder, int $size, int $from): array
{
$filters = collect($builder->wheres)
->map(fn (mixed $value, string $field): array => [
'term' => [$field => $value],
])
->values()
->all();
return $this->client->search([
'index' => $builder->index ?: $builder->model->searchableAs(),
'body' => [
'from' => $from,
'size' => $size,
'query' => [
'bool' => [
'must' => [
[
'multi_match' => [
'query' => $builder->query,
'fields' => ['name^4', 'sku^5', 'description'],
'fuzziness' => 'AUTO',
],
],
],
'filter' => $filters,
],
],
],
]);
}
}
Register it:
use App\Scout\ElasticsearchEngine;
use Elastic\Elasticsearch\Client;
use Laravel\Scout\EngineManager;
public function boot(): void
{
resolve(EngineManager::class)->extend('elasticsearch', function (): ElasticsearchEngine {
return new ElasticsearchEngine(app(Client::class));
});
}
.env:
SCOUT_DRIVER=elasticsearch
This skeleton is intentionally narrow. A production engine should handle whereIn, whereNotIn, range filters, sorts, index aliases, failed bulk items, retries, mapping creation, soft deletes, and refresh behavior.
Reindexing strategy
Search indexes are disposable read models. Your database should remain the source of truth.
For Scout engines:
php artisan scout:flush "App\Models\Product"
php artisan scout:queue-import "App\Models\Product" --chunk=500
For Meilisearch, remember:
- Index writes are asynchronous.
- Settings changes require
scout:sync-index-settings. - Filter and sort fields must be declared before you depend on them.
- Numeric filters need numeric values in
toSearchableArray().
For Elasticsearch, use versioned indexes and aliases:
products_v1
products_v2
products_current -> products_v2
Deployment flow:
- Create
products_v2with the new mapping. - Backfill documents from the database.
- Run benchmark and smoke queries against
products_v2. - Move
products_currentfromproducts_v1toproducts_v2. - Keep the old index until rollback is no longer needed.
[IMAGE: Supporting visual 2 for Full-Text Search in PHP: Elasticsearch, Meilisearch & Laravel Scout, showing Full-Text Search in PHP: Elasticsearch, Meilisearch & Laravel Scout decisions, examples, and PHP, Laravel Scout, Elasticsearch. Alt: Full-Text Search in PHP: Elasticsearch, Meilisearch & Laravel Scout full-text-search-php-elasticsearch-meilisearch-laravel-scout visual 2]
Do not change Elasticsearch mappings in place and hope existing data behaves the same.
Benchmark relevance and latency
Benchmark with your own corpus. A good search benchmark has:
[IMAGE: Supporting visual 2 for Full-Text Search in PHP: Elasticsearch, Meilisearch & Laravel Scout, showing Full-Text Search in PHP: Elasticsearch, Meilisearch & Laravel Scout decisions, examples, and PHP, Laravel Scout, Elasticsearch. Alt: Full-Text Search in PHP: Elasticsearch, Meilisearch & Laravel Scout full-text-search-php-elasticsearch-meilisearch-laravel-scout visual 2]
- A frozen dataset snapshot.
- Real queries from logs.
- A hand-labeled expected result set.
- Tenant, status, price, and category filters.
- Cold and warm runs.
- p50, p95, and p99 latency.
- Relevance metrics such as MRR@10 or NDCG@10.
Example benchmark cases:
return [
[
'query' => 'waterproof hiking jacket',
'filters' => ['tenant_id' => 1, 'status' => 'published'],
'relevant_ids' => [421, 833, 19],
],
[
'query' => 'iphone charger usb c',
'filters' => ['tenant_id' => 1, 'status' => 'published'],
'relevant_ids' => [118, 119],
],
[
'query' => 'acrlyic paint set',
'filters' => ['tenant_id' => 1, 'status' => 'published'],
'relevant_ids' => [902],
],
];
Artisan command:
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Product;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
final class BenchmarkSearch extends Command
{
protected $signature = 'search:benchmark {--runs=10}';
protected $description = 'Benchmark Scout search latency and MRR@10.';
public function handle(): int
{
$cases = require base_path('tests/Fixtures/search-cases.php');
$runs = (int) $this->option('runs');
$rows = [];
foreach ($cases as $case) {
$latencies = [];
$scores = [];
for ($i = 0; $i < $runs; $i++) {
$start = hrtime(true);
$results = Product::search($case['query'])
->where('tenant_id', $case['filters']['tenant_id'])
->where('status', $case['filters']['status'])
->paginate(10);
$latencies[] = (hrtime(true) - $start) / 1_000_000;
$ids = $results->getCollection()
->pluck('id')
->map(fn (int|string $id): int => (int) $id)
->all();
$scores[] = $this->reciprocalRank($ids, $case['relevant_ids']);
}
sort($latencies);
$rows[] = [
$case['query'],
round($this->average($latencies), 2),
round($latencies[(int) floor(count($latencies) * 0.95) - 1] ?? end($latencies), 2),
round($this->average($scores), 3),
];
}
$this->table(['Query', 'Avg ms', 'p95 ms', 'MRR@10'], $rows);
return self::SUCCESS;
}
/**
* @param array<int, int> $ids
* @param array<int, int> $relevantIds
*/
private function reciprocalRank(array $ids, array $relevantIds): float
{
foreach ($ids as $position => $id) {
if (in_array($id, $relevantIds, true)) {
return 1 / ($position + 1);
}
}
return 0.0;
}
/**
* @param array<int, float> $values
*/
private function average(array $values): float
{
return array_sum($values) / max(count($values), 1);
}
}
Run the same benchmark against each driver:
SCOUT_DRIVER=database php artisan search:benchmark --runs=20
SCOUT_DRIVER=meilisearch php artisan search:benchmark --runs=20
SCOUT_DRIVER=elasticsearch php artisan search:benchmark --runs=20
Use the results to decide. If Meilisearch gives better MRR and simpler operations, use it. If Elasticsearch wins because analyzers and filters match your domain, accept the extra operational cost. If the database engine is good enough, do not add a cluster.
Operational comparison
| Concern | Scout database | Meilisearch | Elasticsearch |
|---|---|---|---|
| Extra service | No | Yes | Yes |
| First-party Scout support | Yes | Yes | No |
| Typo tolerance | No | Yes | Yes, with query/mapping design |
| Facets | Limited | Yes | Yes |
| Complex aggregations | No | Limited | Yes |
| Custom analyzers | Database-specific | Limited | Strong |
| Reindex complexity | Low | Medium | High |
| Ops burden | Low | Medium | High |
| Best fit | Small to medium app search | Product/catalog/app search | Complex search platform |
Production checklist
Before shipping:
- Search documents contain only fields needed for search, filtering, sorting, and display.
- Tenant and authorization filters run in the search engine, not after hydration.
- Scout writes are queued for external engines.
- Backfills are chunked and repeatable.
- Meilisearch filterable and sortable attributes are synced.
- Elasticsearch mappings are versioned and deployed through aliases.
- Failed bulk indexing responses are logged and retried.
- Relevance tests run in CI against a fixture dataset.
- p95 latency is measured with production-like data volume.
- The UI handles stale results after writes.
The implementation detail matters less than the contract: users type a query, get relevant results quickly, and never see data they are not allowed to see.
FAQ
What is Full-Text Search in PHP: Elasticsearch, Meilisearch & Laravel Scout?
Full-Text Search in PHP: Elasticsearch, Meilisearch & Laravel Scout is a practical database topic that should be evaluated through implementation scope, production risk, testing, documentation, and long-term maintainability.
When should a team use Full-Text Search in PHP: Elasticsearch, Meilisearch & Laravel Scout?
Use Full-Text Search in PHP: Elasticsearch, Meilisearch & Laravel Scout when it solves a real project constraint, improves clarity, or reduces operational risk. Avoid it when it only adds novelty or hides behavior from future maintainers.
What is the biggest risk with Full-Text Search in PHP: Elasticsearch, Meilisearch & Laravel Scout?
The biggest risk is copying a pattern without its context. Production systems need clear boundaries, rollback options, tests, and observability before a technique becomes dependable.
How do you test Full-Text Search in PHP: Elasticsearch, Meilisearch & Laravel Scout?
Test the smallest unit that owns the behavior, then add integration coverage for the path users or systems actually rely on. Include failure cases, configuration differences, and regression checks.
How does Full-Text Search in PHP: Elasticsearch, Meilisearch & Laravel Scout affect SEO and AI search visibility?
It improves visibility when the article gives a direct answer, expert context, structured headings, internal links, trustworthy references, and FAQ content that matches the visible page.
Conclusion
Full-Text Search in PHP: Elasticsearch, Meilisearch & Laravel Scout is worth doing when the implementation improves clarity, reliability, or delivery speed. It is not worth doing when it hides ownership, increases operational risk, or makes the system harder to explain.
Use the framework above as a review checklist. Then connect this topic to the rest of the project documentation so readers can move from concept to implementation without losing context.