Back to blog

Database

Full-Text Search in PHP: Elasticsearch, Meilisearch & Laravel Scout

Compares search backends, integrates each with Laravel Scout, and benchmarks relevance and performance on large datasets.

  • PHP
  • Laravel Scout
  • Elasticsearch
  • Meilisearch
  • Full-Text Search

Reader map

Key points in Full-Text Search in PHP: Elasticsearch, Meilisearch & Laravel Scout

Syntax first, runtime behavior second, migration cleanup last.

Read
13 min
Waypoints
8
Track
Database
  1. 01
    Start here

    Do users search natural language or product codes?

  2. 02
    Waypoint

    Do filters need to be exact and tenant-safe?

  3. 03
    Waypoint

    Do you need typo tolerance?

  4. 04
    Waypoint

    Do you need synonyms?

  5. 05
    Waypoint

    Do you need per-field weights?

  6. 06
    Waypoint

    Do you need aggregations?

  7. 07
    Waypoint

    Can your team operate another stateful service?

  8. 08
    Migration check

    How stale can the search index be after writes?

SEO Metadata

SEO Title Options

  1. Full-Text Search in PHP: Elasticsearch, Meilisearch
  2. Full-Text Search in PHP: Elasticsearch: Practical 2026
  3. Database Playbook: Full-Text Search in PHP: Elasticsearch

Meta Description Options

  1. Learn Full-Text Search in PHP: Elasticsearch, Meilisearch & Laravel Scout with a practical Database framework, expert mistakes, implementation steps.
  2. 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

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.

  1. Define the user problem and the production risk.
  2. Identify the smallest reliable implementation boundary.
  3. Keep configuration, secrets, and environment-specific behavior outside the article's core logic.
  4. Add tests for the behavior that would hurt if it regressed.
  5. Document the trade-off, not only the final code.
  6. Measure the result with logs, metrics, or user-facing outcomes.
  7. 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 areaStrong approachWeak approachWhy it matters
ScopeSolve one clear problemMix unrelated concernsFocus improves testing and search intent
ArchitecturePut logic in explicit classes or documented boundariesHide behavior in templates or incidental callbacksFuture changes stay easier to review
Data flowPass prepared data into the view or endpointQuery or compute in presentation codeReduces regressions and performance surprises
TestingCover the risky behavior directlyTest only the happy pathCatches production failures earlier
DocumentationExplain trade-offs and limitsRepeat generic definitionsBuilds E-E-A-T and reader trust
OperationsTrack logs, metrics, and rollback stepsShip without measurementMakes 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]

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.]

Internal linking opportunities

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:

NeedDefault
Simple keyword search inside MySQL or PostgreSQLLaravel full-text query builder or Scout database engine
Laravel app search with typo tolerance and facetsMeilisearch through Laravel Scout
Complex analyzers, nested documents, logs, aggregations, huge search clustersElasticsearch through the official PHP client or a custom Scout engine
Small tests and prototypesScout 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:

<?php

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:

<?php

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:

<?php

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:

<?php

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:

<?php

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:

<?php

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:

<?php

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:

  1. Create products_v2 with the new mapping.
  2. Backfill documents from the database.
  3. Run benchmark and smoke queries against products_v2.
  4. Move products_current from products_v1 to products_v2.
  5. 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:

<?php

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

ConcernScout databaseMeilisearchElasticsearch
Extra serviceNoYesYes
First-party Scout supportYesYesNo
Typo toleranceNoYesYes, with query/mapping design
FacetsLimitedYesYes
Complex aggregationsNoLimitedYes
Custom analyzersDatabase-specificLimitedStrong
Reindex complexityLowMediumHigh
Ops burdenLowMediumHigh
Best fitSmall to medium app searchProduct/catalog/app searchComplex 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.

Top