Resource Queries
Resource::query() is the single entry point for every record a resource can reach. Override it once and the scope applies to the list, the record pages, the actions, the bulk operations, and global search alike. This page covers that method, the declarations that feed it, and the layers that narrow it further.
The minimal override
use Illuminate\Database\Eloquent\Builder;
public static function query(): Builder
{
return parent::query()->where('team_id', currentTeamId());
}2
3
4
5
6
That is the whole mechanism. Nothing else has to be touched: a record with another team's id is a 404 on the view page, on the edit page, from the action endpoint, and from the command palette — not a row that was filtered out of a list but still reachable by URL.
Always call parent::query(). The base method applies the resource's eager loads, the nested-resource scope, the tenant scope, and the panel's own narrowing. Starting from Model::query() instead silently drops all four.
What the base method does
public static function query(): Builder
{
$query = static::isNested()
? static::parentRelation()->getQuery()->with(static::$with)
: static::getModel()::query()->with(static::$with);
$query = static::applyTenantScope($query);
return static::configurationIn(panel())?->applyQuery($query) ?? $query;
}2
3
4
5
6
7
8
9
10
Three things in order: the starting builder (the model's, or the parent record's relation for a nested resource), the tenant scope, and the current panel's configured narrowing.
Everything that reads through it
| Surface | Path |
|---|---|
| The index | ListRecords::render() starts from query() and hands it to the table layer |
| View, edit, and custom record pages | resolveRecord() → recordQuery() → query() |
| Record, cell, and infolist actions | findRecord() → recordQuery() → query() |
| Bulk actions | findRecords() → recordQuery() → query() |
| Global search | globalSearchQuery(), which returns query() |
| File uploads attached to a record | query() directly |
| Exports | the same table query the list built |
recordQuery() is query() with exactly one difference — SoftDeletingScope is lifted for a resource that declares $softDeletes — so a trashed record can be opened and restored while tenant, module, and permission scopes still apply. See Soft deletes.
Eager loading
/** @var list<string> */
protected static array $with = ['author', 'tags'];2
Applied on every query the resource builds, so serializing a column can never trigger a lazy load per row. This is not an optimisation to add later: with Model::shouldBeStrict() on outside production, a column reading an unloaded relation fails loudly, and without strict mode it quietly costs a query per record.
The list page's serialization is expected to run a fixed number of queries whatever the page size — the package's own test asserts that five rows and thirty-five rows produce the same query count.
For a relation needed by one column only on one panel, prefer $with; for something conditional, add it in the override:
use Illuminate\Database\Eloquent\Builder;
public static function query(): Builder
{
return parent::query()->withCount('comments');
}2
3
4
5
6
Narrowing per panel
A resource registered in two panels can mean something narrower in one of them, without a subclass:
use Illuminate\Database\Eloquent\Builder;
use PandaPanel\Resources\ResourceConfiguration;
$panel->resources([
ResourceConfiguration::for(UserResource::class)
->slug('people')
->modifyQueryUsing(static fn (Builder $query): Builder => $query->where('is_admin', false)),
]);2
3
4
5
6
7
8
modifyQueryUsing() is applied by query() itself, last, so it composes with whatever the resource already did. The same 404 guarantee holds: from that panel, an administrator record cannot be opened, edited, deleted, bulk-selected, or found by search. See Per-panel configuration.
Narrowing per tab
use Illuminate\Database\Eloquent\Builder;
use PandaPanel\Tables\Tab;
/**
* @return array<string, Tab>
*/
public function tabs(): array
{
return [
'all' => Tab::make('all'),
'drafts' => Tab::make('drafts')
->query(static fn (Builder $query): Builder => $query->whereNull('published_at')),
];
}2
3
4
5
6
7
8
9
10
11
12
13
14
A tab receives Resource::query() and returns it narrowed. It is a presentation filter, not a security boundary: the record pages know nothing about tabs, so a record hidden by a tab is still reachable by URL. Use query() or modifyQueryUsing() for anything that must not be reachable.
Tenant scoping
A resource in a tenant-scoped panel opts in by naming the relationship that leads to the tenant:
final class DocumentResource extends Resource
{
protected static string $model = Document::class;
protected static ?string $tenantRelationship = 'workspace';
}2
3
4
5
6
public static function tenantRelationship(): ?string; // reads $tenantRelationshipNaming one is the whole opt-in. A resource that names nothing is not scoped, which is right for the two cases that actually occur: a database-per-tenant arrangement where the connection is already the boundary, and a genuinely global table — a plan, a country, a feature flag — that every tenant reads.
The scope is built with whereHas, so belongsTo, belongsToMany, and hasOneThrough all work and it is the relationship's own definition that decides what "belongs to this tenant" means. Three conditions must hold, and they fail in this order:
- The panel has tenancy. If not, the query is returned untouched.
- The resource names a relationship. If not, untouched.
- A tenant is bound. If not,
PandaPanel\Tenancy\Tenancy::require()throws.
The third is a throw rather than a skip on purpose. A resource that declared itself tenant-scoped and then ran unscoped would return every tenant's records — the exact failure the mechanism exists to prevent, and one that looks like a working page.
Console commands and queued jobs that legitimately run outside a request enter a tenant explicitly:
use PandaPanel\Tenancy\Tenancy;
Tenancy::for($workspace, static function (): void {
DocumentResource::query()->each(/* ... */);
});2
3
4
5
Two registration mistakes fail loudly rather than silently: a $tenantRelationship naming a method that does not exist on the model, and one naming a method that exists but does not return a Relation — a scope or an accessor. Both throw PanelRegistrationException naming the resource, the model, and the property.
The table layer on top
ListRecords does not apply search, sorting, filters, or pagination itself. It hands the resource query to PandaPanel\Tables\TableQuery, which reads the URL and applies only what the schema declared: a column that is not searchable() is not searched, a column that is not sortable() is not sorted, an unknown filter name is ignored, and perPage is clamped to the declared options. Nothing from the request ever reaches a column name.
Because the same builder is used for the page and for its summaries, an aggregate reflects exactly what the page is a page of. See Tables and Filters.
Global search
use Illuminate\Database\Eloquent\Builder;
public static function globalSearchQuery(): Builder
{
return static::query();
}2
3
4
5
6
Searching starts from the same query as everything else, so a resource scope narrows the palette exactly as it narrows a list. Override it to search a different starting point — a published-only scope, say — while keeping the resource's own reach for its pages. See Global search.
Notes
- A resource that overrides
query()and forgetsparent::query()loses the panel's narrowing, the tenant scope, the nested parent scope, and$with, all silently. The failure looks like a working page showing too much. - The scope is a 404, not a filtered row. That is what makes it provable: a guessed id is refused by the same rule that hides the link.
- Do not scope in a page. A page-level
wherecovers that page only, and the action endpoint does not go through pages at all — a record hidden from the list would still be deletable. - Do not scope in the table schema. The table layer runs after the resource query and describes presentation; the record pages never see it.
query()is static and runs per call. It is not memoized, so it is safe to call twice, and an override doing expensive work will do it twice.- Outside a panel there is no configuration to apply.
configurationIn(null)isnull, soquery()called from a console command returns the resource's own query plus tenancy.