mirror of
https://github.com/filamentphp/filament.git
synced 2026-10-29 18:19:53 +08:00
Support trait-named lifecycle hooks on pages and importers (#20189)
* Support trait-named lifecycle hooks on pages and importers
Lifecycle hooks such as afterCreate() and afterSave() can currently only
be defined directly on the page class. A trait that defines one of these
hooks is silently overridden as soon as the page declares its own,
which makes it impossible for reusable traits or plugins to reliably
hook into the page lifecycle.
callHook() now also invokes {hook}{TraitName}() for every trait used by
the class (recursively), mirroring how Eloquent boots model traits via
boot{TraitName}(). The class's own hook runs first, followed by each
trait hook. Applies to panel pages (BasePage) and importers (Importer).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Test and document trait lifecycle hooks
Co-authored-by: Amp <amp@ampcode.com>
---------
Co-authored-by: jim <jim@townsend-music.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Dan Harrin <git@danharrin.com>
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
co-authored by
Claude Fable 5
Amp
jim
Dan Harrin
parent
d8ef648e60
commit
33d86d4196
@@ -211,6 +211,35 @@ class CreateUser extends CreateRecord
|
||||
|
||||
Alternatively, if you're creating records in a modal action, check out the [Actions documentation](../actions/create#lifecycle-hooks).
|
||||
|
||||
### Defining lifecycle hooks in traits
|
||||
|
||||
To define a lifecycle hook in a trait, suffix the hook name with the trait's name. This follows the `boot{TraitName}()` convention used by Eloquent and the `mount{TraitName}()` convention used by Livewire, allowing reusable traits to hook into the page lifecycle without colliding with hooks defined on the page itself:
|
||||
|
||||
```php
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
trait HandlesDrafts
|
||||
{
|
||||
protected function afterCreateHandlesDrafts(): void
|
||||
{
|
||||
// Runs after the form fields are saved to the database, in addition
|
||||
// to the hook on the page.
|
||||
}
|
||||
}
|
||||
|
||||
class CreateUser extends CreateRecord
|
||||
{
|
||||
use HandlesDrafts;
|
||||
|
||||
protected function afterCreate(): void
|
||||
{
|
||||
// Both lifecycle hooks are called.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The page's own hook is called first, followed by each trait hook. Hooks from traits used by other traits are also called. Trait hooks are called automatically, so you should not also call them from the page's own hook.
|
||||
|
||||
## Halting the creation process
|
||||
|
||||
At any time, you may call `$this->halt()` from inside a lifecycle hook or mutation method, which will halt the entire creation process:
|
||||
|
||||
@@ -195,6 +195,35 @@ class EditUser extends EditRecord
|
||||
|
||||
Alternatively, if you're editing records in a modal action, check out the [Actions documentation](../actions/edit#lifecycle-hooks).
|
||||
|
||||
### Defining lifecycle hooks in traits
|
||||
|
||||
To define a lifecycle hook in a trait, suffix the hook name with the trait's name. This follows the `boot{TraitName}()` convention used by Eloquent and the `mount{TraitName}()` convention used by Livewire, allowing reusable traits to hook into the page lifecycle without colliding with hooks defined on the page itself:
|
||||
|
||||
```php
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
trait HandlesDrafts
|
||||
{
|
||||
protected function afterSaveHandlesDrafts(): void
|
||||
{
|
||||
// Runs after the form fields are saved to the database, in addition
|
||||
// to the hook on the page.
|
||||
}
|
||||
}
|
||||
|
||||
class EditUser extends EditRecord
|
||||
{
|
||||
use HandlesDrafts;
|
||||
|
||||
protected function afterSave(): void
|
||||
{
|
||||
// Both lifecycle hooks are called.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The page's own hook is called first, followed by each trait hook. Hooks from traits used by other traits are also called. Trait hooks are called automatically, so you should not also call them from the page's own hook.
|
||||
|
||||
## Saving a part of the form independently
|
||||
|
||||
You may want to allow the user to save a part of the form independently of the rest of the form. One way to do this is with a [section action in the header or footer](../schemas/sections#adding-actions-to-the-sections-header-or-footer). From the `action()` method, you can call `saveFormComponentOnly()`, passing in the `Section` component that you want to save:
|
||||
|
||||
@@ -120,6 +120,35 @@ class ViewUser extends ViewRecord
|
||||
}
|
||||
```
|
||||
|
||||
### Defining lifecycle hooks in traits
|
||||
|
||||
To define a lifecycle hook in a trait, suffix the hook name with the trait's name. This follows the `boot{TraitName}()` convention used by Eloquent and the `mount{TraitName}()` convention used by Livewire, allowing reusable traits to hook into the page lifecycle without colliding with hooks defined on the page itself:
|
||||
|
||||
```php
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
trait LoadsAuditData
|
||||
{
|
||||
protected function afterFillLoadsAuditData(): void
|
||||
{
|
||||
// Runs after the form fields are populated from the database, in
|
||||
// addition to the hook on the page.
|
||||
}
|
||||
}
|
||||
|
||||
class ViewUser extends ViewRecord
|
||||
{
|
||||
use LoadsAuditData;
|
||||
|
||||
protected function afterFill(): void
|
||||
{
|
||||
// Both lifecycle hooks are called.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The page's own hook is called first, followed by each trait hook. Hooks from traits used by other traits are also called. Trait hooks are called automatically, so you should not also call them from the page's own hook.
|
||||
|
||||
## Authorization
|
||||
|
||||
For authorization, Filament will observe any [model policies](https://laravel.com/docs/authorization#creating-policies) that are registered in your app.
|
||||
|
||||
@@ -970,6 +970,35 @@ class ProductImporter extends Importer
|
||||
}
|
||||
```
|
||||
|
||||
### Defining lifecycle hooks in traits
|
||||
|
||||
To define a lifecycle hook in a trait, suffix the hook name with the trait's name. This follows the `boot{TraitName}()` convention used by Eloquent and the `mount{TraitName}()` convention used by Livewire, allowing reusable traits to hook into an importer's lifecycle without colliding with hooks defined on the importer itself:
|
||||
|
||||
```php
|
||||
use Filament\Actions\Imports\Importer;
|
||||
|
||||
trait LogsImports
|
||||
{
|
||||
protected function afterSaveLogsImports(): void
|
||||
{
|
||||
// Runs after a record is saved to the database, in addition to the
|
||||
// hook on the importer.
|
||||
}
|
||||
}
|
||||
|
||||
class ProductImporter extends Importer
|
||||
{
|
||||
use LogsImports;
|
||||
|
||||
protected function afterSave(): void
|
||||
{
|
||||
// Both lifecycle hooks are called.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The importer's own hook is called first, followed by each trait hook. Hooks from traits used by other traits are also called. Trait hooks are called automatically, so you should not also call them from the importer's own hook.
|
||||
|
||||
Inside these hooks, you can access the current row's data using `$this->data`. You can also access the original row of data from the CSV, before it was [cast](#casting-state) or mapped, using `$this->originalData`.
|
||||
|
||||
The current record (if it exists yet) is accessible in `$this->record`, and the [import form options](#using-import-options) using `$this->options`.
|
||||
|
||||
@@ -8,6 +8,7 @@ use Filament\Actions\ActionGroup;
|
||||
use Filament\Actions\Imports\Models\Import;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Schemas\Components\Component;
|
||||
use Filament\Support\Concerns\CanCallHooks;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Queue\Middleware\WithoutOverlapping;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
@@ -15,6 +16,8 @@ use Illuminate\Validation\ValidationException;
|
||||
|
||||
abstract class Importer
|
||||
{
|
||||
use CanCallHooks;
|
||||
|
||||
// Security: Imports do not perform per-record authorization checks.
|
||||
// Each CSV row is processed by `resolveRecord()`, `fillRecord()`,
|
||||
// and `saveRecord()` without consulting Laravel policies. Add
|
||||
@@ -426,15 +429,6 @@ abstract class Importer
|
||||
return $this->options;
|
||||
}
|
||||
|
||||
protected function callHook(string $hook): void
|
||||
{
|
||||
if (! method_exists($this, $hook)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->{$hook}();
|
||||
}
|
||||
|
||||
public function getImport(): Import
|
||||
{
|
||||
return $this->import;
|
||||
|
||||
@@ -8,6 +8,7 @@ use Filament\Actions\Contracts\HasActions;
|
||||
use Filament\Schemas\Concerns\InteractsWithSchemas;
|
||||
use Filament\Schemas\Contracts\HasRenderHookScopes;
|
||||
use Filament\Schemas\Contracts\HasSchemas;
|
||||
use Filament\Support\Concerns\CanCallHooks;
|
||||
use Filament\Support\Enums\Alignment;
|
||||
use Filament\Support\Enums\Width;
|
||||
use Filament\Support\Exceptions\Halt;
|
||||
@@ -19,6 +20,7 @@ use Livewire\Component;
|
||||
|
||||
abstract class BasePage extends Component implements HasActions, HasRenderHookScopes, HasSchemas
|
||||
{
|
||||
use CanCallHooks;
|
||||
use InteractsWithActions;
|
||||
use InteractsWithSchemas;
|
||||
|
||||
@@ -131,15 +133,6 @@ abstract class BasePage extends Component implements HasActions, HasRenderHookSc
|
||||
throw (new Halt)->rollBackDatabaseTransaction($shouldRollbackDatabaseTransaction);
|
||||
}
|
||||
|
||||
protected function callHook(string $hook): void
|
||||
{
|
||||
if (! method_exists($this, $hook)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->{$hook}();
|
||||
}
|
||||
|
||||
public static function stickyFormActions(bool $condition = true): void
|
||||
{
|
||||
static::$formActionsAreSticky = $condition;
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace Filament\Support\Concerns;
|
||||
|
||||
trait CanCallHooks
|
||||
{
|
||||
protected function callHook(string $hook): void
|
||||
{
|
||||
if (method_exists($this, $hook)) {
|
||||
$this->{$hook}();
|
||||
}
|
||||
|
||||
$calledTraitHooks = [];
|
||||
|
||||
foreach (class_uses_recursive($this) as $trait) {
|
||||
$method = $hook . class_basename($trait);
|
||||
|
||||
if (method_exists($this, $method) && (! in_array($method, $calledTraitHooks, strict: true))) {
|
||||
$this->{$method}();
|
||||
|
||||
$calledTraitHooks[] = $method;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,24 @@ use Filament\Actions\Imports\Importer;
|
||||
use Filament\Actions\Imports\Models\Import;
|
||||
use Filament\Tests\Fixtures\Models\User;
|
||||
use Filament\Tests\TestCase;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
trait TracksImporterLifecycleHooks
|
||||
{
|
||||
protected function beforeValidateTracksImporterLifecycleHooks(): void
|
||||
{
|
||||
$this->lifecycleHookInvocations[] = 'beforeValidateTracksImporterLifecycleHooks';
|
||||
}
|
||||
|
||||
protected function afterSaveTracksImporterLifecycleHooks(): void
|
||||
{
|
||||
$this->lifecycleHookInvocations[] = 'afterSaveTracksImporterLifecycleHooks';
|
||||
}
|
||||
}
|
||||
|
||||
class PlainTestImporter extends Importer
|
||||
{
|
||||
public static function getColumns(): array
|
||||
@@ -36,9 +50,53 @@ class FormulaSafeTestImporter extends Importer
|
||||
}
|
||||
}
|
||||
|
||||
class ImporterWithTraitHooks extends Importer
|
||||
{
|
||||
use TracksImporterLifecycleHooks;
|
||||
|
||||
/**
|
||||
* @var array<string>
|
||||
*/
|
||||
public array $lifecycleHookInvocations = [];
|
||||
|
||||
public static function getColumns(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public static function getCompletedNotificationBody(Import $import): string
|
||||
{
|
||||
return 'Import completed';
|
||||
}
|
||||
|
||||
public function resolveRecord(): ?Model
|
||||
{
|
||||
return new User;
|
||||
}
|
||||
|
||||
public function saveRecord(): void {}
|
||||
|
||||
protected function afterSave(): void
|
||||
{
|
||||
$this->lifecycleHookInvocations[] = 'afterSave';
|
||||
}
|
||||
}
|
||||
|
||||
// Reset the base default after every test so the global toggle cannot leak.
|
||||
afterEach(fn () => Importer::preventFormulaInjection(false));
|
||||
|
||||
it('calls trait lifecycle hooks alongside `Importer` hooks', function (): void {
|
||||
$importer = new ImporterWithTraitHooks(new Import, [], []);
|
||||
|
||||
$importer([]);
|
||||
|
||||
expect($importer->lifecycleHookInvocations)->toBe([
|
||||
'beforeValidateTracksImporterLifecycleHooks',
|
||||
'afterSave',
|
||||
'afterSaveTracksImporterLifecycleHooks',
|
||||
]);
|
||||
});
|
||||
|
||||
describe('`shouldPreventFormulaInjection()`', function (): void {
|
||||
it('defaults to `false`', function (): void {
|
||||
expect(PlainTestImporter::shouldPreventFormulaInjection())->toBeFalse();
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace Filament\Tests\Fixtures\Resources\Posts\Pages\Concerns;
|
||||
|
||||
trait TracksLifecycleHooks
|
||||
{
|
||||
/**
|
||||
* @var array<string>
|
||||
*/
|
||||
public array $lifecycleHookInvocations = [];
|
||||
|
||||
protected function beforeCreateTracksLifecycleHooks(): void
|
||||
{
|
||||
$this->lifecycleHookInvocations[] = 'beforeCreateTracksLifecycleHooks';
|
||||
}
|
||||
|
||||
protected function afterCreateTracksLifecycleHooks(): void
|
||||
{
|
||||
$this->lifecycleHookInvocations[] = 'afterCreateTracksLifecycleHooks';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace Filament\Tests\Fixtures\Resources\Posts\Pages;
|
||||
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
use Filament\Tests\Fixtures\Resources\Posts\Pages\Concerns\TracksLifecycleHooks;
|
||||
use Filament\Tests\Fixtures\Resources\Posts\PostResource;
|
||||
|
||||
class CreatePostWithTraitHooks extends CreateRecord
|
||||
{
|
||||
use TracksLifecycleHooks;
|
||||
|
||||
protected static string $resource = PostResource::class;
|
||||
|
||||
protected function afterCreate(): void
|
||||
{
|
||||
$this->lifecycleHookInvocations[] = 'afterCreate';
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ use Filament\Tests\Fixtures\Resources\Posts\Pages\CreateAnotherPreservingDataPos
|
||||
use Filament\Tests\Fixtures\Resources\Posts\Pages\CreateAnotherPreservingRepeaterPost;
|
||||
use Filament\Tests\Fixtures\Resources\Posts\Pages\CreateAnotherPreservingRepeaterWithDefaultPost;
|
||||
use Filament\Tests\Fixtures\Resources\Posts\Pages\CreatePost;
|
||||
use Filament\Tests\Fixtures\Resources\Posts\Pages\CreatePostWithTraitHooks;
|
||||
use Filament\Tests\Fixtures\Resources\Posts\PostResource;
|
||||
use Filament\Tests\Fixtures\Resources\TicketMessages\TicketMessageResource;
|
||||
use Filament\Tests\Fixtures\Resources\Tickets\Pages\CreateTicket;
|
||||
@@ -477,3 +478,25 @@ it('re-authorizes viewAny on Livewire updates after the initial mount of a creat
|
||||
|
||||
app()->bind(TicketPolicy::class . '::viewAny', fn (): bool => true);
|
||||
});
|
||||
|
||||
it('calls trait lifecycle hooks alongside `CreateRecord` hooks', function (): void {
|
||||
$newData = Post::factory()->make();
|
||||
|
||||
$page = livewire(CreatePostWithTraitHooks::class)
|
||||
->fillForm([
|
||||
'author_id' => $newData->author->getKey(),
|
||||
'content' => $newData->content,
|
||||
'tags' => $newData->tags,
|
||||
'title' => $newData->title,
|
||||
'rating' => $newData->rating,
|
||||
])
|
||||
->call('create')
|
||||
->assertHasNoFormErrors();
|
||||
|
||||
expect($page->instance())
|
||||
->lifecycleHookInvocations->toBe([
|
||||
'beforeCreateTracksLifecycleHooks',
|
||||
'afterCreate',
|
||||
'afterCreateTracksLifecycleHooks',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
namespace Filament\Tests\Support\CanCallHooks\Concerns;
|
||||
|
||||
trait RecordsParentLifecycleHooks
|
||||
{
|
||||
protected function afterSaveRecordsParentLifecycleHooks(): void
|
||||
{
|
||||
$this->lifecycleHookInvocations[] = 'afterSaveRecordsParentLifecycleHooks';
|
||||
}
|
||||
}
|
||||
|
||||
trait RecordsNestedLifecycleHooks
|
||||
{
|
||||
protected function afterSaveRecordsNestedLifecycleHooks(): void
|
||||
{
|
||||
$this->lifecycleHookInvocations[] = 'afterSaveRecordsNestedLifecycleHooks';
|
||||
}
|
||||
}
|
||||
|
||||
trait RecordsLifecycleHooks
|
||||
{
|
||||
use RecordsNestedLifecycleHooks;
|
||||
|
||||
protected function beforeCreateRecordsLifecycleHooks(): void
|
||||
{
|
||||
$this->lifecycleHookInvocations[] = 'beforeCreateRecordsLifecycleHooks';
|
||||
}
|
||||
|
||||
protected function afterSaveRecordsLifecycleHooks(): void
|
||||
{
|
||||
$this->lifecycleHookInvocations[] = 'afterSaveRecordsLifecycleHooks';
|
||||
}
|
||||
}
|
||||
|
||||
namespace Filament\Tests\Support\CanCallHooks\First;
|
||||
|
||||
trait RecordsDuplicateLifecycleHooks
|
||||
{
|
||||
protected function beforeSaveRecordsDuplicateLifecycleHooks(): void
|
||||
{
|
||||
$this->lifecycleHookInvocations[] = 'beforeSaveRecordsDuplicateLifecycleHooks';
|
||||
}
|
||||
}
|
||||
|
||||
namespace Filament\Tests\Support\CanCallHooks\Second;
|
||||
|
||||
trait RecordsDuplicateLifecycleHooks {}
|
||||
|
||||
namespace Filament\Tests\Support;
|
||||
|
||||
use Filament\Support\Concerns\CanCallHooks;
|
||||
use Filament\Tests\Support\CanCallHooks\Concerns\RecordsLifecycleHooks;
|
||||
use Filament\Tests\Support\CanCallHooks\Concerns\RecordsParentLifecycleHooks;
|
||||
use Filament\Tests\Support\CanCallHooks\First\RecordsDuplicateLifecycleHooks as FirstRecordsDuplicateLifecycleHooks;
|
||||
use Filament\Tests\Support\CanCallHooks\Second\RecordsDuplicateLifecycleHooks as SecondRecordsDuplicateLifecycleHooks;
|
||||
use Filament\Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
it('calls class and recursive trait hooks in order with `callHook()`', function (): void {
|
||||
$caller = new HookCaller;
|
||||
|
||||
$caller->callHookByName('afterSave');
|
||||
|
||||
expect($caller->lifecycleHookInvocations)->toBe([
|
||||
'afterSave',
|
||||
'afterSaveRecordsParentLifecycleHooks',
|
||||
'afterSaveRecordsLifecycleHooks',
|
||||
'afterSaveRecordsNestedLifecycleHooks',
|
||||
]);
|
||||
});
|
||||
|
||||
it('calls trait hooks when the class hook does not exist with `callHook()`', function (): void {
|
||||
$caller = new HookCaller;
|
||||
|
||||
$caller->callHookByName('beforeCreate');
|
||||
|
||||
expect($caller->lifecycleHookInvocations)->toBe([
|
||||
'beforeCreateRecordsLifecycleHooks',
|
||||
]);
|
||||
});
|
||||
|
||||
it('calls duplicate trait hook methods once with `callHook()`', function (): void {
|
||||
$caller = new HookCaller;
|
||||
|
||||
$caller->callHookByName('beforeSave');
|
||||
|
||||
expect($caller->lifecycleHookInvocations)->toBe([
|
||||
'beforeSaveRecordsDuplicateLifecycleHooks',
|
||||
]);
|
||||
});
|
||||
|
||||
class ParentHookCaller
|
||||
{
|
||||
use CanCallHooks;
|
||||
use RecordsParentLifecycleHooks;
|
||||
|
||||
/**
|
||||
* @var array<string>
|
||||
*/
|
||||
public array $lifecycleHookInvocations = [];
|
||||
|
||||
public function callHookByName(string $hook): void
|
||||
{
|
||||
$this->callHook($hook);
|
||||
}
|
||||
}
|
||||
|
||||
class HookCaller extends ParentHookCaller
|
||||
{
|
||||
use FirstRecordsDuplicateLifecycleHooks;
|
||||
use RecordsLifecycleHooks;
|
||||
use SecondRecordsDuplicateLifecycleHooks;
|
||||
|
||||
protected function afterSave(): void
|
||||
{
|
||||
$this->lifecycleHookInvocations[] = 'afterSave';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user