Skip to content

Actions & Hooks

Actions are buttons in the toolbar. Hooks are lifecycle callbacks that run at specific points in a record's life.

Custom Actions

Add buttons to the toolbar by overriding getActions():

use System\Action;
use System\Auth;

public function getActions(): array
{
    return [
        ...parent::getActions(),    // keep built-in actions (save, delete, etc.)

        (new Action('send_email', 'Send Email'))
            ->withIcon('envelope')
            ->withGroup('Communication')
            ->showWhen(fn($record) => $record->email && !$record->isLocked())
            ->withEnabled(fn($record) => !$record->isSoftDeleted()),
    ];
}

Then implement the action as a public method with the same name:

1
2
3
4
5
public function send_email(): static
{
    Mail::send($this->email, '', 'Hello', 'Welcome!');
    return $this->notify('Email sent');   // shows a toast message
}

Warning

The action method must return $this. Use ->notify('message') to queue a toast notification.

Action Options

1
2
3
4
5
6
7
8
(new Action('key', 'Label'))
    ->withIcon('icon-name')                  // Phosphor icon
    ->withGroup('Group Name')                // toolbar group label
    ->showWhen(fn($m) => $m->isNew())        // visibility condition
    ->withEnabled(fn($m) => !$m->isLocked()) // enabled condition
    ->withSpinner()                          // show loading spinner on click
    ->asAsync()                              // fire-and-forget (implies spinner)
    ->withHref('/adminer/', newTab: true)     // open URL instead of submit
1
2
3
4
5
(new Action('adminer', 'Database Admin'))
    ->withIcon('database')
    ->withEnabled(fn() => Auth::isSuperAdmin())
    ->withGroup('Tools')
    ->withHref('/adminer/', newTab: true)

Lifecycle Hooks

Override these methods in your model to run custom logic at specific lifecycle points.

beforeNew()

Called when a blank record is created for the "New" form. Use it to set default values.

protected function beforeNew(): void
{
    $this->status = 'draft';
    $this->owner_id = Auth::userId();

    // Initialize a todo list
    $this->getProperty('tasks')?->fromTexts([
        'Review requirements',
        'Write implementation',
        'Run tests',
    ]);
}

beforeInsert() / afterInsert()

Before/after the first save (insert). Use beforeInsert() for validation:

protected function beforeInsert(): void
{
    if (empty($this->title)) {
        throw new \InvalidArgumentException('Title is required');
    }
}

protected function afterInsert(): void
{
    // Record now has an ID
    $this->sendWelcomeNotification();
}

Tip

Throwing InvalidArgumentException in any before* hook aborts the operation and shows the exception message as an error toast.

beforeUpdate() / afterUpdate()

Before/after saving an existing record:

1
2
3
4
5
protected function afterUpdate(): void
{
    // Notify watchers about the update
    $this->sendEmailNotification("Record updated: {$this->title}");
}

beforeSave() / afterSave()

Runs on every save, whether insert or update:

1
2
3
4
5
protected function beforeSave(): void
{
    // Normalize data before any save
    $this->reference = strtoupper(trim($this->reference ?? ''));
}

afterChangeState(?State $from, State $to)

Runs after a state machine transition:

1
2
3
4
5
6
7
8
use System\Model\State;

protected function afterChangeState(?State $from, State $to): void
{
    if ($to->name === 'approved') {
        $this->notifyApprovers();
    }
}

beforeCheckout() / afterCheckout()

Before/after a record is locked for editing:

1
2
3
4
protected function afterCheckout(): void
{
    // Log who locked the record
}

beforeCheckin() / afterCheckin()

Before/after a record is unlocked:

1
2
3
4
protected function beforeCheckin(): void
{
    $this->sent = true;   // mark as sent when checked in
}

beforeTrash() / afterTrash()

Before/after soft-deleting:

1
2
3
4
5
6
protected function beforeTrash(): void
{
    if ($this->status === 'active') {
        throw new \InvalidArgumentException('Cannot delete active records');
    }
}

beforeDelete() / afterDelete()

Before/after permanent deletion.

beforeRender()

Called before the form is rendered. Use for dynamic field visibility:

1
2
3
4
5
protected function beforeRender(): void
{
    $this->getProperty('admin_notes')?->setVisible(Auth::isSuperAdmin());
    $this->readonlyAfterInsert('reference');
}

Hook Execution Order

For a new record being saved:

  1. beforeNew() → defaults set
  2. fromPost() → POST data applied
  3. beforeSave() → runs first
  4. beforeInsert() → validation
  5. Database INSERT
  6. afterInsert() → ID now available
  7. afterSave() → runs last
  8. PRG redirect with success toast

For an existing record being saved:

  1. fromPost() → POST data applied
  2. beforeSave()
  3. beforeUpdate()
  4. Database UPDATE
  5. afterUpdate()
  6. afterSave()
  7. PRG redirect

Application-Level Hooks

Define app-wide hooks in App/Hooks.php:

<?php

namespace App;

use System\HookContext;

class Hooks extends \System\Hooks
{
    // Runs on every authenticated request
    public function onEveryRequest(HookContext $ctx): void
    {
        // Check for expired licenses, etc.
    }

    // Runs once per period/scope
    public function onFirstRequest(HookContext $ctx): void
    {
        if ($ctx->period === 'day' && $ctx->scope === 'app') {
            $this->generateDailyReminders($ctx);
        }

        if ($ctx->period === 'day' && $ctx->scope === 'user') {
            $ctx->flashSuccess("Good morning, {$ctx->user->displayname}!");
        }
    }

    // Runs before model registration
    public function bootstrap(): void
    {
        $this->setupPermissions();
    }
}

HookContext

The $ctx parameter provides:

Property/Method Description
$ctx->period 'always', 'day', 'week', 'month', 'year'
$ctx->scope 'app', 'group', 'user'
$ctx->user Current User object
$ctx->userId Current user ID
$ctx->database Database instance
$ctx->flashSuccess(msg) Show green toast
$ctx->flashInfo(msg) Show blue toast
$ctx->flashWarning(msg) Show yellow toast
$ctx->flashError(msg) Show red toast
$ctx->notifyUser(userId, msg, type) In-app notification
$ctx->notifyGroup(groupName, msg, type) Notify a group
$ctx->notifyAll(msg, type) Notify everyone