Meilisearch app fingerprints still need key-scope checks
Meilisearch app fingerprints still need key-scope checks
Meilisearch provides fast search over application indexes. A visible search interface may be expected, while the administrative API that creates indexes, changes settings, and manages keys should not be public. Internet measurement can find product instances, but the product fingerprint says nothing about which API keys are in use or what those keys can reach.
A standalone ZoomEye SDK query using sub_type=all, page 1, and page size 1 returned 4,885 matches for app="Meilisearch" and 4,883 for title="Meilisearch" on 30 October 2026 at 00:40 UTC. The near-equal counts make the application fingerprint more useful here than in products where a generic title produces many unrelated pages. They still describe matching observations, not confirmed vulnerable instances.
Separate search from administration
Meilisearch's production mode requires authentication when the master key is set. The master key can create or manage API keys, while ordinary API keys should be used for searches and controlled operations. The product documentation strongly discourages using the master key for normal API calls. An exposed search route can be valid, but a master key in a browser, mobile application, or static configuration gives every user a path to broader control.
Review key actions, indexes, and tenant restrictions. Use the smallest index and action set required by each client. Tenant tokens can restrict search results to a tenant or user scope, but they depend on a correctly scoped parent key and a rule that matches the application's data model. A token that is short lived still needs a narrow action and index scope.
Treat the index as a data store
Search indexes often contain text copied from databases, support tickets, product catalogs, or user profiles. A search API may return highlights and field values that the primary application would not show. Review which attributes are searchable, displayed, and filterable. Test with a low-privilege key and confirm that protected fields and cross-tenant documents are absent from results and suggestions.
Review indexing jobs as a data path. A compromised indexing credential may be able to replace documents, delete an index, or copy data into a new index. Keep indexing credentials separate from search credentials. Log key creation, deletion, index changes, and unusual document volumes.
Verify the exposure on owned hosts
Use the app match as a starting point for the organization's own assets. Confirm the Meilisearch version and build through approved management channels. Request a harmless endpoint with no key, a search-only key, and an administrative key. The expected status codes and response bodies should match the threat model. Do not use a discovered third-party instance as a test target.
A title match can be a deliberate public demo or an internal application with a broad search page. The useful inventory record includes which index scope, key action, tenant rule, and network boundary protect the service behind that page.
References
- Meilisearch configuration reference
- Meilisearch master and API keys
- Meilisearch security and tenant tokens
- ZoomEye measurement
Laravel Scout in Your Domain Is a Leak: Put Search Behind a Port
- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
---
You add use Searchable; to Product, set SCOUT_DRIVER=meilisearch, and the search box works before lunch. Then the rules arrive. Hide out-of-stock items. Ignore one-letter queries. Never return more than 50 rows. Each one lands as another chained call on Product::search(), inside a controller, and the test suite needs a running search engine, or a stand-in driver, to check any of it.
That is the leak. Your catalog's search rules are written in Laravel Scout's query builder, against one driver's filter syntax, and they return whatever Scout hydrates. Scout is a good library. It keeps indexes in sync through model observers and ships drivers for Algolia, Meilisearch, Typesense, Turbopuffer, plus database and collection engines (Scout docs). The trouble is that the call sits in your controller.
All code below targets PHP 8.3+ and Laravel 13, which requires PHP 8.3.
What use Searchable actually welds together
The usual version puts the whole search in an invokable controller:
<?php
namespace App\Http\Controllers;
use App\Models\Product;
use Illuminate\Http\Request;
final class ProductSearchController
{
public function __invoke(Request $request)
{
$term = trim((string) $request->query('q', ''));
if (mb_strlen($term) < 2) {
return [];
}
return Product::search($term)
->where('in_stock', true)
->take(20)
->get();
}
}
Four things are now glued together.
The rules (minimum length, in-stock only, page size) live in an HTTP controller, phrased as Scout builder calls. A queue job or an Artisan command that needs the same search has to copy them.
The return type is an Eloquent collection. After the engine answers, Scout runs a second query to load the matching models by primary key (Scout docs, "Customizing the Eloquent Results Query"). Your callers now depend on that round trip.
The filters depend on the driver. With Meilisearch, every attribute you pass to where must first be declared as filterable in config/scout.php and pushed with scout:sync-index-settings (Scout docs). Engine-specific options, like Typesense's options(['query_by' => ...]) or the raw-engine callback you can pass to search(), spread the driver into call sites (Scout docs).
Tests have two bad options. Run a real engine, or switch to the collection driver, which loads every record and filters in PHP with Str::is (Scout docs). The second one means your tests exercise a different engine than production.
!A wooden product crate with a thick black search cable welded straight into its side, sparks flying at the weld
The trait wires the search engine straight into the thing you are trying to model.
Name the port, let the use case own the rules
Start in the domain. Describe what the catalog needs from search in its own words, with no Scout or Eloquent import anywhere:
<?php
declare(strict_types=1);
namespace App\Catalog\Domain;
final readonly class SearchCriteria
{
public function __construct(
public string $term,
public int $limit,
public bool $inStockOnly = true,
public ?int $maxPriceCents = null,
) {}
}
final readonly class ProductHit
{
public function __construct(
public int $id,
public string $name,
public int $priceCents,
) {}
}
interface ProductSearch
{
/** @return list<ProductHit> */
public function search(SearchCriteria $criteria): array;
}
Put each type in its own file under app/Catalog/Domain/ so Composer's PSR-4 autoloader finds them. They share a block here to save scrolling.
ProductHit is a read model: the three fields a results page shows. It is not the Product entity, and it does not need the database to exist.
Next, the use case. This is where the rules move to, out of the controller:
<?php
declare(strict_types=1);
namespace App\Catalog\Application;
use App\Catalog\Domain\ProductHit;
use App\Catalog\Domain\ProductSearch;
use App\Catalog\Domain\SearchCriteria;
final class SearchProducts
{
private const int MIN_TERM_LENGTH = 2;
private const int MAX_LIMIT = 50;
public function __construct(
private readonly ProductSearch $search,
) {}
/** @return list<ProductHit> */
public function __invoke(
string $term,
int $limit = 20,
?int $maxPriceCents = null,
): array {
$term = trim($term);
if (mb_strlen($term) < self::MIN_TERM_LENGTH) {
return [];
}
return $this->search->search(new SearchCriteria(
term: $term,
limit: min(max($limit, 1), self::MAX_LIMIT),
inStockOnly: true,
maxPriceCents: $maxPriceCents,
));
}
}
private const int is a typed class constant, new in PHP 8.3. Every rule from the old controller is now a line of plain PHP you can read without knowing which engine runs underneath.
A Meilisearch adapter at the edge
The adapter is the only class that knows Meilisearch exists. It talks to the meilisearch-php client directly. When Meilisearch is installed, Scout's service provider already registers Meilisearch\Client as a container singleton (ScoutServiceProvider source), so the container can inject it.
<?php
declare(strict_types=1);
namespace App\Catalog\Infrastructure;
use App\Catalog\Domain\ProductHit;
use App\Catalog\Domain\ProductSearch;
use App\Catalog\Domain\SearchCriteria;
use Meilisearch\Client;
final class MeilisearchProductSearch implements ProductSearch
{
public function __construct(
private readonly Client $client,
private readonly string $index = 'products',
) {}
#[\Override]
public function search(SearchCriteria $criteria): array
{
$filters = [];
if ($criteria->inStockOnly) {
$filters[] = 'in_stock = true';
}
if ($criteria->maxPriceCents !== null) {
$filters[] = 'price_cents <= '.$criteria->maxPriceCents;
}
$options = ['limit' => $criteria->limit];
if ($filters !== []) {
$options['filter'] = implode(' AND ', $filters);
}
$hits = $this->client
->index($this->index)
->search($criteria->term, $options)
->getHits();
return array_map(
static fn (array $hit): ProductHit => new ProductHit(
id: (int) $hit['id'],
name: (string) $hit['name'],
priceCents: (int) $hit['price_cents'],
),
$hits,
);
}
}
Meilisearch's search API takes limit (default 20) and a filter string or array (search reference), and AND joins two conditions (filter expression reference). The boolean form in_stock = true is the same key=true shape Scout's own Meilisearch engine builds for boolean where clauses (MeilisearchEngine source). #[\Override] arrived in PHP 8.3 and is satisfied by a method that implements an interface method (RFC). If search is ever removed from the port, PHP flags the leftover method in the adapter instead of keeping it around silently.
Scout can keep doing what it is good at: writing the index. The Eloquent model keeps the trait, but it is infrastructure now, and nothing in App\Catalog imports it:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Laravel\Scout\Searchable;
class Product extends Model
{
use Searchable;
public function searchableAs(): string
{
return 'products';
}
public function toSearchableArray(): array
{
return [
'id' => (int) $this->id,
'name' => $this->name,
'price_cents' => (int) $this->price_cents,
'in_stock' => (bool) $this->in_stock,
];
}
}
The default searchableAs() is config('scout.prefix') plus the table name (Searchable source). Pinning it to 'products' keeps the writer and the adapter on the same index; if you use a prefix, pass the prefixed name to the adapter instead. The numeric casts matter because Meilisearch only applies <= and friends to correctly typed data (Scout docs). Then declare the filters in config/scout.php and push them:
'meilisearch' => [
// host, key: unchanged
'index-settings' => [
App\Models\Product::class => [
'filterableAttributes' => ['in_stock', 'price_cents'],
],
],
],
php artisan scout:sync-index-settings
An in-memory fake and fast Pest tests
The use case only knows the interface, so tests can hand it a fake that lives in tests/:
<?php
declare(strict_types=1);
namespace Tests\Fakes;
use App\Catalog\Domain\ProductHit;
use App\Catalog\Domain\ProductSearch;
use App\Catalog\Domain\SearchCriteria;
final class InMemoryProductSearch implements ProductSearch
{
/** @var list<SearchCriteria> */
public array $calls = [];
/** @var array<int, array{hit: ProductHit, inStock: bool}> */
private array $rows = [];
public function add(
int $id,
string $name,
int $priceCents,
bool $inStock = true,
): self {
$this->rows[$id] = [
'hit' => new ProductHit($id, $name, $priceCents),
'inStock' => $inStock,
];
return $this;
}
#[\Override]
public function search(SearchCriteria $criteria): array
{
$this->calls[] = $criteria;
$matches = array_filter(
$this->rows,
fn (array $r): bool => $this->matches($r, $criteria),
);
return array_slice(
array_column($matches, 'hit'),
0,
$criteria->limit,
);
}
/** @param array{hit: ProductHit, inStock: bool} $row */
private function matches(array $row, SearchCriteria $c): bool
{
$name = mb_strtolower($row['hit']->name);
return str_contains($name, mb_strtolower($c->term))
&& (! $c->inStockOnly || $row['inStock'])
&& ($c->maxPriceCents === null
|| $row['hit']->priceCents <= $c->maxPriceCents);
}
}
The fake does plain substring matching. It is there to test the use case's rules; Meilisearch's relevance gets its own integration tests. The $calls log lets a test check what the use case asked for.
<?php
use App\Catalog\Application\SearchProducts;
use Tests\Fakes\InMemoryProductSearch;
beforeEach(function () {
$this->products = (new InMemoryProductSearch())
->add(1, 'Trail running shoe', 8_900)
->add(2, 'Road running shoe', 12_900)
->add(3, 'Running socks', 1_200, inStock: false);
$this->useCase = new SearchProducts($this->products);
});
it('ignores terms shorter than two characters', function () {
expect(($this->useCase)(' r '))->toBe([])
->and($this->products->calls)->toBe([]);
});
it('returns only products that are in stock', function () {
$hits = ($this->useCase)('running');
expect(array_map(fn ($h) => $h->id, $hits))->toBe([1, 2]);
});
it('applies the price ceiling', function () {
$hits = ($this->useCase)('shoe', maxPriceCents: 10_000);
expect($hits)->toHaveCount(1)
->and($hits[0]->name)->toBe('Trail running shoe');
});
it('caps the page size at 50', function () {
($this->useCase)('shoe', limit: 500);
expect($this->products->calls[0]->limit)->toBe(50);
});
Assigning to $this in beforeEach is the pattern the Pest hooks docs show. Drop the file in tests/Unit. The tests/Pest.php that Pest's configuration docs show extends Laravel's TestCase only for Feature, so with that setup these tests never boot the framework, the container, or a search engine.
They run on Pest 4, which accepts PHP 8.3 (composer.json), and on Pest 5, which requires PHP 8.4 (Packagist).
The adapter still deserves a few integration tests against a real Meilisearch in CI. Write them knowing that Meilisearch indexes asynchronously, so a document saved a moment ago may not be searchable yet (Scout docs, "Queueing").
Bind it, then swap it
The binding is the only place that chooses an engine. Create a provider with php artisan make:provider CatalogServiceProvider, which Laravel adds to bootstrap/providers.php for you (service provider docs):
<?php
namespace App\Providers;
use App\Catalog\Domain\ProductSearch;
use App\Catalog\Infrastructure\MeilisearchProductSearch;
use Illuminate\Support\ServiceProvider;
class CatalogServiceProvider extends ServiceProvider
{
public $singletons = [
ProductSearch::class => MeilisearchProductSearch::class,
];
}
The controller shrinks to translation between HTTP and the use case:
<?php
namespace App\Http\Controllers;
use App\Catalog\Application\SearchProducts;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
final class ProductSearchController
{
public function __invoke(
Request $request,
SearchProducts $searchProducts,
): JsonResponse {
$maxPrice = $request->filled('max_price')
? $request->integer('max_price')
: null;
return response()->json($searchProducts(
term: (string) $request->query('q', ''),
maxPriceCents: $maxPrice,
));
}
}
!A hexagon with a single socket on its edge, and three interchangeable plugs lined up beside it: a magnifying glass, a test tube, and a database cylinder
One port, several adapters. Choosing between them is a line in a service provider.
Say the catalog is small and you want to drop the external engine for a while. Write a second adapter against plain Eloquent:
<?php
declare(strict_types=1);
namespace App\Catalog\Infrastructure;
use App\Catalog\Domain\ProductHit;
use App\Catalog\Domain\ProductSearch;
use App\Catalog\Domain\SearchCriteria;
use App\Models\Product;
final class EloquentProductSearch implements ProductSearch
{
#[\Override]
public function search(SearchCriteria $criteria): array
{
return Product::query()
->where('name', 'like', '%'.$criteria->term.'%')
->when(
$criteria->inStockOnly,
fn ($q) => $q->where('in_stock', true),
)
->when(
$criteria->maxPriceCents !== null,
fn ($q) => $q->where(
'price_cents', '<=', $criteria->maxPriceCents,
),
)
->limit($criteria->limit)
->get(['id', 'name', 'price_cents'])
->map(fn (Product $p) => new ProductHit(
(int) $p->id,
(string) $p->name,
(int) $p->price_cents,
))
->all();
}
}
Change the one line in $singletons to EloquentProductSearch::class, and set SCOUT_DRIVER=null so saves stop writing to the old index. The use case, the controller and all four Pest tests stay exactly as they are. Note that % and _ typed by a user act as LIKE wildcards here; escape them if that matters for your catalog.
Moving to Typesense works the same way on the read side: one new adapter, one changed binding. On the write side you point SCOUT_DRIVER at typesense and give it a collection schema in config/scout.php (Scout docs), which is still all infrastructure. Dropping Scout completely touches only infrastructure too. Once no adapter reads through Scout, remove the trait from Product and replace its observer with a listener that writes documents through the client (the meilisearch-php README shows addDocuments()), or stop indexing altogether if you went the SQL route. App\Catalog never notices.
Watch the import list. When a file under App\Catalog\Domain or App\Catalog\Application imports Laravel\Scout or Meilisearch, the leak is back.
---
If this was useful
This refactor is the whole book in miniature: name what the application needs, write the rules in plain PHP, and push Laravel, Scout and the engine to the edge where they can be swapped. Decoupled PHP does the same thing for persistence, queues, HTTP and the rest of a real codebase, with the trade-offs spelled out. If your models carry more traits than business rules, it was written for you.

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.
Mentions
Meilisearch app fingerprints still need key-scope checks Meilisearch app fingerprints still need key-scope checks Meilisearch provides fast search over application indexes. A visible search interface may be expected, while the administrative API that creates indexes, changes settings, and manages keys should not be public. Internet measurement can find product instances, but the product fingerprint says nothing about which API keys are in use or what those keys can reach. A standalone ZoomEye SDK query using sub_type=all, page 1, and page size 1 returned 4,885 matches for app="Meilisearch" and 4,883 for title="Meilisearch" on 30 October 2026 at 00:40 UTC. The near-equal counts make the application fingerprint more useful here than in products where a generic title produces many unrelated pages. They still describe matching observations, not confirmed vulnerable instances. Separate search from administration Meilisearch's production mode requires authentication when the master key is set. The master key can create or manage API keys, while ordinary API keys should be used for searches and controlled operations. The product documentation strongly discourages using the master key for normal API calls. An exposed search route can be valid, but a master key in a browser, mobile application, or static configuration gives every user a path to broader control. Review key actions, indexes, and tenant restrictions. Use the smallest index and action set required by each client. Tenant tokens can restrict search results to a tenant or user scope, but they depend on a correctly scoped parent key and a rule that matches the application's data model. A token that is short lived still needs a narrow action and index scope. Treat the index as a data store Search indexes often contain text copied from databases, support tickets, product catalogs, or user profiles. A search API may return highlights and field values that the primary application would not show. Review which attributes are searchable, displayed, and filterable. Test with a low-privilege key and confirm that protected fields and cross-tenant documents are absent from results and suggestions. Review indexing jobs as a data path. A compromised indexing credential may be able to replace documents, delete an index, or copy data into a new index. Keep indexing credentials separate from search credentials. Log key creation, deletion, index changes, and unusual document volumes. Verify the exposure on owned hosts Use the app match as a starting point for the organization's own assets. Confirm the Meilisearch version and build through approved management channels. Request a harmless endpoint with no key, a search-only key, and an administrative key. The expected status codes and response bodies should match the threat model. Do not use a discovered third-party instance as a test target. A title match can be a deliberate public demo or an internal application with a broad search page. The useful inventory record includes which index scope, key action, tenant rule, and network boundary protect the service behind that page. References - Meilisearch configuration reference - Meilisearch master and API keys - Meilisearch security and tenant tokens - ZoomEye measurement
自己做的网站,搜索总搜不准?🔎 Meilisearch 提供全文搜索 API,支持容错匹配和过滤。给商品目录或文档站加搜索很合适。⚡ meilisearch/meilisearch #Search #DevTools #浇朋友 #蓝朋友 #互关 #fo
网站内容一多,搜索总找不准?🔎 Meilisearch 提供拼写容错、筛选和即时搜索 API。 适合给博客或产品目录加搜索。⚡ meilisearch/meilisearch #Search #DevTools #浇朋友 #蓝朋友 #互关 #fo
Laravel Scout in Your Domain Is a Leak: Put Search Behind a Port - Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework - Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go - My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools - Me: xgabriel.com | GitHub --- You add use Searchable; to Product, set SCOUT_DRIVER=meilisearch, and the search box works before lunch. Then the rules arrive. Hide out-of-stock items. Ignore one-letter queries. Never return more than 50 rows. Each one lands as another chained call on Product::search(), inside a controller, and the test suite needs a running search engine, or a stand-in driver, to check any of it. That is the leak. Your catalog's search rules are written in Laravel Scout's query builder, against one driver's filter syntax, and they return whatever Scout hydrates. Scout is a good library. It keeps indexes in sync through model observers and ships drivers for Algolia, Meilisearch, Typesense, Turbopuffer, plus database and collection engines (Scout docs). The trouble is that the call sits in your controller. All code below targets PHP 8.3+ and Laravel 13, which requires PHP 8.3. What use Searchable actually welds together The usual version puts the whole search in an invokable controller: <?php namespace App\Http\Controllers; use App\Models\Product; use Illuminate\Http\Request; final class ProductSearchController { public function __invoke(Request $request) { $term = trim((string) $request->query('q', '')); if (mb_strlen($term) < 2) { return []; } return Product::search($term) ->where('in_stock', true) ->take(20) ->get(); } } Four things are now glued together. The rules (minimum length, in-stock only, page size) live in an HTTP controller, phrased as Scout builder calls. A queue job or an Artisan command that needs the same search has to copy them. The return type is an Eloquent collection. After the engine answers, Scout runs a second query to load the matching models by primary key (Scout docs, "Customizing the Eloquent Results Query"). Your callers now depend on that round trip. The filters depend on the driver. With Meilisearch, every attribute you pass to where must first be declared as filterable in config/scout.php and pushed with scout:sync-index-settings (Scout docs). Engine-specific options, like Typesense's options(['query_by' => ...]) or the raw-engine callback you can pass to search(), spread the driver into call sites (Scout docs). Tests have two bad options. Run a real engine, or switch to the collection driver, which loads every record and filters in PHP with Str::is (Scout docs). The second one means your tests exercise a different engine than production. !A wooden product crate with a thick black search cable welded straight into its side, sparks flying at the weld The trait wires the search engine straight into the thing you are trying to model. Name the port, let the use case own the rules Start in the domain. Describe what the catalog needs from search in its own words, with no Scout or Eloquent import anywhere: <?php declare(strict_types=1); namespace App\Catalog\Domain; final readonly class SearchCriteria { public function __construct( public string $term, public int $limit, public bool $inStockOnly = true, public ?int $maxPriceCents = null, ) {} } final readonly class ProductHit { public function __construct( public int $id, public string $name, public int $priceCents, ) {} } interface ProductSearch { /** @return list<ProductHit> */ public function search(SearchCriteria $criteria): array; } Put each type in its own file under app/Catalog/Domain/ so Composer's PSR-4 autoloader finds them. They share a block here to save scrolling. ProductHit is a read model: the three fields a results page shows. It is not the Product entity, and it does not need the database to exist. Next, the use case. This is where the rules move to, out of the controller: <?php declare(strict_types=1); namespace App\Catalog\Application; use App\Catalog\Domain\ProductHit; use App\Catalog\Domain\ProductSearch; use App\Catalog\Domain\SearchCriteria; final class SearchProducts { private const int MIN_TERM_LENGTH = 2; private const int MAX_LIMIT = 50; public function __construct( private readonly ProductSearch $search, ) {} /** @return list<ProductHit> */ public function __invoke( string $term, int $limit = 20, ?int $maxPriceCents = null, ): array { $term = trim($term); if (mb_strlen($term) < self::MIN_TERM_LENGTH) { return []; } return $this->search->search(new SearchCriteria( term: $term, limit: min(max($limit, 1), self::MAX_LIMIT), inStockOnly: true, maxPriceCents: $maxPriceCents, )); } } private const int is a typed class constant, new in PHP 8.3. Every rule from the old controller is now a line of plain PHP you can read without knowing which engine runs underneath. A Meilisearch adapter at the edge The adapter is the only class that knows Meilisearch exists. It talks to the meilisearch-php client directly. When Meilisearch is installed, Scout's service provider already registers Meilisearch\Client as a container singleton (ScoutServiceProvider source), so the container can inject it. <?php declare(strict_types=1); namespace App\Catalog\Infrastructure; use App\Catalog\Domain\ProductHit; use App\Catalog\Domain\ProductSearch; use App\Catalog\Domain\SearchCriteria; use Meilisearch\Client; final class MeilisearchProductSearch implements ProductSearch { public function __construct( private readonly Client $client, private readonly string $index = 'products', ) {} #[\Override] public function search(SearchCriteria $criteria): array { $filters = []; if ($criteria->inStockOnly) { $filters[] = 'in_stock = true'; } if ($criteria->maxPriceCents !== null) { $filters[] = 'price_cents <= '.$criteria->maxPriceCents; } $options = ['limit' => $criteria->limit]; if ($filters !== []) { $options['filter'] = implode(' AND ', $filters); } $hits = $this->client ->index($this->index) ->search($criteria->term, $options) ->getHits(); return array_map( static fn (array $hit): ProductHit => new ProductHit( id: (int) $hit['id'], name: (string) $hit['name'], priceCents: (int) $hit['price_cents'], ), $hits, ); } } Meilisearch's search API takes limit (default 20) and a filter string or array (search reference), and AND joins two conditions (filter expression reference). The boolean form in_stock = true is the same key=true shape Scout's own Meilisearch engine builds for boolean where clauses (MeilisearchEngine source). #[\Override] arrived in PHP 8.3 and is satisfied by a method that implements an interface method (RFC). If search is ever removed from the port, PHP flags the leftover method in the adapter instead of keeping it around silently. Scout can keep doing what it is good at: writing the index. The Eloquent model keeps the trait, but it is infrastructure now, and nothing in App\Catalog imports it: <?php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Laravel\Scout\Searchable; class Product extends Model { use Searchable; public function searchableAs(): string { return 'products'; } public function toSearchableArray(): array { return [ 'id' => (int) $this->id, 'name' => $this->name, 'price_cents' => (int) $this->price_cents, 'in_stock' => (bool) $this->in_stock, ]; } } The default searchableAs() is config('scout.prefix') plus the table name (Searchable source). Pinning it to 'products' keeps the writer and the adapter on the same index; if you use a prefix, pass the prefixed name to the adapter instead. The numeric casts matter because Meilisearch only applies <= and friends to correctly typed data (Scout docs). Then declare the filters in config/scout.php and push them: 'meilisearch' => [ // host, key: unchanged 'index-settings' => [ App\Models\Product::class => [ 'filterableAttributes' => ['in_stock', 'price_cents'], ], ], ], php artisan scout:sync-index-settings An in-memory fake and fast Pest tests The use case only knows the interface, so tests can hand it a fake that lives in tests/: <?php declare(strict_types=1); namespace Tests\Fakes; use App\Catalog\Domain\ProductHit; use App\Catalog\Domain\ProductSearch; use App\Catalog\Domain\SearchCriteria; final class InMemoryProductSearch implements ProductSearch { /** @var list<SearchCriteria> */ public array $calls = []; /** @var array<int, array{hit: ProductHit, inStock: bool}> */ private array $rows = []; public function add( int $id, string $name, int $priceCents, bool $inStock = true, ): self { $this->rows[$id] = [ 'hit' => new ProductHit($id, $name, $priceCents), 'inStock' => $inStock, ]; return $this; } #[\Override] public function search(SearchCriteria $criteria): array { $this->calls[] = $criteria; $matches = array_filter( $this->rows, fn (array $r): bool => $this->matches($r, $criteria), ); return array_slice( array_column($matches, 'hit'), 0, $criteria->limit, ); } /** @param array{hit: ProductHit, inStock: bool} $row */ private function matches(array $row, SearchCriteria $c): bool { $name = mb_strtolower($row['hit']->name); return str_contains($name, mb_strtolower($c->term)) && (! $c->inStockOnly || $row['inStock']) && ($c->maxPriceCents === null || $row['hit']->priceCents <= $c->maxPriceCents); } } The fake does plain substring matching. It is there to test the use case's rules; Meilisearch's relevance gets its own integration tests. The $calls log lets a test check what the use case asked for. <?php use App\Catalog\Application\SearchProducts; use Tests\Fakes\InMemoryProductSearch; beforeEach(function () { $this->products = (new InMemoryProductSearch()) ->add(1, 'Trail running shoe', 8_900) ->add(2, 'Road running shoe', 12_900) ->add(3, 'Running socks', 1_200, inStock: false); $this->useCase = new SearchProducts($this->products); }); it('ignores terms shorter than two characters', function () { expect(($this->useCase)(' r '))->toBe([]) ->and($this->products->calls)->toBe([]); }); it('returns only products that are in stock', function () { $hits = ($this->useCase)('running'); expect(array_map(fn ($h) => $h->id, $hits))->toBe([1, 2]); }); it('applies the price ceiling', function () { $hits = ($this->useCase)('shoe', maxPriceCents: 10_000); expect($hits)->toHaveCount(1) ->and($hits[0]->name)->toBe('Trail running shoe'); }); it('caps the page size at 50', function () { ($this->useCase)('shoe', limit: 500); expect($this->products->calls[0]->limit)->toBe(50); }); Assigning to $this in beforeEach is the pattern the Pest hooks docs show. Drop the file in tests/Unit. The tests/Pest.php that Pest's configuration docs show extends Laravel's TestCase only for Feature, so with that setup these tests never boot the framework, the container, or a search engine. They run on Pest 4, which accepts PHP 8.3 (composer.json), and on Pest 5, which requires PHP 8.4 (Packagist). The adapter still deserves a few integration tests against a real Meilisearch in CI. Write them knowing that Meilisearch indexes asynchronously, so a document saved a moment ago may not be searchable yet (Scout docs, "Queueing"). Bind it, then swap it The binding is the only place that chooses an engine. Create a provider with php artisan make:provider CatalogServiceProvider, which Laravel adds to bootstrap/providers.php for you (service provider docs): <?php namespace App\Providers; use App\Catalog\Domain\ProductSearch; use App\Catalog\Infrastructure\MeilisearchProductSearch; use Illuminate\Support\ServiceProvider; class CatalogServiceProvider extends ServiceProvider { public $singletons = [ ProductSearch::class => MeilisearchProductSearch::class, ]; } The controller shrinks to translation between HTTP and the use case: <?php namespace App\Http\Controllers; use App\Catalog\Application\SearchProducts; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; final class ProductSearchController { public function __invoke( Request $request, SearchProducts $searchProducts, ): JsonResponse { $maxPrice = $request->filled('max_price') ? $request->integer('max_price') : null; return response()->json($searchProducts( term: (string) $request->query('q', ''), maxPriceCents: $maxPrice, )); } } !A hexagon with a single socket on its edge, and three interchangeable plugs lined up beside it: a magnifying glass, a test tube, and a database cylinder One port, several adapters. Choosing between them is a line in a service provider. Say the catalog is small and you want to drop the external engine for a while. Write a second adapter against plain Eloquent: <?php declare(strict_types=1); namespace App\Catalog\Infrastructure; use App\Catalog\Domain\ProductHit; use App\Catalog\Domain\ProductSearch; use App\Catalog\Domain\SearchCriteria; use App\Models\Product; final class EloquentProductSearch implements ProductSearch { #[\Override] public function search(SearchCriteria $criteria): array { return Product::query() ->where('name', 'like', '%'.$criteria->term.'%') ->when( $criteria->inStockOnly, fn ($q) => $q->where('in_stock', true), ) ->when( $criteria->maxPriceCents !== null, fn ($q) => $q->where( 'price_cents', '<=', $criteria->maxPriceCents, ), ) ->limit($criteria->limit) ->get(['id', 'name', 'price_cents']) ->map(fn (Product $p) => new ProductHit( (int) $p->id, (string) $p->name, (int) $p->price_cents, )) ->all(); } } Change the one line in $singletons to EloquentProductSearch::class, and set SCOUT_DRIVER=null so saves stop writing to the old index. The use case, the controller and all four Pest tests stay exactly as they are. Note that % and _ typed by a user act as LIKE wildcards here; escape them if that matters for your catalog. Moving to Typesense works the same way on the read side: one new adapter, one changed binding. On the write side you point SCOUT_DRIVER at typesense and give it a collection schema in config/scout.php (Scout docs), which is still all infrastructure. Dropping Scout completely touches only infrastructure too. Once no adapter reads through Scout, remove the trait from Product and replace its observer with a listener that writes documents through the client (the meilisearch-php README shows addDocuments()), or stop indexing altogether if you went the SQL route. App\Catalog never notices. Watch the import list. When a file under App\Catalog\Domain or App\Catalog\Application imports Laravel\Scout or Meilisearch, the leak is back. --- If this was useful This refactor is the whole book in miniature: name what the application needs, write the rules in plain PHP, and push Laravel, Scout and the engine to the edge where they can be swapped. Decoupled PHP does the same thing for persistence, queues, HTTP and the rest of a real codebase, with the trade-offs spelled out. If your models carry more traits than business rules, it was written for you.  Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.