Skip to content
Backend & Architecture•15 min read•Published September 21, 2026

Building Full-Text Search in Multi-Tenant Laravel SaaS

WHERE title LIKE '%term%' works until customers expect search that's fast, relevant, and never leaks another tenant's records.

Aqib Javaid
Aqib Javaid
Senior Full-Stack Engineer
Dark tech blog cover of a Laravel search index syncing tenant records into Meilisearch behind a scoped search bar UI

Every Laravel product's search box starts the same way: a WHERE title LIKE '%' . $term . '%' clause bolted onto whatever query already existed, because it's the fastest thing to ship and it technically returns results. It stays "fine" for exactly as long as the table it's scanning stays small and the term being searched is a literal substring of a single column. Then a customer searches for a two-word phrase and gets nothing, because the words appear in the record but not adjacent in that exact order. Then a customer searches for "SWMS" and gets zero results because the actual field is titled "Safe Work Method Statement" and nothing in a LIKE clause knows those are related. Then the table crosses a few hundred thousand rows and the query that used to return in 40ms starts taking two full seconds, because a leading-wildcard LIKE can't use a standard index — every search is a full table scan, and it gets slower in direct proportion to how much data the product's own success has produced.

None of that is a database problem. It's a category error — treating full-text search as a slightly fancier WHERE clause instead of what it actually is: a separate, purpose-built system that has to be kept in sync with the source of truth, scoped to the same tenant and permission boundaries as everything else in the product, and tuned so that "relevant" means something specific to what's actually being searched.

I've had to build this correctly on products where search isn't a nice-to-have, it's how the product gets used day to day: FileManager, the cloud-based file management system I built on Laravel, Livewire, and Amazon S3, where customers store thousands of business documents and expect to find one by filename, extracted content, or metadata in under a second — not by scrolling a folder tree; SafetySpace, the AI-driven safety platform I run as CTO, where field workers and safety officers search across SWMS/SOP templates and incident records that are both large in volume and strictly access-controlled, so a search result itself can't be the thing that leaks a record someone shouldn't see; ReplyVibe, the brand reputation SaaS where a manager needs to search thousands of customer reviews by keyword, sentiment, and location and get results ranked by what's actually relevant, not just what matched a substring; and SwapPad, the bulk SIM activation platform inside the CelleUp dealer back office, where a dealer support rep searching for a specific SIM or activation batch among tens of thousands of rows needs an answer in milliseconds, not a spinner.

This is a breakdown of how to build full-text search in Laravel that stays fast as data grows, stays in sync with the database without a manual reindex button, ranks results by what actually matters for the data being searched, and never returns a record to someone who wasn't authorized to see it in the first place.

Architecture: What Full-Text Search Needs Beyond an Index#

Laravel Scout and a search engine like Meilisearch solve the raw "find matching documents fast" problem. They don't automatically solve tenant isolation, staying in sync with writes, ranking relevance for your specific data, or filtering results by what the requesting user is allowed to see — those are architecture decisions layered on top, and skipping any one of them is invisible in a single-tenant demo and a real incident in production.

1. The tenant boundary is a filterable attribute on every document, not a separate index per tenant#

The first design decision is whether each tenant gets its own search index or all tenants share one index filtered by a tenant field. Per-tenant indexes sound safer intuitively — "tenant 7's data literally isn't in an index tenant 42 can query" — but at real scale it means provisioning, monitoring, and keeping schema changes in sync across however many indexes there are tenants, which turns a Meilisearch settings update into a loop instead of a single API call. The pattern that holds up, the same one covered in multi-tenant SaaS architecture, is a single shared index with the tenant boundary enforced structurally on every read:

php
class SafetyDocument extends Model
{
    use Searchable;

    public function toSearchableArray(): array
    {
        return [
            'id' => $this->id,
            'tenant_id' => $this->tenant_id,
            'title' => $this->title,
            'content' => $this->extracted_text,
            'document_type' => $this->document_type,
            'allowed_role_ids' => $this->allowed_role_ids, // for permission filtering, see §4
            'created_at' => $this->created_at->timestamp,
        ];
    }

    public function searchableAs(): string
    {
        return 'safety_documents';
    }
}
php
// config/scout.php — tenant_id and allowed_role_ids have to be filterable,
// or a runtime ->where() on the search query can't actually restrict anything
'meilisearch' => [
    'index-settings' => [
        SafetyDocument::class => [
            'filterableAttributes' => ['tenant_id', 'allowed_role_ids', 'document_type'],
            'sortableAttributes' => ['created_at'],
        ],
    ],
],

Every search request then filters on tenant_id as a hard requirement, applied in one place — never left to the caller to remember:

php
class SafetyDocumentSearch
{
    public function query(int $tenantId, string $term, array $filters = []): Collection
    {
        return SafetyDocument::search($term)
            ->where('tenant_id', $tenantId)
            ->when($filters['document_type'] ?? null, fn ($q, $type) => $q->where('document_type', $type))
            ->get();
    }
}

Meilisearch's filterableAttributes config is what makes ->where('tenant_id', $tenantId) an actual filter applied inside the search engine, not a post-query PHP ->filter() on results that already left the index — the difference matters because filtering after the fact still means an under-provisioned page size can return a full page of another tenant's results with zero matches from your own.

2. Indexing runs asynchronously and never drifts silently out of sync with the database#

Scout's default sync driver indexes a record inline, in the same request that saved it — fine for a low-traffic admin panel, and a real problem the moment a bulk import touches thousands of rows at once, because now the HTTP request (or the queue worker processing the import) is blocked on Meilisearch round-trips for every single row. The fix is the same discipline from queue architecture in Laravel SaaS: indexing is a queued side effect of a write, not part of the write itself.

php
// config/scout.php
'queue' => true,

'meilisearch' => [
    'index-settings' => [/* ... */],
],
php
// AppServiceProvider — route indexing onto its own queue, isolated from
// user-facing jobs, the same separation-by-latency-sensitivity discipline
// covered in the queue architecture piece
class SafetyDocument extends Model
{
    use Searchable;

    public function syncWithSearchUsingQueue(): string
    {
        return 'search-index';
    }
}

Queuing the indexing call solves throughput. It doesn't solve drift — a search index that falls silently out of sync with the database is worse than no search at all, because a stale result looks correct until someone acts on it. On ReplyVibe, a review that's been deleted for a GDPR takedown request has to disappear from search results the moment it's deleted, not whenever the next full reindex happens to run:

php
class Review extends Model
{
    use Searchable;

    protected static function booted(): void
    {
        static::deleted(fn (Review $review) => $review->unsearchable());
    }
}

Searchable's model observers handle create and update automatically; deleted needs the explicit unsearchable() call, because a soft-deleted or force-deleted row still existing in the search index is the single most common cause of "why does search show me something that isn't there anymore" tickets. For data that can be bulk-mutated outside Eloquent's normal lifecycle — a raw DB::table()->update() in a migration, an import script — a scheduled reconciliation job that diffs record counts between the database and the index catches drift that model events, by construction, never see:

php
class ReconcileSearchIndex extends Command
{
    public function handle(): void
    {
        Tenant::cursor()->each(function (Tenant $tenant) {
            $dbCount = SafetyDocument::where('tenant_id', $tenant->id)->count();
            $indexCount = SafetyDocument::search('')
                ->where('tenant_id', $tenant->id)
                ->take(0)
                ->raw()['estimatedTotalHits'] ?? 0;

            if (abs($dbCount - $indexCount) > 5) {
                Log::channel('search')->warning('Search index drift detected', [
                    'tenant_id' => $tenant->id,
                    'db_count' => $dbCount,
                    'index_count' => $indexCount,
                ]);
            }
        });
    }
}

3. Relevance ranking reflects what the data actually means, not the engine's defaults#

A search engine's out-of-the-box ranking is tuned for general text relevance — word proximity, typo tolerance, term frequency. It has no idea that on SafetySpace, a SWMS template updated last week should usually outrank one that's five years old even if the older one matches the search term slightly more literally, or that on ReplyVibe, a one-star review mentioning a specific product defect is often more useful to surface first than a five-star review that happens to use the same words in passing. Meilisearch's ranking rules are ordered and composable — this is where a generic index becomes a search experience that matches how the product is actually used:

php
// config/scout.php
'meilisearch' => [
    'index-settings' => [
        SafetyDocument::class => [
            'filterableAttributes' => ['tenant_id', 'allowed_role_ids', 'document_type'],
            'sortableAttributes' => ['created_at'],
            'rankingRules' => [
                'words',
                'typo',
                'proximity',
                'attribute',
                'sort',
                'exactness',
                'created_at:desc', // recency as a tiebreaker, not the primary signal
            ],
        ],
    ],
],

Typo tolerance matters more than it sounds like it should for a field-heavy product: SafetySpace's field workers are often searching from a phone, one-handed, on a job site — a search for "hazzard assessment" needs to still find "hazard assessment" templates, or the search bar becomes something people stop trusting and go back to scrolling folders instead, which defeats the entire point of building it.

php
'typoTolerance' => [
    'minWordSizeForTypos' => ['oneTypo' => 4, 'twoTypos' => 8],
],

4. Search results are filtered by the same authorization boundary as everything else#

This is the failure mode that's easy to miss entirely, because it doesn't show up as "search is broken" — it shows up as a data leak that nobody notices until an audit. If your app's Policy layer says a Field Worker role can't view a specific restricted SWMS template, but the search index has no idea permissions exist, then a search result is a second, un-audited path to the exact same document — the same class of bug covered in RBAC architecture in Laravel SaaS: an authorization boundary that's enforced in the app's Policy layer but not in the search layer isn't actually enforced everywhere the data can be reached.

php
class SafetyDocumentSearch
{
    public function query(User $user, string $term): Collection
    {
        $roleIds = $user->roles()->pluck('id')->implode(',');

        return SafetyDocument::search($term)
            ->where('tenant_id', $user->tenant_id)
            ->options([
                'filter' => "allowed_role_ids IS EMPTY OR allowed_role_ids IN [{$roleIds}]",
            ])
            ->get();
    }
}

allowed_role_ids is indexed as a searchable document's own field precisely so the permission check happens inside Meilisearch's filter, before results are even returned to the app — not as a ->filter() on the PHP collection afterward, which still means a badly-paginated request could return a page entirely made of documents the user isn't allowed to see, with zero visible results left after the app-side filter runs. The search index has to know about access control, because it's a second surface where the data lives, and a second surface with a smaller permission model than the primary one is exactly how "search leaked something the UI never would have shown" incidents happen.

Step-by-Step: Building the Pipeline#

  1. Pick Meilisearch (or a hosted equivalent) over database LIKE queries the moment search is a primary way users find data, not a nice-to-have. Postgres full-text search (tsvector/tsquery) is a reasonable middle step for a single feature with modest volume; a dedicated search engine is worth the operational cost once search spans multiple models, needs typo tolerance, or has to stay fast past a few hundred thousand rows per tenant.
  1. Define toSearchableArray() deliberately — index what's searched and filtered on, not the entire model. Sending every column to the search engine bloats the index and slows every write; a document's full audit trail or binary file content has no business being reindexed on every update when only its title and extracted text are ever actually searched.
  1. Route indexing through its own queue, as shown above, isolated from user-facing jobs — a bulk import of 10,000 SwapPad activation records shouldn't compete with a customer-facing search-index update for the same worker pool.
  1. Cache repeat searches for hot, low-cardinality queries — the same stampede-aware pattern from Redis caching architecture, keyed by tenant, term, and filters together:
php
public function query(User $user, string $term, array $filters): Collection
{
    $key = CacheKey::tenant($user->tenant_id, 'search', md5($term . json_encode($filters)));

    return Cache::remember($key, now()->addMinutes(2), fn () => $this->runSearch($user, $term, $filters));
}

A short TTL is deliberate here — search results need to reflect recent writes quickly, so this is a cache for repeat identical queries within a tight window (a user retyping, a dashboard re-rendering), not a substitute for correct invalidation.

  1. Debounce on the frontend, not just cache on the backend. A search-as-you-type input firing a request on every keystroke turns a two-word search into six or eight full round-trips before the user finishes typing — 200–300ms of debounce client-side cuts real request volume more than any backend optimization can, for free.
  1. Reindex in batches during a schema or ranking-rule change, never row-by-row in a loop that reopens a connection each time:
php
Artisan::call('scout:flush', ['model' => SafetyDocument::class]);
Artisan::call('scout:import', ['model' => SafetyDocument::class]);

scout:import chunks and dispatches through the same queue configuration already in place — running it during a low-traffic window, tenant by tenant if the reindex is scoped to a schema change affecting one customer, keeps a full reindex from competing with live indexing traffic.

  1. Test that search actually respects the tenant and permission boundary, not just that it returns matches — the same negative-case discipline from testing strategy in Laravel SaaS: a passing suite that only asserts a matching term returns a result will never catch a regression where tenant scoping silently dropped off a query.
php
public function test_search_never_returns_another_tenants_document(): void
{
    $tenantA = Tenant::factory()->create();
    $tenantB = Tenant::factory()->create();

    SafetyDocument::factory()->for($tenantA)->create(['title' => 'Fall Protection SWMS']);
    SafetyDocument::factory()->for($tenantB)->create(['title' => 'Fall Protection SWMS']);

    $userA = User::factory()->for($tenantA)->create();

    $results = app(SafetyDocumentSearch::class)->query($userA, 'Fall Protection');

    $this->assertCount(1, $results);
    $this->assertEquals($tenantA->id, $results->first()->tenant_id);
}

Real-World Pitfalls to Avoid#

LIKE '%term%' past a few hundred thousand rows. A leading wildcard can't use a standard B-tree index — every search is a full table scan that gets slower in direct proportion to table growth, which is exactly the wrong performance curve for a feature that's supposed to get more useful as a customer's data grows, not less.

No tenant filter enforced inside the search engine itself. Filtering search results by tenant in PHP after they come back from the index means a badly-paginated request can return a page that's mostly or entirely another tenant's data before the app-side filter strips it out — the filter has to be a filterableAttributes constraint applied inside the query, not a post-hoc ->filter().

Search that doesn't know authorization exists. A document a user can't open through the UI but can find through search is a real data leak, not a UX inconsistency — permission fields need to be indexed and filtered on exactly like the tenant boundary, described in §4 above.

Synchronous indexing on every write. A bulk import or a high-write-volume feature that indexes inline, in the same request or job as the write, turns an otherwise-fast operation into one gated on Meilisearch's response time for every single row — queue it, the same way any other non-urgent side effect gets queued.

No reconciliation for writes that bypass Eloquent events. A raw DB::table()->update(), a database seed, or a migration that touches searchable rows directly doesn't fire the model events Scout listens to — the index silently drifts out of sync, and nothing surfaces that until a customer notices a record that "isn't findable" or one that shouldn't exist anymore still is.

Default ranking rules left untouched for data where recency or severity matters more than raw term match. A five-year-old document that happens to match a search term more literally outranking last week's update isn't a neutral default — it's actively wrong for how most operational SaaS data actually gets searched.

No debounce on the frontend. Firing a full search request on every keystroke multiplies request volume for no benefit — the user was going to finish typing in another 200ms anyway, and the backend shouldn't have to answer six queries to get to the one that mattered.

Key Takeaways#

Full-text search earns its place in a Laravel SaaS product the same way caching, queueing, and authorization do — as a system with a correctness story, not a feature that's "done" the moment a search box returns something plausible.

  • The tenant boundary is a filterable attribute enforced inside the search engine, not a PHP filter applied after results already left the index
  • Indexing runs asynchronously on its own queue and reconciles against the database on a schedule, because model events alone don't catch every write path
  • Ranking rules are tuned to what "relevant" actually means for the data — recency, severity, or role, not just raw term proximity
  • Search results respect the exact same authorization boundary as the rest of the app, with permission fields indexed and filtered inside the query, not bolted on afterward
  • Repeat queries are cached with a short, deliberate TTL, and the frontend debounces before a request ever fires

I've built this discipline into a document management product where employees need to find a file among thousands in under a second, an AI safety platform where search has to respect the same access controls as everything else, a reputation management tool where relevance has to reflect sentiment and recency, and a dealer back office where support reps need an instant answer among tens of thousands of activation records. The data changes; the requirement that search be fast, correct, and scoped to exactly what someone's allowed to see doesn't.

If your product's search is still a LIKE clause that's starting to show its age — or a search box that technically works but nobody fully trusts to be complete or scoped correctly — get in touch about your search architecture or see the full case studies from platforms running tenant-aware, permission-safe search in production today.

Share this technical insight with your network

Share to LinkedIn or Facebook with key takeaways, featured media, and direct links.

📁 Production Case Study

Case Study: Violerts - Enterprise NYC PropTech Compliance & Violation Monitoring SaaS

Violerts is a PropTech SaaS platform that consolidates fragmented NYC municipal property data into a single compliance intelligence platform. I led the modernization of the React frontend and Laravel backend, building multi-agency data ingestion, GIS mapping, asynchronous scraping, real-time alerts, team collaboration, and Stripe-powered SaaS billing.

Related Technical Articles

View all articles →
✦ Let's Build Together

Have a complex technical project in mind?

Available for full-stack engineering, performance audits, cloud deployments, and high-concurrency systems architecture.

Need a web or software development partner?

Tell me what you’re building, what’s getting in the way, and where you need help. Whether you need a custom web application, SaaS platform, API integration, or full-stack development, I’ll give you a clear answer on scope, cost, and timeline usually within one business day.

AqibJavaid

Senior Full-Stack Engineer building backend systems, cloud infrastructure and product platforms for teams that need them to stay up.

Available for new projects

Get in touch

© 2026 Aqib Javaid. All rights reserved.

Built and maintained by Aqib Javaid