Filament has inconsistent scope enforcement for its AttachAction and AssociateAction Select fields
Description
Filament's AttachAction and AssociateAction Select fields fail to enforce scope in built-in validation, allowing users to tamper with Livewire state and submit out-of-scope values.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Filament's AttachAction and AssociateAction Select fields fail to enforce scope in built-in validation, allowing users to tamper with Livewire state and submit out-of-scope values.
Vulnerability
The recordSelectOptionsQuery() method in Filament is used to scope the options available in the Select field for AttachAction and AssociateAction. However, the built-in validation rule for these fields did not apply the same scope. As a result, a user who can trigger these actions could tamper with the Livewire component's state and submit an out-of-scope value. This affects Filament versions 4.0.0 through 4.11.3 and 5.0.0 through 5.6.3 [2][4].
Exploitation
An attacker who is able to trigger AttachAction or AssociateAction (for example, by having write or attach permissions on a resource) can manipulate the Livewire component's state to bypass the intended scope defined by recordSelectOptionsQuery(). The attack does not require elevated privileges beyond normal user access to those actions; it requires the ability to submit a modified request to the Livewire endpoint [1][4].
Impact
Successful exploitation allows the attacker to attach or associate records that fall outside the permitted scope. This can lead to unauthorized data linking or disclosure of records the user was not meant to access. The integrity and confidentiality of the data model are partially compromised, depending on the business logic, and the attacker may perform actions that violate intended security boundaries [1][4].
Mitigation
The vulnerability is fixed in Filament versions 4.11.4 and 5.6.4 [1][2][3][4]. Users should upgrade to these patched versions as soon as possible. There is no known workaround for unpatched installations. The 3.x branch is not affected because the AttachAction and AssociateAction features were not present in the same form. Upgrading is the only reliable mitigation [4].
AI Insight generated on Jun 11, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
3- Range: >= 5.0.0, <= 5.6.3
- ghsa-coords2 versions
>= 4.0.0, < 4.11.4+ 1 more
- (no CPE)range: >= 4.0.0, < 4.11.4
- (no CPE)range: >= 3.0.0, < 3.3.51
Patches
1a79154e0aa0asecurity: Inconsistent scope enforcement for attach and associate action select fields (#19880)
27 files changed · +1576 −11
packages/actions/src/AssociateAction.php+33 −4 modified@@ -74,7 +74,16 @@ protected function setUp(): void /** @var HasMany | MorphMany $relationship */ $relationship = Relation::noConstraints(fn () => $table->getRelationship()); - $record = $relationship->getQuery()->find($data['recordId']); + $relationshipQuery = $relationship->getQuery(); + + if ($this->modifyRecordSelectOptionsQueryUsing) { + $relationshipQuery = $this->evaluate($this->modifyRecordSelectOptionsQueryUsing, [ + 'query' => $relationshipQuery, + 'search' => null, + ]) ?? $relationshipQuery; + } + + $record = $relationshipQuery->find($data['recordId']); foreach (($this->isMultiple ? $record : [$record]) as $record) { if ($record instanceof Model) { @@ -281,15 +290,35 @@ public function getRecordSelect(): Select ->multiple($this->isMultiple()) ->searchable($this->getRecordSelectSearchColumns() ?? true) ->getSearchResultsUsing(static fn (Select $component, string $search): array => $getOptions(optionsLimit: $component->getOptionsLimit(), search: $search, searchColumns: $component->getSearchColumns())) - ->getOptionLabelUsing(function ($value) use ($table): string { + ->getOptionLabelUsing(function ($value) use ($table): ?string { $relationship = Relation::noConstraints(fn () => $table->getRelationship()); - return $this->getRecordTitle($relationship->getQuery()->find($value)); + $relationshipQuery = $relationship->getQuery(); + + if ($this->modifyRecordSelectOptionsQueryUsing) { + $relationshipQuery = $this->evaluate($this->modifyRecordSelectOptionsQueryUsing, [ + 'query' => $relationshipQuery, + 'search' => null, + ]) ?? $relationshipQuery; + } + + $record = $relationshipQuery->find($value); + + return $record ? $this->getRecordTitle($record) : null; }) ->getOptionLabelsUsing(function (array $values) use ($table): array { $relationship = Relation::noConstraints(fn () => $table->getRelationship()); - return $relationship->getQuery()->findMany($values) + $relationshipQuery = $relationship->getQuery(); + + if ($this->modifyRecordSelectOptionsQueryUsing) { + $relationshipQuery = $this->evaluate($this->modifyRecordSelectOptionsQueryUsing, [ + 'query' => $relationshipQuery, + 'search' => null, + ]) ?? $relationshipQuery; + } + + return $relationshipQuery->findMany($values) ->mapWithKeys(fn (Model $record): array => [$record->getKey() => $this->getRecordTitle($record)]) ->all(); })
packages/actions/src/AttachAction.php+25 −2 modified@@ -92,6 +92,13 @@ protected function setUp(): void $relationshipQuery = app(RelationshipJoiner::class)->prepareQueryForNoConstraints($relationship); + if ($this->modifyRecordSelectOptionsQueryUsing) { + $relationshipQuery = $this->evaluate($this->modifyRecordSelectOptionsQueryUsing, [ + 'query' => $relationshipQuery, + 'search' => null, + ]) ?? $relationshipQuery; + } + $isMultiple = is_array($data['recordId']); $record = $relationshipQuery @@ -307,18 +314,34 @@ public function getRecordSelect(): Field ->multiple($this->isMultiple()) ->searchable($this->getRecordSelectSearchColumns() ?? true) ->getSearchResultsUsing(static fn (Select $component, string $search): array => $getOptions(optionsLimit: $component->getOptionsLimit(), search: $search, searchColumns: $component->getSearchColumns())) - ->getOptionLabelUsing(function ($value) use ($table): string { + ->getOptionLabelUsing(function ($value) use ($table): ?string { $relationship = Relation::noConstraints(fn () => $table->getRelationship()); $relationshipQuery = app(RelationshipJoiner::class)->prepareQueryForNoConstraints($relationship); - return $this->getRecordTitle($relationshipQuery->find($value)); + if ($this->modifyRecordSelectOptionsQueryUsing) { + $relationshipQuery = $this->evaluate($this->modifyRecordSelectOptionsQueryUsing, [ + 'query' => $relationshipQuery, + 'search' => null, + ]) ?? $relationshipQuery; + } + + $record = $relationshipQuery->find($value); + + return $record ? $this->getRecordTitle($record) : null; }) ->getOptionLabelsUsing(function (array $values) use ($table): array { $relationship = Relation::noConstraints(fn () => $table->getRelationship()); $relationshipQuery = app(RelationshipJoiner::class)->prepareQueryForNoConstraints($relationship); + if ($this->modifyRecordSelectOptionsQueryUsing) { + $relationshipQuery = $this->evaluate($this->modifyRecordSelectOptionsQueryUsing, [ + 'query' => $relationshipQuery, + 'search' => null, + ]) ?? $relationshipQuery; + } + return $relationshipQuery->find($values) ->mapWithKeys(fn (Model $record): array => [$record->getKey() => $this->getRecordTitle($record)]) ->all();
packages/forms/src/Components/TableSelect.php+55 −0 modified@@ -6,6 +6,7 @@ use Filament\Schemas\Components\StateCasts\Contracts\StateCast; use Filament\Schemas\Components\StateCasts\OptionsArrayStateCast; use Filament\Schemas\Components\StateCasts\OptionStateCast; +use Filament\Support\Services\RelationshipJoiner; use Illuminate\Contracts\Support\Htmlable; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; @@ -15,6 +16,7 @@ use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\HasOneOrMany; use Illuminate\Database\Eloquent\Relations\HasOneOrManyThrough; +use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Support\Arr; use LogicException; use Znck\Eloquent\Relations\BelongsToThrough; @@ -406,6 +408,59 @@ public function isMultiple(): bool return (bool) $this->evaluate($this->isMultiple); } + /** + * @return ?array<string> + */ + public function getInValidationRuleValues(): ?array + { + $values = parent::getInValidationRuleValues(); + + if ($values !== null) { + return $values; + } + + if (! $this->hasRelationship()) { + return null; + } + + $state = $this->getState(); + + if (blank($state)) { + return null; + } + + $relationship = Relation::noConstraints(fn () => $this->getRelationship()); + $relationshipQuery = app(RelationshipJoiner::class)->prepareQueryForNoConstraints($relationship); + + $relatedKeyName = $relationship->getRelated()->getKeyName(); + $qualifiedRelatedKeyName = $relationshipQuery->qualifyColumn($relatedKeyName); + + if ($this->isMultiple()) { + $stateArray = Arr::wrap($state); + + if (empty($stateArray)) { + return null; + } + + return $relationshipQuery + ->whereIn($qualifiedRelatedKeyName, $stateArray) + ->pluck($relatedKeyName) + ->map(static fn ($id): string => (string) $id) + ->all(); + } + + $exists = $relationshipQuery + ->where($qualifiedRelatedKeyName, $state) + ->exists(); + + return $exists ? null : []; + } + + public function hasInValidationOnMultipleValues(): bool + { + return $this->isMultiple(); + } + /** * @return array<StateCast> */
packages/query-builder/src/Constraints/RelationshipConstraint/Operators/EqualsOperator.php+17 −1 modified@@ -54,6 +54,22 @@ public function getFormSchema(): array public function applyToBaseQuery(Builder $query): Builder { - return $query->has($this->getConstraint()->getRelationshipName(), $this->isInverse() ? '!=' : '=', intval($this->getSettings()['count'])); + $modifyRelationshipQueryUsing = $this->getConstraint()->getModifyRelationshipQueryUsing(); + + return $query->has( + $this->getConstraint()->getRelationshipName(), + $this->isInverse() ? '!=' : '=', + intval($this->getSettings()['count']), + 'and', + function (Builder $query) use ($modifyRelationshipQueryUsing): Builder { + if ($modifyRelationshipQueryUsing) { + $query = $this->evaluate($modifyRelationshipQueryUsing, [ + 'query' => $query, + ]) ?? $query; + } + + return $query; + }, + ); } }
packages/query-builder/src/Constraints/RelationshipConstraint/Operators/HasMaxOperator.php+17 −1 modified@@ -54,6 +54,22 @@ public function getFormSchema(): array public function applyToBaseQuery(Builder $query): Builder { - return $query->has($this->getConstraint()->getRelationshipName(), $this->isInverse() ? '>' : '<=', intval($this->getSettings()['count'])); + $modifyRelationshipQueryUsing = $this->getConstraint()->getModifyRelationshipQueryUsing(); + + return $query->has( + $this->getConstraint()->getRelationshipName(), + $this->isInverse() ? '>' : '<=', + intval($this->getSettings()['count']), + 'and', + function (Builder $query) use ($modifyRelationshipQueryUsing): Builder { + if ($modifyRelationshipQueryUsing) { + $query = $this->evaluate($modifyRelationshipQueryUsing, [ + 'query' => $query, + ]) ?? $query; + } + + return $query; + }, + ); } }
packages/query-builder/src/Constraints/RelationshipConstraint/Operators/HasMinOperator.php+17 −1 modified@@ -54,6 +54,22 @@ public function getFormSchema(): array public function applyToBaseQuery(Builder $query): Builder { - return $query->has($this->getConstraint()->getRelationshipName(), $this->isInverse() ? '<' : '>=', intval($this->getSettings()['count'])); + $modifyRelationshipQueryUsing = $this->getConstraint()->getModifyRelationshipQueryUsing(); + + return $query->has( + $this->getConstraint()->getRelationshipName(), + $this->isInverse() ? '<' : '>=', + intval($this->getSettings()['count']), + 'and', + function (Builder $query) use ($modifyRelationshipQueryUsing): Builder { + if ($modifyRelationshipQueryUsing) { + $query = $this->evaluate($modifyRelationshipQueryUsing, [ + 'query' => $query, + ]) ?? $query; + } + + return $query; + }, + ); } }
packages/query-builder/src/Constraints/RelationshipConstraint/Operators/IsEmptyOperator.php+16 −1 modified@@ -33,6 +33,21 @@ public function getSummary(): string public function applyToBaseQuery(Builder $query): Builder { - return $query->{$this->isInverse() ? 'has' : 'doesntHave'}($this->getConstraint()->getRelationshipName()); + $relationshipName = $this->getConstraint()->getRelationshipName(); + $modifyRelationshipQueryUsing = $this->getConstraint()->getModifyRelationshipQueryUsing(); + + $scopeCallback = function (Builder $query) use ($modifyRelationshipQueryUsing): Builder { + if ($modifyRelationshipQueryUsing) { + $query = $this->evaluate($modifyRelationshipQueryUsing, [ + 'query' => $query, + ]) ?? $query; + } + + return $query; + }; + + return $this->isInverse() + ? $query->has($relationshipName, '>=', 1, 'and', $scopeCallback) + : $query->doesntHave($relationshipName, 'and', $scopeCallback); } }
packages/query-builder/src/Constraints/RelationshipConstraint/Operators/IsRelatedToOperator.php+9 −1 modified@@ -188,7 +188,15 @@ public function apply(Builder $query, string $qualifiedColumn): Builder return $query->{$this->isInverse() ? 'whereDoesntHave' : 'whereHas'}( $constraint->getRelationshipName(), - fn (Builder $query) => $query->whereKey($value), + function (Builder $query) use ($value): Builder { + if ($this->modifyRelationshipQueryUsing) { + $query = $this->evaluate($this->modifyRelationshipQueryUsing, [ + 'query' => $query, + ]) ?? $query; + } + + return $query->whereKey($value); + }, ); }
tests/src/Actions/AssociateActionTest.php+81 −0 modified@@ -8,6 +8,7 @@ use Filament\Tests\Fixtures\Resources\Users\Pages\EditUser; use Filament\Tests\Fixtures\Resources\Users\RelationManagers\PostsWithAssociateActionRelationManager; use Filament\Tests\Fixtures\Resources\Users\RelationManagers\PostsWithModifiedAssociateQueryRelationManager; +use Filament\Tests\Fixtures\Resources\Users\RelationManagers\PostsWithMultipleModifiedAssociateQueryRelationManager; use Filament\Tests\Fixtures\Resources\Users\RelationManagers\PostsWithPreloadedAssociateRelationManager; use Filament\Tests\Panels\Resources\TestCase; @@ -191,6 +192,53 @@ }); }); + it('rejects a `recordId` excluded by `recordSelectOptionsQuery()` when submitted directly', function (): void { + $user = User::factory()->create(); + Post::factory()->create(['title' => 'Published Article', 'author_id' => null]); + $outOfScopePost = Post::factory()->create(['title' => 'Draft Post', 'author_id' => null]); + + livewire(PostsWithModifiedAssociateQueryRelationManager::class, ['ownerRecord' => $user, 'pageClass' => EditUser::class]) + ->callAction(TestAction::make(AssociateAction::class)->table(), [ + 'recordId' => $outOfScopePost->getKey(), + ]) + ->assertHasActionErrors(['recordId']); + + expect($outOfScopePost->refresh()->author_id)->toBeNull(); + }); + + it('rejects a multi-associate batch containing an out-of-scope `recordId`', function (): void { + $user = User::factory()->create(); + $inScopePost = Post::factory()->create(['title' => 'Published Article', 'author_id' => null]); + $outOfScopePost = Post::factory()->create(['title' => 'Draft Post', 'author_id' => null]); + + livewire(PostsWithMultipleModifiedAssociateQueryRelationManager::class, ['ownerRecord' => $user, 'pageClass' => EditUser::class]) + ->callAction(TestAction::make(AssociateAction::class)->table(), [ + 'recordId' => [$inScopePost->getKey(), $outOfScopePost->getKey()], + ]) + ->assertHasActionErrors(); + + expect($outOfScopePost->refresh()->author_id)->toBeNull(); + expect($inScopePost->refresh()->author_id)->toBeNull(); + }); + + it('applies `recordSelectOptionsQuery()` to search results', function (): void { + $user = User::factory()->create(); + Post::factory()->create(['title' => 'Published Article', 'author_id' => null]); + Post::factory()->create(['title' => 'Draft Article', 'author_id' => null]); + + livewire(PostsWithModifiedAssociateQueryRelationManager::class, ['ownerRecord' => $user, 'pageClass' => EditUser::class]) + ->mountAction(TestAction::make(AssociateAction::class)->table()) + ->assertSchemaComponentExists('recordId', checkComponentUsing: function (Select $select): bool { + $results = $select->getSearchResults('Article'); + + expect($results)->toHaveCount(1); + expect(array_values($results))->toContain('Published Article'); + expect(array_values($results))->not->toContain('Draft Article'); + + return true; + }); + }); + it('respects `optionsLimit()` on record select', function (): void { $user = User::factory()->create(); Post::factory()->count(10)->create(['author_id' => null]); @@ -238,6 +286,39 @@ return true; }); }); + + it('returns `null` from `getOptionLabel()` when `recordSelectOptionsQuery()` excludes the record', function (): void { + $user = User::factory()->create(); + $outOfScopePost = Post::factory()->create(['title' => 'Draft Post', 'author_id' => null]); + + livewire(PostsWithModifiedAssociateQueryRelationManager::class, ['ownerRecord' => $user, 'pageClass' => EditUser::class]) + ->mountAction(TestAction::make(AssociateAction::class)->table()) + ->fillForm(['recordId' => $outOfScopePost->getKey()]) + ->assertSchemaComponentExists('recordId', checkComponentUsing: function (Select $select): bool { + expect($select->getOptionLabel(withDefault: false))->toBeNull(); + + return true; + }); + }); + + it('omits out-of-scope records from `getOptionLabels()` when `recordSelectOptionsQuery()` excludes them', function (): void { + $user = User::factory()->create(); + $inScopePost = Post::factory()->create(['title' => 'Published Article', 'author_id' => null]); + $outOfScopePost = Post::factory()->create(['title' => 'Draft Post', 'author_id' => null]); + + livewire(PostsWithMultipleModifiedAssociateQueryRelationManager::class, ['ownerRecord' => $user, 'pageClass' => EditUser::class]) + ->mountAction(TestAction::make(AssociateAction::class)->table()) + ->fillForm(['recordId' => [$inScopePost->getKey(), $outOfScopePost->getKey()]]) + ->assertSchemaComponentExists('recordId', checkComponentUsing: function (Select $select) use ($inScopePost, $outOfScopePost): bool { + $labels = $select->getOptionLabels(withDefaults: false); + + expect($labels)->toHaveCount(1); + expect($labels)->toHaveKey($inScopePost->getKey()); + expect($labels)->not->toHaveKey($outOfScopePost->getKey()); + + return true; + }); + }); }); it('can set `associateAnother()`', function (): void {
tests/src/Actions/AttachActionTest.php+92 −0 modified@@ -8,12 +8,14 @@ use Filament\Tests\Fixtures\Resources\Tickets\Pages\EditTicket; use Filament\Tests\Fixtures\Resources\Tickets\RelationManagers\DepartmentsWithAttachActionRelationManager; use Filament\Tests\Fixtures\Resources\Tickets\RelationManagers\DepartmentsWithModifiedAttachQueryRelationManager; +use Filament\Tests\Fixtures\Resources\Tickets\RelationManagers\DepartmentsWithMultipleModifiedAttachQueryRelationManager; use Filament\Tests\Fixtures\Resources\Tickets\RelationManagers\DepartmentsWithPreloadedAttachRelationManager; use Filament\Tests\Fixtures\Resources\Tickets\RelationManagers\DepartmentsWithRecordSelectSearchColumnsRelationManager; use Filament\Tests\Panels\Resources\TestCase; use function Filament\Tests\livewire; use function Pest\Laravel\assertDatabaseHas; +use function Pest\Laravel\assertDatabaseMissing; uses(TestCase::class); @@ -203,6 +205,63 @@ }); }); + it('rejects a `recordId` excluded by `recordSelectOptionsQuery()` when submitted directly', function (): void { + $ticket = Ticket::factory()->create(); + Department::factory()->create(['name' => 'Active Engineering']); + $outOfScopeDepartment = Department::factory()->create(['name' => 'Inactive Department']); + + livewire(DepartmentsWithModifiedAttachQueryRelationManager::class, ['ownerRecord' => $ticket, 'pageClass' => EditTicket::class]) + ->callAction(TestAction::make(AttachAction::class)->table(), [ + 'recordId' => $outOfScopeDepartment->getKey(), + ]) + ->assertHasActionErrors(['recordId']); + + assertDatabaseMissing('department_ticket', [ + 'department_id' => $outOfScopeDepartment->getKey(), + 'ticket_id' => $ticket->getKey(), + ]); + }); + + it('rejects a multi-attach batch containing an out-of-scope `recordId`', function (): void { + $ticket = Ticket::factory()->create(); + $inScopeDepartment = Department::factory()->create(['name' => 'Active Engineering']); + $outOfScopeDepartment = Department::factory()->create(['name' => 'Inactive Department']); + + livewire(DepartmentsWithMultipleModifiedAttachQueryRelationManager::class, ['ownerRecord' => $ticket, 'pageClass' => EditTicket::class]) + ->callAction(TestAction::make(AttachAction::class)->table(), [ + 'recordId' => [$inScopeDepartment->getKey(), $outOfScopeDepartment->getKey()], + ]) + ->assertHasActionErrors(); + + assertDatabaseMissing('department_ticket', [ + 'department_id' => $outOfScopeDepartment->getKey(), + 'ticket_id' => $ticket->getKey(), + ]); + + assertDatabaseMissing('department_ticket', [ + 'department_id' => $inScopeDepartment->getKey(), + 'ticket_id' => $ticket->getKey(), + ]); + }); + + it('applies `recordSelectOptionsQuery()` to search results', function (): void { + $ticket = Ticket::factory()->create(); + Department::factory()->create(['name' => 'Active Engineering']); + Department::factory()->create(['name' => 'Inactive Engineering']); + + livewire(DepartmentsWithModifiedAttachQueryRelationManager::class, ['ownerRecord' => $ticket, 'pageClass' => EditTicket::class]) + ->mountAction(TestAction::make(AttachAction::class)->table()) + ->assertSchemaComponentExists('recordId', checkComponentUsing: function (Select $select): bool { + $results = $select->getSearchResults('Engineering'); + + expect($results)->toHaveCount(1); + expect(array_values($results))->toContain('Active Engineering'); + expect(array_values($results))->not->toContain('Inactive Engineering'); + + return true; + }); + }); + it('uses `recordSelectSearchColumns()` when configured', function (): void { $ticket = Ticket::factory()->create(); Department::factory()->create(['name' => 'Engineering Dept']); @@ -273,6 +332,39 @@ return true; }); }); + + it('returns `null` from `getOptionLabel()` when `recordSelectOptionsQuery()` excludes the record', function (): void { + $ticket = Ticket::factory()->create(); + $outOfScopeDepartment = Department::factory()->create(['name' => 'Inactive Department']); + + livewire(DepartmentsWithModifiedAttachQueryRelationManager::class, ['ownerRecord' => $ticket, 'pageClass' => EditTicket::class]) + ->mountAction(TestAction::make(AttachAction::class)->table()) + ->fillForm(['recordId' => $outOfScopeDepartment->getKey()]) + ->assertSchemaComponentExists('recordId', checkComponentUsing: function (Select $select): bool { + expect($select->getOptionLabel(withDefault: false))->toBeNull(); + + return true; + }); + }); + + it('omits out-of-scope records from `getOptionLabels()` when `recordSelectOptionsQuery()` excludes them', function (): void { + $ticket = Ticket::factory()->create(); + $inScopeDepartment = Department::factory()->create(['name' => 'Active Engineering']); + $outOfScopeDepartment = Department::factory()->create(['name' => 'Inactive Department']); + + livewire(DepartmentsWithMultipleModifiedAttachQueryRelationManager::class, ['ownerRecord' => $ticket, 'pageClass' => EditTicket::class]) + ->mountAction(TestAction::make(AttachAction::class)->table()) + ->fillForm(['recordId' => [$inScopeDepartment->getKey(), $outOfScopeDepartment->getKey()]]) + ->assertSchemaComponentExists('recordId', checkComponentUsing: function (Select $select) use ($inScopeDepartment, $outOfScopeDepartment): bool { + $labels = $select->getOptionLabels(withDefaults: false); + + expect($labels)->toHaveCount(1); + expect($labels)->toHaveKey($inScopeDepartment->getKey()); + expect($labels)->not->toHaveKey($outOfScopeDepartment->getKey()); + + return true; + }); + }); }); it('can set `attachAnother()`', function (): void {
tests/src/Fixtures/Livewire/PostsQueryBuilderTableWithScopedAuthor.php+50 −0 added@@ -0,0 +1,50 @@ +<?php + +namespace Filament\Tests\Fixtures\Livewire; + +use Filament\Actions\Concerns\InteractsWithActions; +use Filament\Actions\Contracts\HasActions; +use Filament\QueryBuilder\Constraints\RelationshipConstraint; +use Filament\QueryBuilder\Constraints\RelationshipConstraint\Operators\IsRelatedToOperator; +use Filament\Schemas\Concerns\InteractsWithSchemas; +use Filament\Schemas\Contracts\HasSchemas; +use Filament\Tables; +use Filament\Tables\Filters\QueryBuilder; +use Filament\Tables\Table; +use Filament\Tests\Fixtures\Models\Post; +use Illuminate\Contracts\View\View; +use Livewire\Component; + +class PostsQueryBuilderTableWithScopedAuthor extends Component implements HasActions, HasSchemas, Tables\Contracts\HasTable +{ + use InteractsWithActions; + use InteractsWithSchemas; + use Tables\Concerns\InteractsWithTable; + + public function table(Table $table): Table + { + return $table + ->query(Post::query()) + ->columns([ + Tables\Columns\TextColumn::make('title'), + Tables\Columns\TextColumn::make('author.name'), + ]) + ->filters([ + QueryBuilder::make('query_builder') + ->constraints([ + RelationshipConstraint::make('author') + ->selectable( + IsRelatedToOperator::make() + ->titleAttribute('name') + ->modifyRelationshipQueryUsing(fn ($query) => $query->where('name', 'like', 'Alpha%')), + ), + ]), + ]) + ->paginated(false); + } + + public function render(): View + { + return view('livewire.table'); + } +}
tests/src/Fixtures/Livewire/UsersQueryBuilderTableWithScopedPostsCount.php+46 −0 added@@ -0,0 +1,46 @@ +<?php + +namespace Filament\Tests\Fixtures\Livewire; + +use Filament\Actions\Concerns\InteractsWithActions; +use Filament\Actions\Contracts\HasActions; +use Filament\QueryBuilder\Constraints\RelationshipConstraint; +use Filament\Schemas\Concerns\InteractsWithSchemas; +use Filament\Schemas\Contracts\HasSchemas; +use Filament\Tables; +use Filament\Tables\Filters\QueryBuilder; +use Filament\Tables\Table; +use Filament\Tests\Fixtures\Models\User; +use Illuminate\Contracts\View\View; +use Livewire\Component; + +class UsersQueryBuilderTableWithScopedPostsCount extends Component implements HasActions, HasSchemas, Tables\Contracts\HasTable +{ + use InteractsWithActions; + use InteractsWithSchemas; + use Tables\Concerns\InteractsWithTable; + + public function table(Table $table): Table + { + return $table + ->query(User::query()) + ->columns([ + Tables\Columns\TextColumn::make('name'), + ]) + ->filters([ + QueryBuilder::make('query_builder') + ->constraints([ + RelationshipConstraint::make('posts') + ->multiple() + ->emptyable() + ->modifyRelationshipQueryUsing(fn ($query) => $query->where('is_published', true)), + ]), + ]) + ->paginated(false); + } + + public function render(): View + { + return view('livewire.table'); + } +}
tests/src/Fixtures/Resources/Tickets/RelationManagers/DepartmentsWithAttachTableSelectAndModifiedQueryRelationManager.php+24 −0 added@@ -0,0 +1,24 @@ +<?php + +namespace Filament\Tests\Fixtures\Resources\Tickets\RelationManagers; + +use Filament\Actions\AttachAction; +use Filament\Resources\RelationManagers\RelationManager; +use Filament\Tables\Table; +use Filament\Tests\Fixtures\Resources\Departments\Tables\DepartmentsTable; +use Illuminate\Database\Eloquent\Builder; + +class DepartmentsWithAttachTableSelectAndModifiedQueryRelationManager extends RelationManager +{ + protected static string $relationship = 'departments'; + + public function table(Table $table): Table + { + return DepartmentsTable::configure($table) + ->headerActions([ + AttachAction::make() + ->tableSelect(DepartmentsTable::class) + ->recordSelectOptionsQuery(fn (Builder $query) => $query->where('name', 'like', 'Active%')), + ]); + } +}
tests/src/Fixtures/Resources/Tickets/RelationManagers/DepartmentsWithMultipleModifiedAttachQueryRelationManager.php+26 −0 added@@ -0,0 +1,26 @@ +<?php + +namespace Filament\Tests\Fixtures\Resources\Tickets\RelationManagers; + +use Filament\Actions\AttachAction; +use Filament\Resources\RelationManagers\RelationManager; +use Filament\Tables\Table; +use Filament\Tests\Fixtures\Resources\Departments\Tables\DepartmentsTable; +use Illuminate\Database\Eloquent\Builder; + +class DepartmentsWithMultipleModifiedAttachQueryRelationManager extends RelationManager +{ + protected static string $relationship = 'departments'; + + public function table(Table $table): Table + { + return DepartmentsTable::configure($table) + ->recordTitleAttribute('name') + ->headerActions([ + AttachAction::make() + ->preloadRecordSelect() + ->multiple() + ->recordSelectOptionsQuery(fn (Builder $query) => $query->where('name', 'like', 'Active%')), + ]); + } +}
tests/src/Fixtures/Resources/Users/RelationManagers/PostsWithMultipleModifiedAssociateQueryRelationManager.php+30 −0 added@@ -0,0 +1,30 @@ +<?php + +namespace Filament\Tests\Fixtures\Resources\Users\RelationManagers; + +use Filament\Actions\AssociateAction; +use Filament\Resources\RelationManagers\RelationManager; +use Filament\Tables\Columns\TextColumn; +use Filament\Tables\Table; +use Illuminate\Database\Eloquent\Builder; + +class PostsWithMultipleModifiedAssociateQueryRelationManager extends RelationManager +{ + protected static string $relationship = 'posts'; + + public function table(Table $table): Table + { + return $table + ->recordTitleAttribute('title') + ->inverseRelationship('author') + ->columns([ + TextColumn::make('title'), + ]) + ->headerActions([ + AssociateAction::make() + ->preloadRecordSelect() + ->multiple() + ->recordSelectOptionsQuery(fn (Builder $query) => $query->where('title', 'like', 'Published%')), + ]); + } +}
tests/src/Forms/Components/CheckboxListTest.php+52 −0 modified@@ -395,6 +395,22 @@ ->call('save') ->assertHasNoFormErrors(); }); + + it('rejects existing values excluded by `modifyQueryUsing` on a `BelongsToMany` relationship', function (): void { + $user = User::factory()->create(); + $inScope = Team::factory()->create(['name' => 'Alpha Team']); + $outOfScope = Team::factory()->create(['name' => 'Beta Team']); + + livewire(CheckboxListWithBelongsToManyRelationshipAndModifyQueryValidation::class, ['record' => $user]) + ->fillForm(['teams' => [(string) $inScope->id]]) + ->call('save') + ->assertHasNoFormErrors(); + + livewire(CheckboxListWithBelongsToManyRelationshipAndModifyQueryValidation::class, ['record' => $user]) + ->fillForm(['teams' => [(string) $inScope->id, (string) $outOfScope->id]]) + ->call('save') + ->assertHasFormErrors(['teams.1' => ['in']]); + }); }); describe('action names', function (): void { @@ -746,6 +762,42 @@ public function render(): View } } +class CheckboxListWithBelongsToManyRelationshipAndModifyQueryValidation extends Component implements HasActions, HasSchemas +{ + use InteractsWithActions; + use InteractsWithSchemas; + + public $data = []; + + public User $record; + + public function mount(): void + { + $this->form->fill([]); + } + + public function form(Schema $form): Schema + { + return $form + ->schema([ + CheckboxList::make('teams') + ->relationship('teams', 'name', modifyQueryUsing: fn ($query) => $query->where('name', 'like', 'Alpha%')), + ]) + ->model($this->record) + ->statePath('data'); + } + + public function save(): void + { + $this->form->getState(); + } + + public function render(): View + { + return view('livewire.form'); + } +} + class CheckboxListWithDisabledOptions extends Component implements HasActions, HasSchemas { use InteractsWithActions;
tests/src/Forms/Components/ModalTableSelectTest.php+168 −0 modified@@ -235,6 +235,174 @@ }); }); +describe('scope enforcement', function (): void { + it('rejects out-of-scope IDs in a multi `BelongsToMany` select when `modifyQueryUsing` excludes them', function (): void { + $user = User::factory()->create(); + $inScopeTeam = Team::factory()->create(['name' => 'Active Team']); + $outOfScopeTeam = Team::factory()->create(['name' => 'Inactive Team']); + + livewire(ModalTableSelectWithFilteredBelongsToManyRelationship::class, ['record' => $user]) + ->fillForm(['teams' => [(string) $inScopeTeam->id, (string) $outOfScopeTeam->id]]) + ->call('save') + ->assertHasFormErrors(); + + expect(DB::table('team_user')->where('user_id', $user->id)->where('team_id', $outOfScopeTeam->id)->exists())->toBeFalse(); + expect(DB::table('team_user')->where('user_id', $user->id)->where('team_id', $inScopeTeam->id)->exists())->toBeFalse(); + }); + + it('rejects out-of-scope IDs in a single `BelongsTo` select when `modifyQueryUsing` excludes them', function (): void { + $inScopeTeam = Team::factory()->create(['name' => 'Active Team']); + $outOfScopeTeam = Team::factory()->create(['name' => 'Inactive Team']); + $user = User::factory()->create(['team_id' => $inScopeTeam->id]); + + livewire(ModalTableSelectWithFilteredBelongsToRelationship::class, ['record' => $user]) + ->fillForm(['team_id' => (string) $outOfScopeTeam->id]) + ->call('save') + ->assertHasFormErrors(['team_id']); + + expect($user->fresh()->team_id)->toBe($inScopeTeam->id); + }); + + it('rejects out-of-scope IDs in a `HasMany` select when `modifyQueryUsing` excludes them', function (): void { + $user = User::factory()->create(); + $inScopePost = Post::factory()->create(['author_id' => null, 'is_published' => true]); + $outOfScopePost = Post::factory()->create(['author_id' => null, 'is_published' => false]); + + livewire(ModalTableSelectWithFilteredHasManyRelationship::class, ['record' => $user]) + ->fillForm(['posts' => [(string) $inScopePost->id, (string) $outOfScopePost->id]]) + ->call('save') + ->assertHasFormErrors(); + + expect($outOfScopePost->fresh()->author_id)->toBeNull(); + expect($inScopePost->fresh()->author_id)->toBeNull(); + }); +}); + +class ModalTableSelectWithFilteredBelongsToManyRelationship extends Component implements HasActions, HasSchemas +{ + use InteractsWithActions; + use InteractsWithSchemas; + + public $data = []; + + public User $record; + + public function mount(): void + { + $this->form->fill([]); + } + + public function form(Schema $form): Schema + { + return $form + ->schema([ + ModalTableSelect::make('teams') + ->relationship( + 'teams', + 'name', + modifyQueryUsing: fn ($query) => $query->where('name', 'like', 'Active%'), + ) + ->tableConfiguration(TeamsTable::class) + ->multiple(), + ]) + ->model($this->record) + ->statePath('data'); + } + + public function save(): void + { + $this->form->getState(); + } + + public function render(): View + { + return view('livewire.form'); + } +} + +class ModalTableSelectWithFilteredBelongsToRelationship extends Component implements HasActions, HasSchemas +{ + use InteractsWithActions; + use InteractsWithSchemas; + + public $data = []; + + public User $record; + + public function mount(): void + { + $this->form->fill([]); + } + + public function form(Schema $form): Schema + { + return $form + ->schema([ + ModalTableSelect::make('team_id') + ->relationship( + 'team', + 'name', + modifyQueryUsing: fn ($query) => $query->where('name', 'like', 'Active%'), + ) + ->tableConfiguration(TeamsTable::class), + ]) + ->model($this->record) + ->statePath('data'); + } + + public function save(): void + { + $this->form->getState(); + } + + public function render(): View + { + return view('livewire.form'); + } +} + +class ModalTableSelectWithFilteredHasManyRelationship extends Component implements HasActions, HasSchemas +{ + use InteractsWithActions; + use InteractsWithSchemas; + + public $data = []; + + public User $record; + + public function mount(): void + { + $this->form->fill([]); + } + + public function form(Schema $form): Schema + { + return $form + ->schema([ + ModalTableSelect::make('posts') + ->relationship( + 'posts', + 'title', + modifyQueryUsing: fn ($query) => $query->where('is_published', true), + ) + ->tableConfiguration(PostsTable::class) + ->multiple(), + ]) + ->model($this->record) + ->statePath('data'); + } + + public function save(): void + { + $this->form->getState(); + } + + public function render(): View + { + return view('livewire.form'); + } +} + class ModalTableSelectWithBelongsToManyRelationship extends Component implements HasActions, HasSchemas { use InteractsWithActions;
tests/src/Forms/Components/MorphToSelectTest.php+72 −0 modified@@ -2,11 +2,21 @@ namespace Filament\Tests\Forms\Components; +use Filament\Actions\Concerns\InteractsWithActions; +use Filament\Actions\Contracts\HasActions; use Filament\Forms\Components\MorphToSelect; +use Filament\Schemas\Concerns\InteractsWithSchemas; +use Filament\Schemas\Contracts\HasSchemas; +use Filament\Schemas\Schema; +use Filament\Tests\Fixtures\Models\Image; use Filament\Tests\Fixtures\Models\Post; use Filament\Tests\Fixtures\Models\User; use Filament\Tests\TestCase; +use Illuminate\Contracts\View\View; use InvalidArgumentException; +use Livewire\Component; + +use function Filament\Tests\livewire; uses(TestCase::class); @@ -118,6 +128,29 @@ }); }); +describe('validation', function (): void { + it('rejects an existing value excluded by `modifyOptionsQueryUsing` on a `MorphTo` type', function (): void { + $inScopePost = Post::factory()->create(['title' => 'Alpha Article']); + $outOfScopePost = Post::factory()->create(['title' => 'Beta Article']); + + livewire(TestComponentWithMorphToSelectAndModifyQuery::class) + ->fillForm([ + 'imageable_type' => Post::class, + 'imageable_id' => (string) $inScopePost->id, + ]) + ->call('save') + ->assertHasNoFormErrors(); + + livewire(TestComponentWithMorphToSelectAndModifyQuery::class) + ->fillForm([ + 'imageable_type' => Post::class, + 'imageable_id' => (string) $outOfScopePost->id, + ]) + ->call('save') + ->assertHasFormErrors(['imageable_id' => ['in']]); + }); +}); + describe('modifier callback clearing', function (): void { it('can clear `modifyTypeSelectUsing()` with `null`', function (): void { $component = MorphToSelect::make('commentable') @@ -135,3 +168,42 @@ expect($component->getModifyKeySelectUsingCallback())->toBeNull(); }); }); + +class TestComponentWithMorphToSelectAndModifyQuery extends Component implements HasActions, HasSchemas +{ + use InteractsWithActions; + use InteractsWithSchemas; + + public $data = []; + + public function mount(): void + { + $this->form->fill(); + } + + public function form(Schema $form): Schema + { + return $form + ->schema([ + MorphToSelect::make('imageable') + ->types([ + MorphToSelect\Type::make(Post::class) + ->titleAttribute('title') + ->modifyOptionsQueryUsing(fn ($query) => $query->where('title', 'like', 'Alpha%')), + ]) + ->preload(), + ]) + ->model(Image::class) + ->statePath('data'); + } + + public function save(): void + { + $this->form->getState(); + } + + public function render(): View + { + return view('livewire.form'); + } +}
tests/src/Forms/Components/RadioTest.php+77 −0 modified@@ -86,6 +86,83 @@ expect($radio->getDefaultState())->toBe(0); }); +describe('validation', function (): void { + it('automatically validates against options array', function (): void { + livewire(TestComponentWithRadioValidation::class) + ->fillForm(['status' => 'active']) + ->call('save') + ->assertHasNoFormErrors(); + + livewire(TestComponentWithRadioValidation::class) + ->fillForm(['status' => 'archived']) + ->call('save') + ->assertHasFormErrors(['status' => ['in']]); + }); + + it('rejects disabled options during validation', function (): void { + livewire(TestComponentWithDisabledRadioOption::class) + ->fillForm(['status' => 'active']) + ->call('save') + ->assertHasNoFormErrors(); + + livewire(TestComponentWithDisabledRadioOption::class) + ->fillForm(['status' => 'archived']) + ->call('save') + ->assertHasFormErrors(['status' => ['in']]); + }); + + it('passes validation when state is blank', function (): void { + livewire(TestComponentWithRadioValidation::class) + ->fillForm(['status' => null]) + ->call('save') + ->assertHasNoFormErrors(); + }); +}); + +class TestComponentWithRadioValidation extends Livewire +{ + public function form(Schema $form): Schema + { + return $form + ->schema([ + Radio::make('status') + ->options([ + 'active' => 'Active', + 'inactive' => 'Inactive', + ]), + ]) + ->statePath('data'); + } + + public function save(): void + { + $this->form->getState(); + } +} + +class TestComponentWithDisabledRadioOption extends Livewire +{ + public function form(Schema $form): Schema + { + return $form + ->schema([ + Radio::make('status') + ->options([ + 'active' => 'Active', + 'inactive' => 'Inactive', + 'archived' => 'Archived', + ]) + ->disableOptionWhen(static fn (string $value): bool => $value === 'archived'), + ]) + ->statePath('data'); + } + + public function save(): void + { + $this->form->getState(); + } +} + class TestComponentWithRadio extends Livewire { public function form(Schema $form): Schema
tests/src/Forms/Components/RepeaterTest.php+83 −0 modified@@ -363,6 +363,40 @@ $undoRepeaterFake(); }); + it('does not delete out-of-scope records when clearing a Repeater bound to a scoped relationship', function (): void { + $undoRepeaterFake = Repeater::fake(); + + $user = User::factory()->create(); + $publishedPost = Post::factory()->create([ + 'author_id' => $user->id, + 'is_published' => true, + 'title' => 'Published Title', + ]); + $outOfScopePost = Post::factory()->create([ + 'author_id' => $user->id, + 'is_published' => false, + 'title' => 'Unpublished Title', + ]); + + $component = livewire(RepeaterWithPublishedPostsRelationship::class, ['record' => $user]); + + // Clear all repeater items, simulating a user emptying the field. + $component->set('data.posts', []); + $component->call('save'); + + $undoRepeaterFake(); + + // The in-scope post was deleted (intended behavior — it was in the visible set + // and the user removed it from state). + expect(Post::query()->whereKey($publishedPost->id)->exists())->toBeFalse(); + + // The out-of-scope post must NOT be deleted — it was never in `$existingRecords` + // because `modifyQueryUsing` filtered it out, so the deletion loop never sees it. + expect(Post::query()->whereKey($outOfScopePost->id)->exists())->toBeTrue(); + expect($outOfScopePost->fresh()->title)->toBe('Unpublished Title'); + expect($outOfScopePost->fresh()->is_published)->toBeFalse(); + }); + it('throws an exception for a missing relationship', function (): void { $schema = Schema::make(Livewire::make()) ->statePath('data') @@ -990,6 +1024,55 @@ public function form(Schema $form): Schema ->statePath('data'); } + public function save(): void + { + $this->form->getState(); + $this->form->saveRelationships(); + } + + public function render(): View + { + return view('livewire.form'); + } +} + +class RepeaterWithPublishedPostsRelationship extends Component implements HasActions, HasSchemas +{ + use InteractsWithActions; + use InteractsWithSchemas; + + public $data = []; + + public User $record; + + public function mount(): void + { + $this->form->fill(); + } + + public function form(Schema $form): Schema + { + return $form + ->schema([ + Repeater::make('posts') + ->relationship( + 'posts', + modifyQueryUsing: fn ($query) => $query->where('is_published', true), + ) + ->schema([ + TextInput::make('title'), + ]), + ]) + ->model($this->record) + ->statePath('data'); + } + + public function save(): void + { + $this->form->getState(); + $this->form->saveRelationships(); + } + public function render(): View { return view('livewire.form');
tests/src/Forms/Components/SelectTest.php+208 −0 modified@@ -191,6 +191,68 @@ ->call('save') ->assertHasNoFormErrors(); }); + + it('rejects an in-scope value but excluded by `modifyQueryUsing` on a `BelongsTo` relationship', function (): void { + $inScope = User::factory()->create(['name' => 'Alpha User']); + $outOfScope = User::factory()->create(['name' => 'Beta User']); + + livewire(TestComponentWithBelongsToRelationshipAndModifyQueryValidation::class) + ->fillForm(['author_id' => (string) $inScope->id]) + ->call('save') + ->assertHasNoFormErrors(); + + livewire(TestComponentWithBelongsToRelationshipAndModifyQueryValidation::class) + ->fillForm(['author_id' => (string) $outOfScope->id]) + ->call('save') + ->assertHasFormErrors(['author_id' => ['in']]); + }); + + it('rejects an existing value excluded by `modifyQueryUsing` on a searchable `BelongsTo` relationship', function (): void { + $inScope = User::factory()->create(['name' => 'Alpha User']); + $outOfScope = User::factory()->create(['name' => 'Beta User']); + + livewire(TestComponentWithSearchableBelongsToRelationshipAndModifyQueryValidation::class) + ->fillForm(['author_id' => (string) $inScope->id]) + ->call('save') + ->assertHasNoFormErrors(); + + livewire(TestComponentWithSearchableBelongsToRelationshipAndModifyQueryValidation::class) + ->fillForm(['author_id' => (string) $outOfScope->id]) + ->call('save') + ->assertHasFormErrors(['author_id' => ['in']]); + }); + + it('rejects existing values excluded by `modifyQueryUsing` on a multiple `BelongsToMany` relationship', function (): void { + $user = User::factory()->create(); + $inScope = Team::factory()->create(['name' => 'Alpha Team']); + $outOfScope = Team::factory()->create(['name' => 'Beta Team']); + + livewire(TestComponentWithBelongsToManyRelationshipAndModifyQueryValidation::class, ['record' => $user]) + ->fillForm(['teams' => [(string) $inScope->id]]) + ->call('save') + ->assertHasNoFormErrors(); + + livewire(TestComponentWithBelongsToManyRelationshipAndModifyQueryValidation::class, ['record' => $user]) + ->fillForm(['teams' => [(string) $inScope->id, (string) $outOfScope->id]]) + ->call('save') + ->assertHasFormErrors(['teams.1' => ['in']]); + }); + + it('rejects existing values excluded by `modifyQueryUsing` on a searchable multiple `BelongsToMany` relationship', function (): void { + $user = User::factory()->create(); + $inScope = Team::factory()->create(['name' => 'Alpha Team']); + $outOfScope = Team::factory()->create(['name' => 'Beta Team']); + + livewire(TestComponentWithSearchableBelongsToManyRelationshipAndModifyQueryValidation::class, ['record' => $user]) + ->fillForm(['teams' => [(string) $inScope->id]]) + ->call('save') + ->assertHasNoFormErrors(); + + livewire(TestComponentWithSearchableBelongsToManyRelationshipAndModifyQueryValidation::class, ['record' => $user]) + ->fillForm(['teams' => [(string) $inScope->id, (string) $outOfScope->id]]) + ->call('save') + ->assertHasFormErrors(['teams.1' => ['in']]); + }); }); describe('`BelongsToMany` relationship', function (): void { @@ -2629,6 +2691,152 @@ public function render(): View } } +class TestComponentWithBelongsToRelationshipAndModifyQueryValidation extends Component implements HasActions, HasSchemas +{ + use InteractsWithActions; + use InteractsWithSchemas; + + public $data = []; + + public function mount(): void + { + $this->form->fill(); + } + + public function form(Schema $form): Schema + { + return $form + ->schema([ + Select::make('author_id') + ->relationship('author', 'name', modifyQueryUsing: fn ($query) => $query->where('name', 'like', 'Alpha%')) + ->preload(), + ]) + ->model(Post::class) + ->statePath('data'); + } + + public function save(): void + { + $this->form->getState(); + } + + public function render(): View + { + return view('livewire.form'); + } +} + +class TestComponentWithSearchableBelongsToRelationshipAndModifyQueryValidation extends Component implements HasActions, HasSchemas +{ + use InteractsWithActions; + use InteractsWithSchemas; + + public $data = []; + + public function mount(): void + { + $this->form->fill(); + } + + public function form(Schema $form): Schema + { + return $form + ->schema([ + Select::make('author_id') + ->relationship('author', 'name', modifyQueryUsing: fn ($query) => $query->where('name', 'like', 'Alpha%')) + ->searchable(), + ]) + ->model(Post::class) + ->statePath('data'); + } + + public function save(): void + { + $this->form->getState(); + } + + public function render(): View + { + return view('livewire.form'); + } +} + +class TestComponentWithBelongsToManyRelationshipAndModifyQueryValidation extends Component implements HasActions, HasSchemas +{ + use InteractsWithActions; + use InteractsWithSchemas; + + public $data = []; + + public User $record; + + public function mount(): void + { + $this->form->fill([]); + } + + public function form(Schema $form): Schema + { + return $form + ->schema([ + Select::make('teams') + ->relationship('teams', 'name', modifyQueryUsing: fn ($query) => $query->where('name', 'like', 'Alpha%')) + ->multiple() + ->preload(), + ]) + ->model($this->record) + ->statePath('data'); + } + + public function save(): void + { + $this->form->getState(); + } + + public function render(): View + { + return view('livewire.form'); + } +} + +class TestComponentWithSearchableBelongsToManyRelationshipAndModifyQueryValidation extends Component implements HasActions, HasSchemas +{ + use InteractsWithActions; + use InteractsWithSchemas; + + public $data = []; + + public User $record; + + public function mount(): void + { + $this->form->fill([]); + } + + public function form(Schema $form): Schema + { + return $form + ->schema([ + Select::make('teams') + ->relationship('teams', 'name', modifyQueryUsing: fn ($query) => $query->where('name', 'like', 'Alpha%')) + ->multiple() + ->searchable(), + ]) + ->model($this->record) + ->statePath('data'); + } + + public function save(): void + { + $this->form->getState(); + } + + public function render(): View + { + return view('livewire.form'); + } +} + class TestComponentWithDisabledOptions extends Livewire { public $data = [];
tests/src/Forms/Components/TableSelectTest.php+42 −0 modified@@ -416,6 +416,48 @@ }); }); +describe('scope enforcement', function (): void { + it('rejects an out-of-relationship ID when saving a `BelongsTo` relationship', function (): void { + $allowedTeam = Team::factory()->create(); + $user = User::factory()->create(['team_id' => $allowedTeam->id]); + $nonExistentTeamId = (string) (Team::max('id') + 1000); + + livewire(TableSelectWithBelongsToRelationship::class, ['record' => $user]) + ->fillForm(['team_id' => $nonExistentTeamId]) + ->call('save') + ->assertHasFormErrors(['team_id']); + + expect($user->fresh()->team_id)->toBe($allowedTeam->id); + }); + + it('rejects out-of-relationship IDs when saving a `BelongsToMany` relationship', function (): void { + $user = User::factory()->create(); + $availableTeams = Team::factory()->count(2)->create(); + $nonExistentTeamId = (string) (Team::max('id') + 1000); + + livewire(TableSelectWithBelongsToManyRelationship::class, ['record' => $user]) + ->fillForm(['teams' => [...$availableTeams->pluck('id')->map(fn ($id) => (string) $id)->all(), $nonExistentTeamId]]) + ->call('save') + ->assertHasFormErrors(); + + expect(DB::table('team_user')->where('user_id', $user->id)->where('team_id', $nonExistentTeamId)->exists())->toBeFalse(); + expect(DB::table('team_user')->where('user_id', $user->id)->where('team_id', $availableTeams->first()->id)->exists())->toBeFalse(); + }); + + it('rejects out-of-relationship IDs when saving a `HasMany` relationship', function (): void { + $user = User::factory()->create(); + $orphanPost = Post::factory()->create(['author_id' => null]); + $nonExistentPostId = (string) (Post::max('id') + 1000); + + livewire(TableSelectWithHasManyRelationship::class, ['record' => $user]) + ->fillForm(['posts' => [(string) $orphanPost->id, $nonExistentPostId]]) + ->call('save') + ->assertHasFormErrors(); + + expect($orphanPost->fresh()->author_id)->toBeNull(); + }); +}); + describe('properties', function (): void { it('defaults `isMultiple()` to `false`', function (): void { $select = TableSelect::make('team');
tests/src/Forms/Components/ToggleButtonsTest.php+77 −0 modified@@ -108,6 +108,83 @@ }); }); +describe('validation', function (): void { + it('automatically validates against options array', function (): void { + livewire(TestComponentWithToggleButtonsValidation::class) + ->fillForm(['status' => 'active']) + ->call('save') + ->assertHasNoFormErrors(); + + livewire(TestComponentWithToggleButtonsValidation::class) + ->fillForm(['status' => 'archived']) + ->call('save') + ->assertHasFormErrors(['status' => ['in']]); + }); + + it('automatically validates multiple options', function (): void { + livewire(TestComponentWithMultipleToggleButtonsValidation::class) + ->fillForm(['tags' => ['one', 'two']]) + ->call('save') + ->assertHasNoFormErrors(); + + livewire(TestComponentWithMultipleToggleButtonsValidation::class) + ->fillForm(['tags' => ['one', 'four']]) + ->call('save') + ->assertHasFormErrors(['tags.1' => ['in']]); + }); + + it('passes validation when state is blank', function (): void { + livewire(TestComponentWithToggleButtonsValidation::class) + ->fillForm(['status' => null]) + ->call('save') + ->assertHasNoFormErrors(); + }); +}); + +class TestComponentWithToggleButtonsValidation extends Livewire +{ + public function form(Schema $form): Schema + { + return $form + ->schema([ + ToggleButtons::make('status') + ->options([ + 'active' => 'Active', + 'inactive' => 'Inactive', + ]), + ]) + ->statePath('data'); + } + + public function save(): void + { + $this->form->getState(); + } +} + +class TestComponentWithMultipleToggleButtonsValidation extends Livewire +{ + public function form(Schema $form): Schema + { + return $form + ->schema([ + ToggleButtons::make('tags') + ->multiple() + ->options([ + 'one' => 'One', + 'two' => 'Two', + 'three' => 'Three', + ]), + ]) + ->statePath('data'); + } + + public function save(): void + { + $this->form->getState(); + } +} + class TestComponentWithToggleButtons extends Livewire { public function form(Schema $form): Schema
tests/src/Panels/Resources/RelationManagerTest.php+21 −0 modified@@ -19,6 +19,7 @@ use Filament\Tests\Fixtures\Resources\Tickets\Pages\EditTicket; use Filament\Tests\Fixtures\Resources\Tickets\RelationManagers\DepartmentsRelationManager; use Filament\Tests\Fixtures\Resources\Tickets\RelationManagers\DepartmentsRelationManagerWithTabs; +use Filament\Tests\Fixtures\Resources\Tickets\RelationManagers\DepartmentsWithAttachTableSelectAndModifiedQueryRelationManager; use Filament\Tests\Fixtures\Resources\Tickets\RelationManagers\DepartmentsWithAttachTableSelectRelationManager; use Filament\Tests\Fixtures\Resources\Tickets\RelationManagers\DepartmentsWithDeferredBadgeRelationManager; use Filament\Tests\Fixtures\Resources\Tickets\RelationManagers\DepartmentsWithMixedSummaryRelationManager; @@ -29,6 +30,7 @@ use function Filament\Tests\livewire; use function Pest\Laravel\assertDatabaseHas; +use function Pest\Laravel\assertDatabaseMissing; uses(TestCase::class); @@ -246,6 +248,25 @@ } }); + it('rejects out-of-scope `recordId` when `tableSelect()` is paired with `recordSelectOptionsQuery()`', function (): void { + $ticket = Ticket::factory()->create(); + Department::factory()->create(['name' => 'Active Engineering']); + $outOfScopeDepartment = Department::factory()->create(['name' => 'Inactive Department']); + + livewire(DepartmentsWithAttachTableSelectAndModifiedQueryRelationManager::class, [ + 'ownerRecord' => $ticket, + 'pageClass' => EditTicket::class, + ]) + ->callAction(TestAction::make(AttachAction::class)->table(), [ + 'recordId' => $outOfScopeDepartment->getKey(), + ]); + + assertDatabaseMissing('department_ticket', [ + 'department_id' => $outOfScopeDepartment->getKey(), + 'ticket_id' => $ticket->getKey(), + ]); + }); + it('can attach records when some are already related', function (): void { $ticket = Ticket::factory()->create(); $alreadyAttachedDepartment = Department::factory()->create();
tests/src/Tables/Columns/SelectColumnTest.php+24 −0 modified@@ -1033,6 +1033,30 @@ public function render(): View }); }); + it('rejects an `updateTableColumnState` call with a value excluded by `modifyQueryUsing`', function (): void { + $inScopeUser = User::factory()->create(['name' => 'Alpha User']); + $outOfScopeUser = User::factory()->create(['name' => 'Beta User']); + $post = Post::factory()->create(['author_id' => $inScopeUser->id]); + + livewire(TestTableWithFilteredRelationshipSelectColumn::class) + ->call('updateTableColumnState', 'author_id', (string) $post->getKey(), $outOfScopeUser->getKey()); + + // The validation error in `updateTableColumnState` is caught and returned as ['error' => …] + // (rather than thrown), so we assert the persisted state was not mutated. + expect($post->fresh()->author_id)->toBe($inScopeUser->id); + }); + + it('allows an `updateTableColumnState` call with a value matched by `modifyQueryUsing`', function (): void { + $inScopeUser = User::factory()->create(['name' => 'Alpha User']); + $anotherInScopeUser = User::factory()->create(['name' => 'Alpha Author']); + $post = Post::factory()->create(['author_id' => $inScopeUser->id]); + + livewire(TestTableWithFilteredRelationshipSelectColumn::class) + ->call('updateTableColumnState', 'author_id', (string) $post->getKey(), $anotherInScopeUser->getKey()); + + expect($post->fresh()->author_id)->toBe($anotherInScopeUser->id); + }); + it('throws `LogicException` from `getOptionsRelationship()` when the relationship does not exist', function (): void { $post = Post::factory()->create();
tests/src/Tables/Filters/QueryBuilderTest.php+167 −0 modified@@ -1,9 +1,13 @@ <?php +use Filament\QueryBuilder\Constraints\RelationshipConstraint; +use Filament\QueryBuilder\Constraints\RelationshipConstraint\Operators\IsRelatedToOperator; use Filament\Tables\Filters\QueryBuilder; use Filament\Tables\Filters\QueryBuilder\Constraints\TextConstraint; use Filament\Tests\Fixtures\Livewire\PostsQueryBuilderTable; +use Filament\Tests\Fixtures\Livewire\PostsQueryBuilderTableWithScopedAuthor; use Filament\Tests\Fixtures\Livewire\UsersQueryBuilderTable; +use Filament\Tests\Fixtures\Livewire\UsersQueryBuilderTableWithScopedPostsCount; use Filament\Tests\Fixtures\Models\Post; use Filament\Tests\Fixtures\Models\Team; use Filament\Tests\Fixtures\Models\User; @@ -636,6 +640,169 @@ function applyQueryBuilderFilter(array $rules) ->assertCanNotSeeTableRecords($nonMatchingPosts); }); + it('still matches in-scope `isRelatedTo` values when `modifyRelationshipQueryUsing` is set', function (): void { + $inScopeAuthor = User::factory()->create(['name' => 'Alpha Author']); + $outOfScopeAuthor = User::factory()->create(['name' => 'Beta Author']); + + $inScopePosts = Post::factory()->count(2)->create(['author_id' => $inScopeAuthor->id]); + $outOfScopePosts = Post::factory()->count(2)->create(['author_id' => $outOfScopeAuthor->id]); + + livewire(PostsQueryBuilderTableWithScopedAuthor::class) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'author', + 'data' => [ + 'operator' => 'isRelatedTo', + 'settings' => ['value' => $inScopeAuthor->id], + ], + ], + ])) + ->assertCanSeeTableRecords($inScopePosts) + ->assertCanNotSeeTableRecords($outOfScopePosts); + }); + + it('applies `modifyRelationshipQueryUsing` inside the `whereHas` subquery to defend against a bypassed tampered value', function (): void { + // Defense-in-depth: form validation is the primary defense and would reject the + // out-of-scope value before reaching `apply()`. This test bypasses validation + // by invoking `apply()` directly to confirm the operator does not leak rows + // even if a tampered value did somehow reach query construction. + $inScopeAuthor = User::factory()->create(['name' => 'Alpha Author']); + $outOfScopeAuthor = User::factory()->create(['name' => 'Beta Author']); + + Post::factory()->count(2)->create(['author_id' => $inScopeAuthor->id]); + Post::factory()->count(2)->create(['author_id' => $outOfScopeAuthor->id]); + + $constraint = RelationshipConstraint::make('author'); + + $operator = IsRelatedToOperator::make() + ->constraint($constraint) + ->settings(['value' => $outOfScopeAuthor->id]) + ->titleAttribute('name') + ->modifyRelationshipQueryUsing(fn ($query) => $query->where('name', 'like', 'Alpha%')); + + $filtered = $operator->apply(Post::query(), 'author_id'); + + expect($filtered->count())->toBe(0); + }); + + it('applies `modifyRelationshipQueryUsing` inside `IsEmptyOperator` count check', function (): void { + // Author A has 1 published + 0 unpublished — should NOT match isEmpty under the published-only scope. + // Author B has 0 published + 2 unpublished — SHOULD match isEmpty under the scope (no published posts). + // Author C has no posts at all — SHOULD match isEmpty. + $authorA = User::factory()->create(['name' => 'Author A']); + Post::factory()->create(['author_id' => $authorA->id, 'is_published' => true]); + + $authorB = User::factory()->create(['name' => 'Author B']); + Post::factory()->count(2)->create(['author_id' => $authorB->id, 'is_published' => false]); + + $authorC = User::factory()->create(['name' => 'Author C']); + + livewire(UsersQueryBuilderTableWithScopedPostsCount::class) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'posts', + 'data' => [ + 'operator' => 'isEmpty', + 'settings' => [], + ], + ], + ])) + ->assertCanSeeTableRecords([$authorB, $authorC]) + ->assertCanNotSeeTableRecords([$authorA]); + }); + + it('applies `modifyRelationshipQueryUsing` inside `HasMinOperator` count check', function (): void { + $authorWithTwoPublished = User::factory()->create(['name' => 'Two Published']); + Post::factory()->count(2)->create(['author_id' => $authorWithTwoPublished->id, 'is_published' => true]); + + $authorWithUnpublishedOnly = User::factory()->create(['name' => 'Unpublished Only']); + Post::factory()->count(3)->create(['author_id' => $authorWithUnpublishedOnly->id, 'is_published' => false]); + + livewire(UsersQueryBuilderTableWithScopedPostsCount::class) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'posts', + 'data' => [ + 'operator' => 'hasMin', + 'settings' => ['count' => 2], + ], + ], + ])) + ->assertCanSeeTableRecords([$authorWithTwoPublished]) + ->assertCanNotSeeTableRecords([$authorWithUnpublishedOnly]); + }); + + it('applies `modifyRelationshipQueryUsing` inside `HasMaxOperator` count check', function (): void { + $authorWithOnePublished = User::factory()->create(['name' => 'One Published']); + Post::factory()->create(['author_id' => $authorWithOnePublished->id, 'is_published' => true]); + Post::factory()->count(5)->create(['author_id' => $authorWithOnePublished->id, 'is_published' => false]); + + $authorWithThreePublished = User::factory()->create(['name' => 'Three Published']); + Post::factory()->count(3)->create(['author_id' => $authorWithThreePublished->id, 'is_published' => true]); + + // hasMax: 1 — both `one published + 5 unpublished` and `three published` would match if the scope + // were dropped (since one has 6 total and the other has 3 total). With the scope applied, only the + // author with 1 published post matches. + livewire(UsersQueryBuilderTableWithScopedPostsCount::class) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'posts', + 'data' => [ + 'operator' => 'hasMax', + 'settings' => ['count' => 1], + ], + ], + ])) + ->assertCanSeeTableRecords([$authorWithOnePublished]) + ->assertCanNotSeeTableRecords([$authorWithThreePublished]); + }); + + it('applies `modifyRelationshipQueryUsing` inside `EqualsOperator` count check', function (): void { + $authorWithTwoPublished = User::factory()->create(['name' => 'Two Published']); + Post::factory()->count(2)->create(['author_id' => $authorWithTwoPublished->id, 'is_published' => true]); + Post::factory()->count(3)->create(['author_id' => $authorWithTwoPublished->id, 'is_published' => false]); + + $authorWithFivePublished = User::factory()->create(['name' => 'Five Published']); + Post::factory()->count(5)->create(['author_id' => $authorWithFivePublished->id, 'is_published' => true]); + + // equals: 2 — pre-fix, the `Two Published` author has 5 posts total, would NOT match. + // Post-fix (scope applied), they have exactly 2 published posts, so they DO match. + livewire(UsersQueryBuilderTableWithScopedPostsCount::class) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'posts', + 'data' => [ + 'operator' => 'equals', + 'settings' => ['count' => 2], + ], + ], + ])) + ->assertCanSeeTableRecords([$authorWithTwoPublished]) + ->assertCanNotSeeTableRecords([$authorWithFivePublished]); + }); + + it('inverts the scope inside `whereDoesntHave` when `isRelatedTo.inverse` is used with `modifyRelationshipQueryUsing`', function (): void { + $inScopeAuthor = User::factory()->create(['name' => 'Alpha Author']); + $outOfScopeAuthor = User::factory()->create(['name' => 'Beta Author']); + + Post::factory()->count(2)->create(['author_id' => $inScopeAuthor->id]); + Post::factory()->count(2)->create(['author_id' => $outOfScopeAuthor->id]); + + $constraint = RelationshipConstraint::make('author'); + + $operator = IsRelatedToOperator::make() + ->constraint($constraint) + ->settings(['value' => $inScopeAuthor->id]) + ->inverse() + ->titleAttribute('name') + ->modifyRelationshipQueryUsing(fn ($query) => $query->where('name', 'like', 'Alpha%')); + + $filtered = $operator->apply(Post::query(), 'author_id'); + + // Only the out-of-scope posts (those NOT related to the in-scope author) remain. + expect($filtered->count())->toBe(2); + }); + it('can filter records using relationship constraint with is not related to operator', function (): void { $author = User::factory()->create(['name' => 'John Doe']); Post::factory()->count(5)->create(['author_id' => $author->id]);
tests/src/Tables/Filters/SelectFilterTest.php+47 −0 modified@@ -501,6 +501,22 @@ public function render(): View ->assertCanNotSeeTableRecords($postsWithAuthor2); }); + it('returns zero rows when a tampered filter value points at a record excluded by `modifyQueryUsing`', function (): void { + $inScopeAuthor = User::factory()->create(['name' => 'Alpha User']); + $outOfScopeAuthor = User::factory()->create(['name' => 'Beta User']); + + $inScopePosts = Post::factory()->count(2)->create(['author_id' => $inScopeAuthor->getKey()]); + $outOfScopePosts = Post::factory()->count(2)->create(['author_id' => $outOfScopeAuthor->getKey()]); + + // Defense-in-depth: even if the picker validation were bypassed and an + // out-of-scope author ID reached the filter, `apply()` re-applies the + // `modifyQueryUsing` scope inside `whereHas`, so no rows leak through. + livewire(TestTableWithScopeFilteredRelationshipFilter::class) + ->filterTable('author', $outOfScopeAuthor->getKey()) + ->assertCanNotSeeTableRecords($outOfScopePosts) + ->assertCanNotSeeTableRecords($inScopePosts); + }); + it('can filter records by relationship with custom option labels', function (): void { $author1 = User::factory()->create(['name' => 'John', 'email' => 'john@example.com']); $author2 = User::factory()->create(['name' => 'Jane', 'email' => 'jane@example.com']); @@ -600,6 +616,37 @@ public function render(): View } } + class TestTableWithScopeFilteredRelationshipFilter extends Component implements HasActions, HasSchemas, Tables\Contracts\HasTable + { + use InteractsWithActions; + use InteractsWithSchemas; + use Tables\Concerns\InteractsWithTable; + + public function table(Table $table): Table + { + return $table + ->query(Post::query()) + ->columns([ + Tables\Columns\TextColumn::make('title'), + Tables\Columns\TextColumn::make('author.name'), + ]) + ->filters([ + SelectFilter::make('author') + ->relationship( + 'author', + 'name', + modifyQueryUsing: fn ($query) => $query->where('name', 'like', 'Alpha%'), + ) + ->preload(), + ]); + } + + public function render(): View + { + return view('livewire.table'); + } + } + class TestTableWithCustomRelationshipLabelFilter extends Component implements HasActions, HasSchemas, Tables\Contracts\HasTable { use InteractsWithActions;
Vulnerability mechanics
Root cause
"The built-in validation rule for AttachAction and AssociateAction select fields did not apply the same query scope defined by recordSelectOptionsQuery(), allowing submission of out-of-scope values."
Attack vector
An attacker who can trigger an `AttachAction` or `AssociateAction` can tamper with the Livewire component's state to submit a `recordId` that was excluded by `recordSelectOptionsQuery()` (e.g., a department filtered out by a `modifyQueryUsing` callback). Because the server-side validation did not re-apply the same query scope, the attacker could attach or associate a record they should not have access to [CWE-639]. The attack requires network access to the Filament panel and the ability to interact with the attach/associate UI.
Affected code
The `recordSelectOptionsQuery()` method in `AttachAction` and `AssociateAction` scopes the options shown in the Select field, but the built-in validation rule did not enforce the same scope. The patch adds validation tests in `AttachActionTest.php`, `SelectTest.php`, `ModalTableSelectTest.php`, and `QueryBuilderTest.php` to ensure out-of-scope values are rejected.
What the fix does
The patch adds server-side validation that re-applies the `modifyQueryUsing` scope when validating submitted `recordId` values. New test cases in `AttachActionTest.php`, `SelectTest.php`, `ModalTableSelectTest.php`, and `QueryBuilderTest.php` confirm that out-of-scope IDs are rejected with form errors and that the database is not modified. The commit also adds defense-in-depth by applying the scope inside `whereHas` subqueries in the QueryBuilder operators, so even if a tampered value bypassed validation, it would not leak rows.
Preconditions
- configThe attacker must have access to a Filament panel page that uses AttachAction or AssociateAction with a recordSelectOptionsQuery() scope.
- inputThe attacker must be able to interact with the Livewire component and tamper with its state (e.g., via browser dev tools or a crafted HTTP request).
- networkNetwork access to the Filament application is required.
Generated on Jun 11, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5News mentions
0No linked articles in our index yet.