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,
// ... other searchable fields
];
}
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,
// ... other searchable fields
];
}
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)andfilterOnly(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();
// When CompaniesBranches is bound (request scoped to a branch), use that company
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_idfor non-app-owners — prevents cross-company data leaks - Use
isAppOwner()(notisAdmin()) for the company-scoping check —isAppOwner()returnstrueonly for@guardByAppKeyrequests with Owner role isAdmin()returnstruefor any Admin/Owner role regardless of auth method, which would skip company filtering on@guardendpoints- Check
CompaniesBranchesbinding 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 flatcompanies_id. Match what's intoSearchableArray() @searchbypasses@paginate(builder:)scoping — When Lighthouse's@searchdirective is active, it callsModel::search()and results come entirely from the search engine. The custom builder specified in@paginate(builder: ...)is NOT applied. Thesearch()method is the only place to enforce multi-tenancy during search- When using
search()in a custom builder (not via@search), calltraitSearch()directly with explicit filters instead of the model'ssearch()method, sincesearch()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().
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.