sairiz / filament-adaptive-query
Conservative UI-state-driven conditional SQL projection and declarative joins for Filament 5 tables.
Requires
- php: ^8.3
- filament/filament: ^5.11.1
- illuminate/database: ^13.0
- illuminate/support: ^13.0
Requires (Dev)
- laravel/pint: ^1.0
- orchestra/testbench: ^11.0
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-04 11:18:16 UTC
README
Load only the declared joins needed by a Filament table's current columns, filters, search, and sort. Optionally select fewer model attributes for listing rows to avoid loading large unused fields.
Filament continues to handle filtering, searching, authorization, relationships, and pagination. Your resource's query and security scopes remain responsible for which records a user can access.
Requirements
- PHP 8.3 or later
- Laravel 13
- Filament 5.11.1 or later within 5.x
- An existing Laravel application with a configured Filament panel
Installation
For a local installation, place this package at packages/filament-adaptive-query inside your Laravel application. Add this entry to the application's composer.json, keeping any existing repositories:
{
"repositories": [
{
"type": "path",
"url": "packages/filament-adaptive-query",
"options": {
"symlink": true
}
}
]
}
Run these commands from the application directory:
composer require sairiz/filament-adaptive-query:dev-main php artisan vendor:publish --tag=filament-adaptive-query-config
Laravel automatically discovers the package's service provider. Publishing the configuration is optional; the package supplies these defaults:
// config/filament-adaptive-query.php return [ 'enabled' => true, 'projection_enabled' => false, 'skip_projection_with_actions' => true, ];
Attach the package to a listing page
Add HasAdaptiveTableQuery to your resource's list page. This example uses a MemberResource backed by a members table; replace the names and namespace with your own.
namespace App\Filament\Resources\Members\Pages; use App\Filament\Resources\Members\MemberResource; use Filament\Resources\Pages\ListRecords; use Sairiz\FilamentAdaptiveQuery\Concerns\HasAdaptiveTableQuery; class ListMembers extends ListRecords { use HasAdaptiveTableQuery; protected static string $resource = MemberResource::class; }
You can instead extend Sairiz\FilamentAdaptiveQuery\AdaptiveListRecords, which already includes the trait. Choose either approach.
The trait alone keeps complete models and adds no joins. Configure conditional joins, listing projection, or both using the examples below. Panel plugin registration is optional.
Select fewer attributes on a listing page
Projection means selecting only the database attributes needed to render the listing. For example, a table showing name can avoid loading a large payload field.
Add these methods to ListMembers:
protected function adaptiveQueryCanProject(): bool { return true; } protected function adaptiveQueryAlwaysSelect(): array { // Hidden attributes used by this page's callbacks or policies. return ['tenant_id', 'status']; }
The package retains visible database fields, the primary key, database route key, configured record title attribute, and standard relationship linking keys. Normal relationship columns such as owner.name do not need a column-map entry.
Declare hidden inputs read by formatters, descriptions, URLs, row classes, accessors, casts, policies, custom views, and actions in adaptiveQueryAlwaysSelect(). The package cannot inspect arbitrary callback dependencies. For a computed display field, declare it with null and retain its inputs:
protected function adaptiveQueryColumns(): array { return ['badge' => null]; } protected function adaptiveQueryAlwaysSelect(): array { return ['status']; // For example, the badge formatter reads status. }
An unknown visible field preserves the full model. Declaring a computed field tells the package that you have accounted for its inputs.
Record, bulk, toolbar, and header actions prevent projection by default. Keep that default until every action and policy dependency has been declared and tested. Record lookups and exports retain full root attributes; listing projection does not reduce the fields loaded by those paths.
Return false from adaptiveQueryCanProject() to disable projection on a page while keeping its conditional joins.
Configure options per panel
Register the plugin in your existing panel provider's panel() method:
use Filament\Panel; use Sairiz\FilamentAdaptiveQuery\AdaptiveQueryPlugin; public function panel(Panel $panel): Panel { return $panel // Keep your existing panel configuration here. ->plugin( AdaptiveQueryPlugin::make() ->projectionEnabled(), ); }
| Option | Default | Effect |
|---|---|---|
enabled / ->enabled(bool) |
true |
Enables adaptive joins and listing projection. |
projection_enabled / ->projectionEnabled(bool) |
false |
Allows listing projection on pages using the trait. |
skip_projection_with_actions / ->skipProjectionWithActions(bool) |
true |
Keeps complete listing models when actions are configured. |
Each plugin method defaults to true when called without an argument. Use ->skipProjectionWithActions(false) only after checking action dependencies.
Unset plugin options use the application configuration. Explicit plugin options apply to the current panel without changing shared configuration or another panel's defaults. Without a plugin or current panel, the package uses configuration values.
A page's adaptiveQueryCanProject() result overrides the panel/config projection option. The master enabled switch and action safety check still apply. To opt in only selected pages, leave projection_enabled off and return true on those pages.
Use AdaptiveQueryPlugin::get() to retrieve a registered plugin from the current panel. AdaptiveQueryPlugin::current() also works without a registered plugin and returns an instance using configuration defaults.
Add a conditional join
This example shows a member's contact phone only when its column is visible, searched, sorted, or needed by a filter.
The database must have:
- A
memberstable withid,name, andcodecolumns. - A
contactstable withmember_codeandphonecolumns. - A unique constraint on
contacts.member_code, so joining a contact cannot duplicate a member row.
1. Declare the alias and join on the list page
Add these methods to the same ListMembers page:
use Illuminate\Database\Eloquent\Builder; protected function adaptiveQueryColumns(): array { return [ 'contact_phone' => 'contact.phone', ]; } protected function adaptiveQueryJoins(): array { return [ 'contact' => [ 'cardinality' => 'one', 'columns' => ['contact_phone'], 'filters' => ['has_phone'], 'sorts' => ['contact_phone'], 'searches' => ['contact_phone'], 'apply' => fn (Builder $query) => $query->leftJoin( 'contacts as contact', 'contact.member_code', '=', 'members.code', ), ], ]; }
Merge entries into these methods if you already declared computed fields or other joins. The column map associates a display attribute (contact_phone) with its SQL source (contact.phone); the package adds the SELECT alias automatically.
2. Configure the resource table
Add these columns and filter to MemberResource::table(). If your resource delegates table configuration to a separate class, put the same configuration there.
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Filters\Filter; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; public static function table(Table $table): Table { return $table ->columns([ TextColumn::make('name'), TextColumn::make('contact_phone') ->label('Phone') ->toggleable(isToggledHiddenByDefault: true) ->searchable(query: fn (Builder $query, string $search) => $query ->where('contact.phone', 'like', "%{$search}%")) ->sortable(query: fn (Builder $query, string $direction) => $query ->orderBy('contact.phone', $direction)), ]) ->filters([ Filter::make('has_phone') ->label('Has phone') ->query(fn (Builder $query) => $query->whereNotNull('contact.phone')), ]); }
The contact join is omitted when the phone column is hidden and no active search, sort, or filter needs it. Showing the column, searching it, sorting it, or applying Has phone activates the join. Conditional joins work with projection disabled.
Use the qualified SQL source in search, sort, and filter callbacks. A SELECT alias such as contact_phone is not a portable WHERE expression. The query builder binds the search value.
Join rules and dependencies
| Rule | Meaning |
|---|---|
cardinality |
Required value 'one': at most one joined row per root record, supported by database uniqueness. |
columns |
Names of visible table components that need the join. |
filters |
Applied filter names or nested paths, such as address.city_id. |
sorts |
Names used by active sorts or a configured default sort. |
searches |
Names of columns used by global or individual searches. |
depends |
Names of other declared joins to apply first. |
always |
Set to true when the join is needed regardless of table state. |
apply |
Callback that appends exactly one LEFT JOIN with the rule's name as its SQL alias. |
Role entries refer to component names, not SQL source names. Each role is independent: declaring a column dependency does not declare a filter or search dependency.
Use '*' in a role to match any active requirement of that role. Callback default sorts use sorts => ['*']; custom table search callbacks can use searches => ['*']. Use always => true for dependencies required by tabs, groups, scopes, or callbacks that run with blank filter state.
Only applied filters activate joins. Deferred form edits wait until applied. False, zero, and strings such as "null" are meaningful values; an unchecked standard isActive filter flag is inactive.
For a join through another join, declare depends. For example, add 'city_name' => 'city.name' to the column map and this rule to the join map:
'city' => [ 'cardinality' => 'one', 'columns' => ['city_name'], 'depends' => ['contact'], 'apply' => fn (Builder $query) => $query->leftJoin( 'cities as city', 'city.id', '=', 'contact.city_id', ), ],
This requires contacts.city_id and a unique cities.id. Add TextColumn::make('city_name') to the table. When that column is visible, the package adds contact before city, once each.
For many-side relationships, use whereHas, EXISTS queries, or native aggregates. The package validates the cardinality declaration but cannot prove your database relationship is unique.
Query behavior and limits
Joins attach before native filters and searches, so they are available to listings, counts, summaries, bulk queries, record lookups, and exports. Record lookups and exports load all declared joins and aliases with full root attributes. Bulk queries retain full root attributes; put hidden aliases needed by bulk callbacks in both the column map and adaptiveQueryAlwaysSelect() so their joins activate too.
Projection runs after native listing filters and sorting and changes only SELECT. Custom SELECTs, aggregates, existing joins, aliased or subquery FROMs, DISTINCT, UNION, GROUP/HAVING, relationship tables, grouping, column layouts, summaries, reordering, and cursor pagination preserve the original projection. Unsupported query shapes that require adaptive joins or aliases raise a configuration error.
Column-map SQL values must be qualified identifiers, optionally with a matching as alias. Raw expressions and aliases that shadow real root attributes are rejected. Join callbacks may only append their declared LEFT JOIN and its bindings; use application queries for more complex changes.
Joined tables do not automatically receive related Eloquent tenant, global, or soft-delete scopes. Include required restrictions in the join's ON clause. Avoid declaring a join already added by your resource or global scopes, including scopes applied when the query executes.
Schema metadata is cached within the request. If you change the schema or SQL search path within the same request or console job, clear it:
use Sairiz\FilamentAdaptiveQuery\AdaptiveTableQuery; app(AdaptiveTableQuery::class)->flushSchemaCache();
If schema introspection fails, projection preserves the full model. Requested aliases raise an error when their names cannot be checked against root attributes.
Standalone Livewire tables
The trait calls Filament methods on its parent class. Put InteractsWithTable on a base component and HasAdaptiveTableQuery on a subclass, keeping your usual Filament table contracts and setup on the base. Adding both traits directly to the same class requires explicit conflict resolution.
If you override makeTable() or the table query methods, preserve their parent calls so the package's query hooks still run.
Troubleshooting
| Symptom | What to check |
|---|---|
| The listing still loads all attributes | Enable projection on the page or panel. Check actions, unknown/computed fields, and unsupported query shapes. |
| A formatter or policy reads a missing attribute | Add its hidden inputs to adaptiveQueryAlwaysSelect(), or disable projection on that page. |
| A filter or search reports a missing joined column | Declare its role on the join rule and use a qualified SQL source in the callback. |
| Records appear twice or pagination counts change | Confirm database uniqueness for every join. A 'one' declaration alone does not enforce it. |
| A join is always present | Check visible columns, default sorts, always, dependencies, and hidden aliases in adaptiveQueryAlwaysSelect(). Lookups and exports intentionally load every declared join. |
The master enabled switch disables all package behavior. Before disabling it on a page whose callbacks reference package-owned joins or aliases, restore those dependencies in the application's query or remove the references.
Verify in your application
Exercise column toggles, applied filters, search, default and user sorts, actions, bulk selection, exports, and relationship values. Compare record IDs, counts, and displayed values with your original query. Enable strict missing-attribute checks during testing to catch undeclared callback inputs.
Measure with your application's database and realistic data before enabling projection broadly. Tables with wide visible columns may gain little from selecting fewer attributes.
For package development:
composer install
composer test
composer lint
composer analyse
See validation details for the package's test coverage and benchmark procedure. Filament's table documentation covers native table configuration.