Skip to main content
Laravel, shipping fast.
Chapter 13 ยท Advanced Features

Search That Stays Consistent With the Source

Julian Beaujardin

Before building a search endpoint, ask where the data actually lives. That answer decides the whole design.

Domain is a real Eloquent model, the row in the domains table is the source of truth. Searching it is a query, nothing more:

// app/Http/Requests/SearchDomainsRequest.php
final class SearchDomainsRequest extends BaseFormRequest
{
    public function rules(): array
    {
        return [
            'q' => ['required', 'string', 'min:2', 'max:100'],
            'status' => ['sometimes', Rule::enum(DomainStatus::class)],
        ];
    }
}
// app/Http/Controllers/DomainController.php
public function search(SearchDomainsRequest $request, string $id): CollectionResponse
{
    $site = Site::findOrFail($id);

    $domains = Domain::query()
        ->where('site_id', $site->id)
        ->whereLike('hostname', '%'.$request->validated('q').'%')
        ->when(
            $request->validated('status'),
            fn ($query, $status) => $query->where('status', $status),
        )
        ->orderBy('hostname')
        ->limit(50)
        ->get();

    return new CollectionResponse(
        data: DomainResource::collection($domains),
    );
}

Notice the where('site_id', $site->id) before the whereLike. Drop that line and the endpoint still works in every test you write against your own site, and still leaks every other tenant's hostnames the day a second customer signs up. The scope isn't an optimization, it's the difference between a search feature and a data breach with a query interface. There's no second copy of this data anywhere either, so there's nothing to go stale. That's the entire argument for keeping search this boring for as long as you can: a query against the source of truth is consistent by construction.

License doesn't get that luxury. LicenseDTO isn't hydrated from a table, LicenseManager builds it fresh from a Statamic API call every time. There's no licenses table to run whereLike against, and Statamic doesn't expose a search endpoint that matches what your consumers will ask for. This is where teams reach for a search index, and where the real cost shows up: you're no longer querying the source of truth, you're querying a copy, and a copy is only correct the moment after it's synced.

If you build that copy, build it off the same event you already have. Listen for LicenseIssuedEvent (and its counterpart on deletion), write a row into a local projection table, and treat that table as disposable: it exists to be searched, not trusted as a system of record. When Statamic and your projection disagree, Statamic wins, and you need a way to rebuild the projection from scratch when they drift. A search index with no rebuild path isn't a feature, it's a liability with a query interface.