Laravel Scout Multi Tenant Search: Shared Index or One Index per Tenant
By ERAG··11 min read
Laravel Scout makes full-text search easy: add a trait, run an import, call Contact::search('acme'). But Scout has no idea what a tenant is. In a multi-tenant app that's a problem, because one search box must only ever return the current customer's records. With a database per tenant it gets worse: every tenant's contacts table starts at ID 1, so a naive setup doesn't just leak results, it overwrites one tenant's documents with another's.
In this post we set up Laravel Scout multi tenant search with Meilisearch and stancl/tenancy. We compare a shared index with a tenant filter against one index per tenant, show how to set a per-tenant Scout prefix that actually works in web requests, queued jobs and Artisan commands, and cover tenant tokens for frontend search, the database driver, Typesense, reindexing every tenant and cleaning up when a tenant is deleted. Meilisearch's own Laravel guide covers a single database with an organization column. Here we also handle database-per-tenant.
How Scout picks an index
Scout syncs each searchable model to an index. The default name comes from searchableAs() in the Searchable trait, which is just the configured prefix plus the table name:
// Laravel\Scout\Searchable (Scout 11)
public function searchableAs()
{
return config('scout.prefix').$this->getTable();
}Two details matter for multi-tenancy. The prefix is read from config each time the method runs, not once at boot. And Scout's own commands, like scout:sync-index-settings and scout:delete-index, resolve model indexes through indexableAs(), which calls searchableAs(). So if you change scout.prefix at runtime, every part of Scout follows. We'll use that below.
Four ways to do multi tenant search in Laravel
| Approach | Isolation | Works with | Best for |
|---|---|---|---|
Shared index + tenant_id filter | Filter on every query | Meilisearch, Algolia, Typesense | Many small tenants, one place to tune |
| One index per tenant | Separate index | Meilisearch, Algolia, Typesense | Database-per-tenant apps, easy deletion |
Scout database driver | Tenant's own database | MySQL, PostgreSQL | Small apps, no extra service |
| Typesense with scoped keys | Filter embedded in key | Typesense | Teams already on Typesense |
Meilisearch recommends the shared index. Its multi-tenancy guide says a large number of indexes degrades performance, especially for multi-index searches, and every settings change has to be applied to each index. That's sound advice for a few thousand tenants. If you have a few hundred tenants with a database each, one index per tenant matches the rest of your isolation and makes deletion trivial. Both work. Pick one per app, not per model.
Option 1: one shared index with a tenant_id filter
Add the tenant to every document and make it filterable:
final class Contact extends Model
{
use HasUlids, Searchable;
/**
* @return array<string, mixed>
*/
public function toSearchableArray(): array
{
return [
'id' => $this->id,
'tenant_id' => tenant()->getTenantKey(),
'name' => $this->name,
'email' => $this->email,
'company' => $this->company,
];
}
}// config/scout.php
'meilisearch' => [
'host' => env('MEILISEARCH_HOST', 'http://localhost:7700'),
'key' => env('MEILISEARCH_KEY'),
'index-settings' => [
Contact::class => [
'filterableAttributes' => ['tenant_id'],
],
],
],Run php artisan scout:sync-index-settings after changing settings, ideally in your deploy script. Then every query filters:
Contact::search($term)->where('tenant_id', tenant()->getTenantKey())->paginate(20);Database-per-tenant needs globally unique IDs
Look at the HasUlids in the model above. It's not optional here. Meilisearch stores one document per primary key. With auto-increment IDs, tenant A's contact 7 and tenant B's contact 7 are the same document, so whichever was saved last wins, and deleting one deletes the other. Search results are mapped back through whereIn('id', ...) on the current tenant's database, so a hit from another tenant can even turn into the wrong local record.
ULID (or UUID) primary keys fix this, because no two tenants ever generate the same ID. If you can't change the primary key, add a unique search_key column (like "{$tenantId}-{$id}") and return it from getScoutKey() and getScoutKeyName(), plus 'string' from getScoutKeyType(), so Scout uses it both in the index and for the database lookup.
Never forget the filter
One missing where() and a customer sees someone else's contacts in a search box. Don't rely on remembering it. Make the scoped search the only one:
use Laravel\Scout\Builder;
use Laravel\Scout\Searchable;
trait TenantSearchable
{
use Searchable {
Searchable::search as scoutSearch;
}
public static function search($query = '', $callback = null): Builder
{
return static::scoutSearch($query, $callback)
->where('tenant_id', tenant()->getTenantKey());
}
}Models use TenantSearchable instead of Searchable. tenant() returns null outside a tenant, so this fails loudly in the central context instead of searching across everyone. Good.
Frontend search with Meilisearch tenant tokens
For instant search, the browser often talks to Meilisearch directly. Never give it a normal search key, because it can search everything. Give it a tenant token: a short-lived JWT, signed with a search API key, with the filter baked in. Meilisearch applies the filter to every query made with the token, and the user can't remove it.
final class SearchTokenService
{
public function forCurrentTenant(): string
{
$index = (new Contact)->searchableAs();
return app(EngineManager::class)->engine('meilisearch')->generateTenantToken(
config('services.meilisearch.search_key_uid'),
[$index => ['filter' => 'tenant_id = '.tenant()->getTenantKey()]],
[
'apiKey' => config('services.meilisearch.search_key'),
'expiresAt' => now()->addHour(),
],
);
}
}If your tenant keys are strings, quote them in the filter (tenant_id = "acme"). Scout's Meilisearch engine forwards unknown methods to the Meilisearch PHP client, which has generateTenantToken($apiKeyUid, $searchRules, $options). Use the default search key and its uid (from GET /keys), not your master key. Share the token as an Inertia prop on the search page, and use it as the API key in Meilisearch's JavaScript client. The details are in Meilisearch's tenant token guide.
Option 2: one index per tenant with stancl/tenancy
Now the other approach: tenant_42_contacts, tenant_43_contacts and so on. Because Scout reads scout.prefix at call time, all we need is to set it when tenancy starts and restore it when tenancy ends. stancl/tenancy has exactly the hook for that: a custom bootstrapper.
use Illuminate\Contracts\Config\Repository;
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
use Stancl\Tenancy\Contracts\Tenant;
final class ScoutPrefixBootstrapper implements TenancyBootstrapper
{
private readonly string $centralPrefix;
public function __construct(private readonly Repository $config)
{
$this->centralPrefix = (string) $config->get('scout.prefix', '');
}
public function bootstrap(Tenant $tenant): void
{
$this->config->set('scout.prefix', $this->prefixFor($tenant));
}
public function revert(): void
{
$this->config->set('scout.prefix', $this->centralPrefix);
}
public function prefixFor(Tenant $tenant): string
{
return $this->centralPrefix.'tenant_'.$tenant->getTenantKey().'_';
}
}Register it in config/tenancy.php:
'bootstrappers' => [
Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper::class,
Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper::class,
Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper::class,
Stancl\Tenancy\Bootstrappers\QueueTenancyBootstrapper::class,
App\Tenancy\ScoutPrefixBootstrapper::class,
],stancl registers every bootstrapper as a singleton, so the instance that saved the central prefix is the one that restores it. Keeping the central prefix in front (for example SCOUT_PREFIX=prod_) lets staging and production share one Meilisearch server. The trailing underscore matters too: without it, tenant_4 would be a prefix of tenant_42.
That one class covers all the places a model gets indexed:
- Web requests. Tenancy initializes from the domain, so
Contact::search()hitstenant_42_contacts. - Queued Scout jobs. The queue bootstrapper restores the tenant before the job runs, our bootstrapper runs with it, and the job writes to the right index.
- Artisan commands.
tenants:runinitializes each tenant in turn, soscout:importandscout:sync-index-settingsact on that tenant's indexes.
No filter is needed in queries, and the ID collision problem disappears, because each tenant's IDs live in their own index. If a model overrides searchableAs(), keep the prefix in it, or that model silently goes back to one shared index:
public function searchableAs(): string
{
return config('scout.prefix').'people';
}Settings and tokens for per-tenant indexes
index-settings in config/scout.php are keyed by model class, so they apply to every tenant's index. Meilisearch creates an index the first time a document is added, but without your settings. Sync settings for a new tenant right after it's created, for example as a step in stancl's TenantCreated job pipeline that runs $tenant->run(fn () => Artisan::call('scout:sync-index-settings')). For existing tenants, run it through tenants:run (below).
Tenant tokens get simpler too. Search rules accept index patterns, so a token that can only see one tenant's indexes needs no filter at all:
$prefix = app(ScoutPrefixBootstrapper::class)->prefixFor(tenant());
$rules = [$prefix.'*' => (object) []];The (object) cast matters: an empty PHP array would be sent as [], not {}.
Option 3: the database driver for small apps
If you have a few thousand records per tenant and don't want to run another service, use Scout's database driver (SCOUT_DRIVER=database). It searches your tables directly with LIKE and full-text queries, supports MySQL and PostgreSQL, and needs no indexing step. In a database-per-tenant app it's isolated for free: the query runs on the tenant's own connection.
use Laravel\Scout\Attributes\SearchUsingFullText;
use Laravel\Scout\Attributes\SearchUsingPrefix;
#[SearchUsingPrefix(['email'])]
#[SearchUsingFullText(['notes'])]
public function toSearchableArray(): array
{
return ['name' => $this->name, 'email' => $this->email, 'notes' => $this->notes];
}SearchUsingFullText needs a full-text index on the column in your tenant migration. You lose typo tolerance and relevance ranking, but you can switch to Meilisearch later without changing your search() calls. searchableAs() and the prefix have no effect with this driver.
A note on Typesense
Scout's Typesense driver uses the same searchableAs() for collection names, so the per-tenant prefix bootstrapper works unchanged. For a shared collection, Typesense has scoped search API keys: you embed a filter_by clause in a key derived from a search key, and users can't override it. That's the same idea as Meilisearch tenant tokens. Remember that Typesense collections need a schema in config/scout.php. To change it, either run scout:flush and scout:import for every tenant or update the collection through Typesense's API.
Queued indexing in tenant context
Scout recommends a queue for any engine other than database and collection, so saving a model doesn't wait on an HTTP call to Meilisearch:
// config/scout.php
'queue' => ['connection' => 'redis', 'queue' => 'scout'],
'after_commit' => true,With stancl's QueueTenancyBootstrapper, the MakeSearchable job carries the tenant ID, and the worker initializes that tenant before it restores the models from the tenant's database. Without the bootstrapper the job would look for contact 7 in the central database. after_commit makes sure the job isn't dispatched before the transaction commits. Our post on multi-tenant queues explains how the tenant travels with a job.
A queued searchable() call on a big query splits into chunks, one job per chunk. Give Scout its own queue, so a reindex of a large tenant doesn't delay password reset emails.
Reindexing all tenants with tenants:run
Commands run in the central context. Wrap Scout's commands in tenants:run to run them once per tenant:
# One index per tenant: safe, each run touches one tenant's index
php artisan tenants:run scout:sync-index-settings
php artisan tenants:run scout:import --argument="model=App\Models\Contact"
# A single tenant
php artisan tenants:run scout:import --tenants=42 --argument="model=App\Models\Contact"With a shared index, be careful. scout:flush and scout:import --fresh call deleteAllDocuments() on the index, which wipes every tenant, not just the one you're looping over. Reindex a single tenant by deleting its documents with a filter and importing again:
final class SearchIndexService
{
public function reindexCurrentTenant(): void
{
app(EngineManager::class)->engine('meilisearch')
->index((new Contact)->searchableAs())
->deleteDocuments(['filter' => 'tenant_id = '.tenant()->getTenantKey()]);
Contact::makeAllSearchable();
}
}For a large number of tenants, dispatch one queued job per tenant instead of a long-running tenants:run, as described in multi-tenant scheduled tasks.
Delete a tenant's index when the tenant is deleted
stancl drops the tenant database, but it doesn't know about your search engine. Documents for a deleted customer staying in Meilisearch is a data protection problem. Listen to DeletingTenant, while the tenant row still exists:
final class DeleteTenantSearchData
{
/** @var list<class-string<Model>> */
private const MODELS = [Contact::class, Deal::class];
public function handle(DeletingTenant $event): void
{
$engine = app(EngineManager::class)->engine();
// Inside run(), searchableAs() returns this tenant's index names
$event->tenant->run(function () use ($engine) {
foreach (self::MODELS as $model) {
$engine->deleteIndex((new $model)->searchableAs());
}
});
}
}With a shared index, call deleteDocuments(['filter' => 'tenant_id = ...']) on each index instead. Don't reach for scout:delete-all-indexes in the central context: it deletes every index whose name starts with the current prefix, and in the central context that prefix is empty or your environment prefix. That's all tenants. Our guide to deleting tenants safely covers the rest of the cleanup.
Mistakes we see
- Auto-increment IDs in a shared index. In database-per-tenant apps they collide. Use ULIDs or a per-tenant index.
- Filtering in the controller. Sooner or later someone writes a search without it. Put the filter in the model.
- A plain search key in the browser. It can query every tenant. Use tenant tokens or scoped keys.
- Forgetting the queue bootstrapper. Queued Scout jobs then read the central database and fail, or index nothing.
--freshon a shared index. It flushes everyone's documents.- New tenants without index settings. Filters and sorting fail until the settings are synced.
Frequently asked questions
Does Laravel Scout support multi-tenancy?
Not by itself. Scout has no tenant concept, so you add it: a tenant_id field and filter in a shared index, or a per-tenant index prefix set when tenancy initializes. Scout reads scout.prefix on every call, which makes the second option a small bootstrapper class.
Should I use one Meilisearch index per tenant?
Meilisearch recommends one shared index with tenant tokens, because many indexes cost performance and every settings change must be applied to each. One index per tenant is still reasonable for a few hundred tenants with database-per-tenant isolation, and it makes deleting a tenant's data a single call.
How do I keep queued Scout jobs in the right tenant?
Enable stancl/tenancy's QueueTenancyBootstrapper. It stores the tenant ID with each queued job and initializes that tenant before the job runs, so Scout's MakeSearchable job loads models from the right database and writes to the right index.
How do I reindex every tenant in Laravel Scout?
Run php artisan tenants:run scout:import --argument="model=App\Models\Contact". With one index per tenant that's safe. With a shared index, don't use --fresh or scout:flush, which clear all tenants; delete a tenant's documents by filter and import again instead.
Adding search to SaaS Laravel
The SaaS Laravel starter kits don't include search, Scout or Meilisearch, so this is something you'd add. They do give you what the per-tenant approach needs: stancl/tenancy v3 with a database per tenant and QueueTenancyBootstrapper already enabled, so queued Scout jobs run for the right tenant out of the box. Tenant IDs are auto-increment integers, which is one more reason we'd go with one index per tenant. We'd add the bootstrapper to config/tenancy.php, put a SearchIndexService in the module that owns the searchable models, add a DeletingTenant listener in TenancyServiceProvider, and gate search pages with a tenant permission in config/permissions/tenant/. The multi-tenancy docs show how to run code in a tenant's context, and the packages reference lists what's installed.