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:
oddvalue
2026-09-04 10:19:44 +01:00
committed by GitHub
co-authored by Claude Fable 5 Amp jim Dan Harrin
parent d8ef648e60
commit 33d86d4196
12 changed files with 387 additions and 18 deletions
+29
View File
@@ -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:
+29
View File
@@ -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:
+29
View File
@@ -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.
+29
View File
@@ -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`.
+3 -9
View File
@@ -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;
+2 -9
View File
@@ -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',
]);
});
+120
View File
@@ -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';
}
}