From ac09d2c62ceb1d12b64ebb0fb60d6c74acdbf1aa Mon Sep 17 00:00:00 2001 From: Dan Harrin Date: Mon, 2 Jun 2025 11:57:09 +0100 Subject: [PATCH] `scopedUnique()` and `scopedExists()` rules --- docs/03-resources/01-overview.md | 8 +- docs/03-resources/06-deleting-records.md | 12 +-- .../03-resources/07-managing-relationships.md | 10 +-- docs/07-users/03-tenancy.md | 17 ++++ docs/08-styling/04-icons.md | 6 +- packages/actions/docs/09-force-delete.md | 6 +- packages/actions/docs/10-restore.md | 2 +- packages/forms/docs/23-validation.md | 51 +++++++++++ .../Components/Concerns/CanBeValidated.php | 87 +++++++++++++++++++ .../panels/src/Commands/MakePageCommand.php | 4 +- .../Commands/MakeRelationManagerCommand.php | 4 +- .../src/Commands/MakeResourceCommand.php | 4 +- packages/tables/docs/03-filters/03-ternary.md | 4 +- 13 files changed, 185 insertions(+), 30 deletions(-) diff --git a/docs/03-resources/01-overview.md b/docs/03-resources/01-overview.md index 999a5ab2f4..e4920ed3aa 100644 --- a/docs/03-resources/01-overview.md +++ b/docs/03-resources/01-overview.md @@ -61,15 +61,15 @@ If you'd like to save time, Filament can automatically generate the [form](#reso php artisan make:filament-resource Customer --generate ``` -### Handling soft deletes +### Handling soft-deletes -By default, you will not be able to interact with deleted records in the app. If you'd like to add functionality to restore, force delete and filter trashed records in your resource, use the `--soft-deletes` flag when generating the resource: +By default, you will not be able to interact with deleted records in the app. If you'd like to add functionality to restore, force-delete and filter trashed records in your resource, use the `--soft-deletes` flag when generating the resource: ```bash php artisan make:filament-resource Customer --soft-deletes ``` -You can find out more about soft deleting [here](deleting-records#handling-soft-deletes). +You can find out more about soft-deleting [here](deleting-records#handling-soft-deletes). ### Generating a View page @@ -519,7 +519,7 @@ public static function getEloquentQuery(): Builder ### Disabling global scopes -By default, Filament will observe all global scopes that are registered to your model. However, this may not be ideal if you wish to access, for example, soft deleted records. +By default, Filament will observe all global scopes that are registered to your model. However, this may not be ideal if you wish to access, for example, soft-deleted records. To overcome this, you may override the `getEloquentQuery()` method that Filament uses: diff --git a/docs/03-resources/06-deleting-records.md b/docs/03-resources/06-deleting-records.md index 206d3f7b63..d7ce8214a5 100644 --- a/docs/03-resources/06-deleting-records.md +++ b/docs/03-resources/06-deleting-records.md @@ -2,19 +2,19 @@ title: Deleting records --- -## Handling soft deletes +## Handling soft-deletes -## Creating a resource with soft delete +## Creating a resource with soft-delete -By default, you will not be able to interact with deleted records in the app. If you'd like to add functionality to restore, force delete and filter trashed records in your resource, use the `--soft-deletes` flag when generating the resource: +By default, you will not be able to interact with deleted records in the app. If you'd like to add functionality to restore, force-delete and filter trashed records in your resource, use the `--soft-deletes` flag when generating the resource: ```bash php artisan make:filament-resource Customer --soft-deletes ``` -## Adding soft deletes to an existing resource +## Adding soft-deletes to an existing resource -Alternatively, you may add soft deleting functionality to an existing resource. +Alternatively, you may add soft-deleting functionality to an existing resource. Firstly, you must update the resource: @@ -108,7 +108,7 @@ They also have the ability to bulk-delete records if the `deleteAny()` method of You can use the `authorizeIndividualRecords()` method on the `BulkDeleteAction` to check the `delete()` policy for each record individually. -### Authorizing soft deletes +### Authorizing soft-deletes The `forceDelete()` policy method is used to prevent a single soft-deleted record from being force-deleted. `forceDeleteAny()` is used to prevent records from being bulk force-deleted. Filament uses the `forceDeleteAny()` method because iterating through multiple records and checking the `forceDelete()` policy is not very performant. diff --git a/docs/03-resources/07-managing-relationships.md b/docs/03-resources/07-managing-relationships.md index 06760e8574..94bbd25bd7 100644 --- a/docs/03-resources/07-managing-relationships.md +++ b/docs/03-resources/07-managing-relationships.md @@ -157,15 +157,15 @@ public function table(Table $table): Table } ``` -### Handling soft deletes +### Handling soft-deletes -By default, you will not be able to interact with deleted records in the relation manager. If you'd like to add functionality to restore, force delete and filter trashed records in your relation manager, use the `--soft-deletes` flag when generating the relation manager: +By default, you will not be able to interact with deleted records in the relation manager. If you'd like to add functionality to restore, force-delete and filter trashed records in your relation manager, use the `--soft-deletes` flag when generating the relation manager: ```bash php artisan make:filament-relation-manager CategoryResource posts title --soft-deletes ``` -You can find out more about soft deleting [here](#deleting-records). +You can find out more about soft-deleting [here](#deleting-records). ## Listing related records @@ -557,13 +557,13 @@ public function table(Table $table): Table ## Deleting related records -By default, you will not be able to interact with deleted records in the relation manager. If you'd like to add functionality to restore, force delete and filter trashed records in your relation manager, use the `--soft-deletes` flag when generating the relation manager: +By default, you will not be able to interact with deleted records in the relation manager. If you'd like to add functionality to restore, force-delete and filter trashed records in your relation manager, use the `--soft-deletes` flag when generating the relation manager: ```bash php artisan make:filament-relation-manager CategoryResource posts title --soft-deletes ``` -Alternatively, you may add soft deleting functionality to an existing relation manager: +Alternatively, you may add soft-deleting functionality to an existing relation manager: ```php use Filament\Tables; diff --git a/docs/07-users/03-tenancy.md b/docs/07-users/03-tenancy.md index 4e9a9dbd0e..2c5188d2d3 100644 --- a/docs/07-users/03-tenancy.md +++ b/docs/07-users/03-tenancy.md @@ -775,6 +775,23 @@ Below is a list of features that Filament provides to help you implement multi-t - As per the point above, models created outside the panel with tenancy enabled do not have access to the current tenant, so are not associated. If in doubt, please check if your models are properly associated or not before deploying your application. - If you need to disable the automatic association for a particular model, you can [mute the events](https://laravel.com/docs/eloquent#muting-events) temporarily while you create it. If any of your code currently does this or removes event listeners permanently, you should check this is not affecting the tenancy feature. +### `unique` and `exists` validation + +Laravel's `unique` and `exists` validation rules do not use Eloquent models to query the database by default, so it will not use any global scopes defined on the model, including for multi-tenancy. As such, even if there is a soft-deleted record with the same value in a different tenant, the validation will fail. + +If you would like two tenants to have complete data separation, you should use the `scopedUnique()` or `scopedExists()` methods instead, which replace Laravel's `unique` and `exists` implementations with ones that uses the model to query the database, applying any global scopes defined on the model, including for multi-tenancy: + +```php +use Filament\Forms\Components\TextInput; + +TextInput::make('email') + ->scopedUnique() + // or + ->scopedExists() +``` + +For more information, see the [validation documentation](../forms/validation) for [`unique()`](../forms/validation#unique) and [`exists()`](../forms/validation#exists). + ### Using tenant-aware middleware to apply additional global scopes Since only models with resources that exist in the panel are automatically scoped to the current tenant, it might be useful to apply additional tenant scoping to other Eloquent models while they are being used in your panel. This would allow you to forget about scoping your queries to the current tenant, and instead have the scoping applied automatically. To do this, you can create a new middleware class like `ApplyTenantScopes`: diff --git a/docs/08-styling/04-icons.md b/docs/08-styling/04-icons.md index f7ec09742f..166848cdf2 100644 --- a/docs/08-styling/04-icons.md +++ b/docs/08-styling/04-icons.md @@ -115,9 +115,9 @@ FilamentIcon::register([ - `actions::edit-action` - Trigger button of an edit action - `actions::edit-action.grouped` - Trigger button of a grouped edit action - `actions::export-action.grouped` - Trigger button of a grouped export action -- `actions::force-delete-action` - Trigger button of a force delete action -- `actions::force-delete-action.grouped` - Trigger button of a grouped force delete action -- `actions::force-delete-action.modal` - Modal of a force delete action +- `actions::force-delete-action` - Trigger button of a force-delete action +- `actions::force-delete-action.grouped` - Trigger button of a grouped force-delete action +- `actions::force-delete-action.modal` - Modal of a force-delete action - `actions::import-action.grouped` - Trigger button of a grouped import action - `actions::modal.confirmation` - Modal of an action that requires confirmation - `actions::replicate-action` - Trigger button of a replicate action diff --git a/packages/actions/docs/09-force-delete.md b/packages/actions/docs/09-force-delete.md index 797fd55f3f..7d3fa8eeec 100644 --- a/packages/actions/docs/09-force-delete.md +++ b/packages/actions/docs/09-force-delete.md @@ -5,7 +5,7 @@ import UtilityInjection from "@components/UtilityInjection.astro" ## Introduction -Filament includes an action that is able to force-delete [soft deleted](https://laravel.com/docs/eloquent#soft-deleting) Eloquent records. When the trigger button is clicked, a modal asks the user for confirmation. You may use it like so: +Filament includes an action that is able to force-delete [soft-deleted](https://laravel.com/docs/eloquent#soft-deleting) Eloquent records. When the trigger button is clicked, a modal asks the user for confirmation. You may use it like so: ```php use Filament\Actions\ForceDeleteAction; @@ -13,7 +13,7 @@ use Filament\Actions\ForceDeleteAction; ForceDeleteAction::make() ``` -Or if you want to add it as a table bulk action, so that the user can choose which rows to force delete, use `Filament\Actions\ForceDeleteBulkAction`: +Or if you want to add it as a table bulk action, so that the user can choose which rows to force-delete, use `Filament\Actions\ForceDeleteBulkAction`: ```php use Filament\Actions\ForceDeleteBulkAction; @@ -100,7 +100,7 @@ ForceDeleteAction::make() These hook functions can inject various utilities as parameters. -## Improving the performance of force delete bulk actions +## Improving the performance of force-delete bulk actions By default, the `ForceDeleteBulkAction` will load all Eloquent records into memory, before looping over them and deleting them one by one. diff --git a/packages/actions/docs/10-restore.md b/packages/actions/docs/10-restore.md index fa476df16e..7b2f15def1 100644 --- a/packages/actions/docs/10-restore.md +++ b/packages/actions/docs/10-restore.md @@ -5,7 +5,7 @@ import UtilityInjection from "@components/UtilityInjection.astro" ## Introduction -Filament includes an action that is able to restore [soft deleted](https://laravel.com/docs/eloquent#soft-deleting) Eloquent records. When the trigger button is clicked, a modal asks the user for confirmation. You may use it like so: +Filament includes an action that is able to restore [soft-deleted](https://laravel.com/docs/eloquent#soft-deleting) Eloquent records. When the trigger button is clicked, a modal asks the user for confirmation. You may use it like so: ```php use Filament\Actions\RestoreAction; diff --git a/packages/forms/docs/23-validation.md b/packages/forms/docs/23-validation.md index 0b88315a92..4c079f7899 100644 --- a/packages/forms/docs/23-validation.md +++ b/packages/forms/docs/23-validation.md @@ -200,6 +200,32 @@ Field::make('invitation') }) ``` +Laravel's `exists` validation rule does not use the Eloquent model to query the database by default, so it will not use any global scopes defined on the model, including for soft-deletes. As such, even if there is a soft-deleted record with the same value, the validation will pass. + +Since global scopes are not applied, Filament's multi-tenancy feature also does not scope the query to the current tenant by default. + +To do this, you should use the `scopedExists()` method instead, which replaces Laravel's `exists` implementation with one that uses the model to query the database, applying any global scopes defined on the model, including for soft-deletes and multi-tenancy: + +```php +use Filament\Forms\Components\TextInput; + +TextInput::make('email') + ->scopedExists() +``` + +If you would like to modify the Eloquent query used to check for presence, including to remove a global scope, you can pass a function to the `modifyQueryUsing` parameter: + +```php +use Filament\Forms\Components\TextInput; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\SoftDeletingScope; + +TextInput::make('email') + ->scopedExists(modifyQueryUsing: function (Builder $query) { + return $query->withoutGlobalScope(SoftDeletingScope::class); + }) +``` + ### Filled The field must not be empty when it is present. [See the Laravel documentation.](https://laravel.com/docs/validation#rule-filled) @@ -510,6 +536,31 @@ Field::make('email') }) ``` +Laravel's `unique` validation rule does not use the Eloquent model to query the database by default, so it will not use any global scopes defined on the model, including for soft-deletes. As such, even if there is a soft-deleted record with the same value, the validation will fail. + +Since global scopes are not applied, Filament's multi-tenancy feature also does not scope the query to the current tenant by default. + +To do this, you should use the `scopedUnique()` method instead, which replaces Laravel's `unique` implementation with one that uses the model to query the database, applying any global scopes defined on the model, including for soft-deletes and multi-tenancy: + +```php +use Filament\Forms\Components\TextInput; + +TextInput::make('email') + ->scopedUnique() +``` + +If you would like to modify the Eloquent query used to check for uniqueness, including to remove a global scope, you can pass a function to the `modifyQueryUsing` parameter: + +```php +use Filament\Forms\Components\TextInput; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\SoftDeletingScope; + +TextInput::make('email') + ->scopedUnique(modifyQueryUsing: function (Builder $query) { + return $query->withoutGlobalScope(SoftDeletingScope::class); + }) +``` ### ULID diff --git a/packages/forms/src/Components/Concerns/CanBeValidated.php b/packages/forms/src/Components/Concerns/CanBeValidated.php index 4cf0273d2b..4e54a55217 100644 --- a/packages/forms/src/Components/Concerns/CanBeValidated.php +++ b/packages/forms/src/Components/Concerns/CanBeValidated.php @@ -3,12 +3,16 @@ namespace Filament\Forms\Components\Concerns; use Closure; +use Exception; +use Filament\Facades\Filament; use Filament\Forms\Components\Contracts\CanBeLengthConstrained; use Filament\Forms\Components\Contracts\HasNestedRecursiveValidationRules; use Filament\Forms\Components\Field; use Filament\Schemas\Components\Component; use Illuminate\Contracts\Support\Arrayable; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\SoftDeletingScope; +use Illuminate\Database\Query\Builder; use Illuminate\Support\Arr; use Illuminate\Support\Str; use Illuminate\Validation\Rule; @@ -157,6 +161,29 @@ trait CanBeValidated $table = $component->evaluate($table) ?? $model; $column = $component->evaluate($column) ?? $component->getName(); + if ( + class_exists($table) && + is_subclass_of($table, Model::class) && + class_exists(Filament::class) && + $table::hasGlobalScope(Filament::getTenancyScopeName()) + ) { + return function (string $attribute, mixed $value, Closure $fail) use ($column, $component, $modifyRuleUsing, $table): void { + $query = $table::query() + ->where($column, $value) + ->withoutGlobalScope(SoftDeletingScope::class); + + if ($modifyRuleUsing) { + $query = $component->evaluate($modifyRuleUsing, [ + 'query' => $query, + ]) ?? $query; + } + + if (! $query->exists()) { + $fail(__($component->getValidationMessages()['exists'] ?? 'validation.exists', ['attribute' => $component->getValidationAttribute()])); + } + }; + } + $rule = Rule::exists($table, $column); if ($modifyRuleUsing) { @@ -171,6 +198,32 @@ trait CanBeValidated return $this; } + public function scopedExists(string | Closure | null $model = null, string | Closure | null $column = null, ?Closure $modifyQueryUsing = null): static + { + $this->rule(static function (Field $component) use ($column, $modifyQueryUsing, $model) { + $model = $component->evaluate($model) ?? $component->getModel(); + $column = $component->evaluate($column) ?? $component->getName(); + + return function (string $attribute, mixed $value, Closure $fail) use ($column, $component, $modifyQueryUsing, $model): void { + $query = $model::query() + ->where($column, $value) + ->withoutGlobalScope(SoftDeletingScope::class); + + if ($modifyQueryUsing) { + $query = $component->evaluate($modifyQueryUsing, [ + 'query' => $query, + ]) ?? $query; + } + + if (! $query->exists()) { + $fail(__($component->getValidationMessages()['exists'] ?? 'validation.exists', ['attribute' => $component->getValidationAttribute()])); + } + }; + }, static fn (Field $component, ?string $model): bool => (bool) ($component->evaluate($model) ?? $model)); + + return $this; + } + public function filled(bool | Closure $condition = true): static { $this->rule('filled', $condition); @@ -540,6 +593,40 @@ trait CanBeValidated return $this; } + public function scopedUnique(string | Closure | null $model = null, string | Closure | null $column = null, Model | Closure | null $ignorable = null, ?bool $ignoreRecord = null, ?Closure $modifyQueryUsing = null): static + { + $this->rule(static function (Field $component) use ($column, $ignorable, $ignoreRecord, $modifyQueryUsing, $model) { + $ignoreRecord ??= $component->shouldUniqueValidationIgnoreRecordByDefault(); + + $model = $component->evaluate($model) ?? $component->getModel(); + $column = $component->evaluate($column) ?? $component->getName(); + $ignorable = ($ignoreRecord && (! $ignorable)) ? + $component->getRecord() : + $component->evaluate($ignorable); + + return function (string $attribute, mixed $value, Closure $fail) use ($column, $component, $ignorable, $modifyQueryUsing, $model): void { + $query = $model::query() + ->where($column, $value); + + if (filled($ignorable)) { + $query->whereKeyNot($ignorable); + } + + if ($modifyQueryUsing) { + $query = $component->evaluate($modifyQueryUsing, [ + 'query' => $query, + ]) ?? $query; + } + + if ($query->exists()) { + $fail(__($component->getValidationMessages()['unique'] ?? 'validation.unique', ['attribute' => $component->getValidationAttribute()])); + } + }; + }, fn (Field $component, ?string $model): bool => (bool) ($component->evaluate($model) ?? $model)); + + return $this; + } + public function uniqueValidationIgnoresRecordByDefault(bool | Closure $condition = true): static { $this->shouldUniqueValidationIgnoreRecordByDefault = $condition; diff --git a/packages/panels/src/Commands/MakePageCommand.php b/packages/panels/src/Commands/MakePageCommand.php index de6ff493fe..4101518547 100644 --- a/packages/panels/src/Commands/MakePageCommand.php +++ b/packages/panels/src/Commands/MakePageCommand.php @@ -476,7 +476,7 @@ class MakePageCommand extends Command 'resourceFqn' => $this->resourceFqn, 'hasViewOperation' => $this->resourceFqn::hasPage('view'), 'isSoftDeletable' => confirm( - label: 'Does the model use soft deletes?', + label: 'Does the model use soft-deletes?', default: false, ), ])); @@ -629,7 +629,7 @@ class MakePageCommand extends Command $isSoftDeletable = (filled($relatedModelFqn) && static::$shouldCheckModelsForSoftDeletes && class_exists($relatedModelFqn)) ? in_array(SoftDeletes::class, class_uses_recursive($relatedModelFqn)) : confirm( - label: 'Does the related model use soft deletes?', + label: 'Does the related model use soft-deletes?', default: false, ); diff --git a/packages/panels/src/Commands/MakeRelationManagerCommand.php b/packages/panels/src/Commands/MakeRelationManagerCommand.php index 9b833c0fd0..6306ef9d5a 100644 --- a/packages/panels/src/Commands/MakeRelationManagerCommand.php +++ b/packages/panels/src/Commands/MakeRelationManagerCommand.php @@ -205,7 +205,7 @@ class MakeRelationManagerCommand extends Command name: 'soft-deletes', shortcut: null, mode: InputOption::VALUE_NONE, - description: 'Indicate if the model uses soft deletes', + description: 'Indicate if the model uses soft-deletes', ), new InputOption( name: 'table', @@ -483,7 +483,7 @@ class MakeRelationManagerCommand extends Command $this->isSoftDeletable = $this->option('soft-deletes') || ((static::$shouldCheckModelsForSoftDeletes && filled($this->relatedModelFqn)) ? in_array(SoftDeletes::class, class_uses_recursive($this->relatedModelFqn)) : confirm( - label: 'Does the model use soft deletes?', + label: 'Does the model use soft-deletes?', default: false, )); } diff --git a/packages/panels/src/Commands/MakeResourceCommand.php b/packages/panels/src/Commands/MakeResourceCommand.php index 4271b6b5a9..8dbdc4ca60 100644 --- a/packages/panels/src/Commands/MakeResourceCommand.php +++ b/packages/panels/src/Commands/MakeResourceCommand.php @@ -207,7 +207,7 @@ class MakeResourceCommand extends Command name: 'soft-deletes', shortcut: null, mode: InputOption::VALUE_NONE, - description: 'Indicate if the model uses soft deletes', + description: 'Indicate if the model uses soft-deletes', ), new InputOption( name: 'view', @@ -433,7 +433,7 @@ class MakeResourceCommand extends Command $this->isSoftDeletable = $this->option('soft-deletes') || ((static::$shouldCheckModelsForSoftDeletes && class_exists($this->modelFqn)) ? in_array(SoftDeletes::class, class_uses_recursive($this->modelFqn)) : confirm( - label: 'Does the model use soft deletes?', + label: 'Does the model use soft-deletes?', default: false, )); } diff --git a/packages/tables/docs/03-filters/03-ternary.md b/packages/tables/docs/03-filters/03-ternary.md index 52d6349fc0..16230562df 100644 --- a/packages/tables/docs/03-filters/03-ternary.md +++ b/packages/tables/docs/03-filters/03-ternary.md @@ -71,9 +71,9 @@ TernaryFilter::make('email_verified_at') ) ``` -## Filtering soft deletable records +## Filtering soft-deletable records -The `TrashedFilter` can be used to filter soft deleted records. It is a type of ternary filter that is built-in to Filament. You can use it like so: +The `TrashedFilter` can be used to filter soft-deleted records. It is a type of ternary filter that is built-in to Filament. You can use it like so: ```php use Filament\Tables\Filters\TrashedFilter;