scopedUnique() and scopedExists() rules

This commit is contained in:
Dan Harrin
2025-06-02 11:57:09 +01:00
parent 9a57824dc8
commit ac09d2c62c
13 changed files with 185 additions and 30 deletions
+4 -4
View File
@@ -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:
+6 -6
View File
@@ -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.
@@ -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;
+17
View File
@@ -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`:
+3 -3
View File
@@ -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
+3 -3
View File
@@ -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()
<UtilityInjection set="actions" version="4.x">These hook functions can inject various utilities as parameters.</UtilityInjection>
## 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.
+1 -1
View File
@@ -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;
+51
View File
@@ -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
@@ -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;
@@ -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,
);
@@ -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,
));
}
@@ -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,
));
}
@@ -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;