| name | kanvas-search |
| description | Add `@search` to a GraphQL list query — pick `DatabaseSearchableTrait` vs `DynamicSearchableTrait`, implement `searchableAs()`/`toSearchableArray()`/`shouldBeSearchable()`, and (critical) override `search()` to scope by `apps_id` + `companies_id` so search doesn't leak across tenants. Load when adding `search: String @search` to a query, adding a searchable trait to a model, or auditing/fixing multi-tenant search scoping. |
Adding @search to GraphQL Queries
All list queries should support the @search directive for text search. This requires three things: the trait on the model, the search: String @search parameter on the query, AND a search() override to enforce multi-tenancy.
1. Add the Trait to the Model
For simple database-only search (most models):
use Baka\Traits\DatabaseSearchableTrait;
use Kanvas\Apps\Models\Apps;
class MyModel extends BaseModel
{
use DatabaseSearchableTrait;
public function searchableAs(): string
{
$app = $this->app ?? app(Apps::class);
$customIndex = $app->get('app_custom_{model}_index') ?? null;
return config('scout.prefix') . ($customIndex ?? '{model}_index');
}
public function toSearchableArray(): array
{
return [
'id' => $this->id,
'name' => $this->name,
];
}
public function shouldBeSearchable(): bool
{
return ! $this->isDeleted();
}
}
For models that need Algolia/Typesense indexing (Products, Leads, Messages, etc.):
use Baka\Traits\DynamicSearchableTrait;
class MyModel extends BaseModel
{
use DynamicSearchableTrait {
search as public traitSearch;
}
public function searchableAs(): string
{
$model = ! $this->searchableDeleteRecord() ? $this : $this->withTrashed()->find($this->id);
$app = $model->app ?? app(Apps::class);
$customIndex = $app->get('app_custom_{model}_index') ?? null;
return config('scout.prefix') . ($customIndex ?? '{model}_index');
}
public function toSearchableArray(): array
{
return [
'id' => $this->id,
'name' => $this->name,
];
}
public function shouldBeSearchable(): bool
{
return ! $this->isDeleted();
}
}
2. Add search Parameter to the GraphQL Query
extend type Query @guard {
myEntities(
search: String @search
where: _ @whereConditions(columns: ["id", "name"])
orderBy: _ @orderBy(columns: ["id", "created_at", "name"])
): [MyEntity!]!
@paginate(
model: "Kanvas\\Domain\\Models\\MyModel"
scopes: ["fromApp", "notDeleted"]
defaultCount: 25
)
}
Which Trait to Use
| Trait | Use When | Examples |
|---|
DatabaseSearchableTrait | Simple models, no external search engine needed | Categories, Channels, Warehouses, Status, Pipeline, Action |
DynamicSearchableTrait | Need Algolia/Typesense indexing, full-text search | Products, Leads, Messages, Agents |
Algolia Index Configuration Requirement
When a model uses DynamicSearchableTrait with Algolia and the search() method filters by numeric attributes (e.g., apps_id, companies_id), those attributes must be configured in the Algolia dashboard for the index:
- Go to the Algolia dashboard > select the index (e.g.,
dev-prompt_messages)
- Navigate to Configuration > Filtering and Faceting > Attributes for faceting
- Add
filterOnly(apps_id) and filterOnly(companies_id)
- If the attribute is purely numeric, also check numericAttributesForFiltering — by default all numeric attributes are filterable, but if a custom list is set, the attribute must be included
Without this, Algolia returns: "invalid numeric attribute(apps_id), attribute not specified in numericAttributesForFiltering setting"
Note: Each app can have a custom index name (e.g., app_custom_message_index), so this must be configured per-index in the Algolia UI.
3. Add Search Scoping to Prevent Data Leaks
Every model that uses @search MUST override the search() method to scope results by apps_id and companies_id. Without this, search queries can leak data across apps and companies.
Both DatabaseSearchableTrait and DynamicSearchableTrait alias search as traitSearch, so the pattern is the same.
Multi-Tenant Search Patterns
Standard pattern (most models — Templates, simple entities):
use Baka\Users\Contracts\UserInterface;
use Kanvas\Apps\Models\Apps;
public static function search($query = '', $callback = null)
{
$query = self::traitSearch($query, $callback)->where('apps_id', app(Apps::class)->getId());
$user = auth()->user();
if ($user instanceof UserInterface && ! $user->isAppOwner()) {
$query->where('companies_id', $user->getCurrentCompany()->getId());
}
return $query;
}
Branch-aware pattern (Lead model — uses CompaniesBranches binding when available):
use Kanvas\Companies\Models\CompaniesBranches;
public static function search($query = '', $callback = null)
{
$query = self::traitSearch($query, $callback)->where('apps_id', app(Apps::class)->getId());
$user = auth()->user();
if ($user instanceof UserInterface && app()->bound(CompaniesBranches::class)) {
$query->where('companies_id', app(CompaniesBranches::class)->company->getId());
} elseif ($user instanceof UserInterface && ! $user->isAppOwner()) {
$query->where('companies_id', $user->getCurrentCompany()->getId());
}
return $query;
}
Product pattern (supports opt-in company-bound search via app config + Algolia callback):
public static function search($query = '', $callback = null)
{
$app = app(Apps::class);
$searchQuery = self::traitSearch($query, $callback)->where('apps_id', $app->getId());
$user = auth()->user();
if (
$user instanceof UserInterface &&
(
! $user->isAppOwner() ||
(app()->bound(CompaniesBranches::class) && $app->get('enable_company_bound_search', false))
)
) {
$searchQuery->where('company.id', $user->getCurrentCompany()->getId());
}
return $searchQuery;
}
Users pattern (uses whereIn for array-based Algolia/Typesense filters):
public static function search($query = '', $callback = null)
{
$query = self::traitSearch($query, $callback)->whereIn('apps', [app(Apps::class)->getId()]);
$user = auth()->user();
if ($user instanceof UserInterface && ! $user->isAppOwner()) {
$query->whereIn('companies', [$user->currentCompanyId()]);
}
return $query;
}
Key rules for search()
- Always filter by
apps_id — no exceptions
- Always filter by
companies_id for non-app-owners — prevents cross-company data leaks
- Use
isAppOwner() (not isAdmin()) for the company-scoping check — isAppOwner() returns true only for @guardByAppKey requests with Owner role
isAdmin() returns true for any Admin/Owner role regardless of auth method, which would skip company filtering on @guard endpoints
- Check
CompaniesBranches binding when the entity is branch-scoped — this ensures the correct company context when a request targets a specific branch
- Filter field names vary by search engine: Algolia uses nested paths like
company.id, Typesense/database use flat companies_id. Match what's in toSearchableArray()
@search bypasses @paginate(builder:) scoping — When Lighthouse's @search directive is active, it calls Model::search() and results come entirely from the search engine. The custom builder specified in @paginate(builder: ...) is NOT applied. The search() method is the only place to enforce multi-tenancy during search
- When using
search() in a custom builder (not via @search), call traitSearch() directly with explicit filters instead of the model's search() method, since search() auto-scopes to the logged-in user's company which may not be the target company
Typesense schema requirement: Models using DynamicSearchableTrait that may use the Typesense engine MUST implement typesenseCollectionSchema(). Without it, the Typesense engine throws Parameter 'fields' is required when creating the collection. The method should define fields matching toSearchableArray().
Typesense id MUST be a string — the #1 recurring production break. Typesense rejects any document whose id field is an integer with Document's 'id' field should be a string (import fails silently mid-reindex, e.g. KANVAS-ECOSYSTEM). This bites because toSearchableArray() usually does ...$this->toArray() which spreads the numeric primary key. Two things are required together:
- Cast in
toSearchableArray() — after any ...$this->toArray() spread, override id:
public function toSearchableArray(): array
{
return [
...$this->toArray(),
'id' => (string) $this->id,
];
}
- Declare
string in typesenseCollectionSchema() — the id field must be 'type' => 'string', NOT 'int64':
public function typesenseCollectionSchema(): array
{
return [
'name' => $this->searchableAs(),
'fields' => [
['name' => 'id', 'type' => 'string'],
['name' => 'name', 'type' => 'string', 'optional' => true],
],
];
}
query_by requirement (Typesense only): Typesense — unlike Algolia — requires an explicit query_by on every search naming which fields to match, or it 400s with No search fields specified for the query (KANVAS-ECOSYSTEM-626). Set it inside the search() override behind an isTypesense() gate, and again in any place that calls traitSearch() directly (traitSearch bypasses search()):
$query = self::traitSearch($query, $callback)->where('apps_id', app(Apps::class)->getId());
if ($query->model->isTypesense()) {
$query->options(['query_by' => 'name,email,slug']);
}
Typesense collections are immutable after creation — getOrCreateCollectionFromModel() only creates a missing collection; it never alters an existing one. Changing typesenseCollectionSchema() (new field, int64→string on id, new query_by target) has NO effect on a live collection until you drop it and reindex. Code change alone won't fix a schema mismatch in production.
Placement: Place the search() method at the end of the class, not at the top. Properties ($table, $guarded, casts()) and relationships should come first.
Analytics/aggregation queries — stricter rule
For analytics queries that aggregate across rows (counts, sums, distributions), filtering by apps_id + companies_id is mandatory at the base action level — no app-owner bypass, no exceptions. Every analytics endpoint needs a cross-tenant leak test.