DEV Community

Cover image for Laravel Scout in Your Domain Is a Leak: Put Search Behind a Port
Gabriel Anhaia
Gabriel Anhaia

Posted on AI-assisted

Laravel Scout in Your Domain Is a Leak: Put Search Behind a Port


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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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,
        ));
    }
}
Enter fullscreen mode Exit fullscreen mode

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,
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

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,
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

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'],
        ],
    ],
],
Enter fullscreen mode Exit fullscreen mode
php artisan scout:sync-index-settings
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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,
    ];
}
Enter fullscreen mode Exit fullscreen mode

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,
        ));
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)