Skip to content

Table Hooks

Table hooks let you modify a table's query, columns, and data from outside the table class. They're registered in a service provider and can be scoped to a specific table class or applied globally to all tables.

This is different from Frontend Hooks which connect PHP tableSettings to JavaScript behaviour. Table hooks operate entirely on the PHP side, modifying the data before it reaches the frontend.

Use Cases

  • Adding query scopes from outside the table (soft-delete filtering, tenant scoping)
  • Eager-loading relations that the table doesn't know about
  • Adding hidden data columns from a package or service
  • Transforming row data (uppercasing, formatting, computed values)
  • Enriching rows with data that isn't column-related
  • Applying cross-cutting concerns to all tables at once (multi-tenancy, audit logging)

Registration

Register hooks in your AppServiceProvider::boot() or any service provider.

Class-Specific Hooks

Scoped to a single table class. A hook registered on ServerTable::class won't affect any other table.

php
use Forjed\InertiaTable\Table;
use Forjed\InertiaTable\Column;
use App\Tables\ServerTable;
use Illuminate\Support\Collection;

// In AppServiceProvider::boot()

Table::beforeQuery(ServerTable::class, function ($query, array &$columns) {
    // Modify the query and/or columns before execution
});

Table::afterData(ServerTable::class, function (Collection $rows) {
    // Transform the mapped row data
});

Global Hooks

Fire on every table in your application. Register once, apply everywhere.

php
use Forjed\InertiaTable\Table;
use Illuminate\Support\Collection;

// In AppServiceProvider::boot()

Table::globalBeforeQuery(function ($query, array &$columns) {
    // Runs before every table's query
    $query->where('tenant_id', auth()->user()->tenant_id);
});

Table::globalAfterData(function (Collection $rows) {
    // Runs after every table's data mapping
});

Global hooks use the same callback signature as class-specific hooks. The table class is also passed as a trailing parameter, which you can opt into if you need conditional logic:

php
Table::globalBeforeQuery(function ($query, array &$columns, string $tableClass) {
    if ($tableClass === AuditLogTable::class) {
        return; // skip tenant scoping for audit logs
    }

    $query->where('tenant_id', auth()->user()->tenant_id);
});

beforeQuery

Runs after columns() is resolved but before search, sorting, and query execution. The callback receives the query builder (mutable) and the columns array (by reference).

Modifying the Query

php
// Add a scope
Table::beforeQuery(ServerTable::class, function ($query, array &$columns) {
    $query->whereNull('deleted_at');
});

// Eager-load a relation
Table::beforeQuery(ServerTable::class, function ($query, array &$columns) {
    $query->with('owner');
});

// Add a join
Table::beforeQuery(ServerTable::class, function ($query, array &$columns) {
    $query->join('teams', 'servers.team_id', '=', 'teams.id')
          ->select('servers.*', 'teams.name as team_name');
});

Adding Columns

php
Table::beforeQuery(ServerTable::class, function ($query, array &$columns) {
    // Add a hidden data column with a computed value
    $columns[] = Column::data('owner_name', fn ($m) => $m->owner?->name);

    // Eager-load the relation it depends on
    $query->with('owner');
});

Columns added via hooks behave identically to columns defined in columns() - they're serialized to the frontend, their values are resolved from models, and sortable columns can be sorted.

WARNING

Adding a column does not make it searchable. Search fields are defined separately via the searchable() method on the table class.

afterData

Runs after models have been mapped to row arrays. The callback receives a Collection of row arrays. Return a modified Collection, or null to keep the original data.

Transforming Values

php
Table::afterData(ServerTable::class, function (Collection $rows) {
    return $rows->map(function (array $row) {
        $row['name'] = strtoupper($row['name']);
        return $row;
    });
});

Adding Computed Fields

php
Table::afterData(ServerTable::class, function (Collection $rows) {
    return $rows->map(function (array $row) {
        $row['full_address'] = "{$row['city']}, {$row['state']} {$row['zip']}";
        return $row;
    });
});

Fields added by afterData are included in the row data sent to the frontend but won't have a column definition. They're accessible in cellRenderers, actions, onRowClick, and any custom component that receives the row object.

Returning Null

If your hook doesn't need to modify the data in certain conditions, return null to skip:

php
Table::afterData(ServerTable::class, function (Collection $rows) {
    if (! auth()->user()->isAdmin()) {
        return null; // no changes
    }

    return $rows->map(function (array $row) {
        $row['internal_notes'] = $row['admin_notes'] ?? '';
        return $row;
    });
});

Multiple Hooks

Multiple hooks on the same table stack in registration order:

php
// Hook 1: filter to active only
Table::beforeQuery(ServerTable::class, function ($query, array &$columns) {
    $query->where('status', 'active');
});

// Hook 2: eager-load owner (runs after hook 1)
Table::beforeQuery(ServerTable::class, function ($query, array &$columns) {
    $query->with('owner');
});

Inheritance

Hooks registered on a parent table class also apply to all child classes:

php
// This hook applies to ServerTable and any class that extends it
Table::beforeQuery(ServerTable::class, function ($query, array &$columns) {
    $query->whereNull('deleted_at');
});

Pipeline

Hooks integrate into the table pipeline at four points. Global hooks run first at each stage, then class-specific hooks:

1. query()                             <-- table's own default modifications
2. $columns = $this->columns()
3. Global beforeQuery hooks            <-- cross-cutting query/column changes
4. Class-specific beforeQuery hooks    <-- table-scoped query/column changes
5. applySearch()
6. applySorting($columns)
7. Query execution (paginate/get)
8. Map models to row arrays
9. Global afterData hooks              <-- cross-cutting data transforms
10. Class-specific afterData hooks     <-- table-scoped data transforms
11. Return response

This applies to all output methods: paginate(), simplePaginate(), toArray(), and toCollection().

Clearing Hooks

php
// Clear ALL hooks (global + class-specific)
Table::clearHooks();

// Clear only a specific table's hooks (global hooks are preserved)
Table::clearHooks(ServerTable::class);

// Clear only global hooks (class-specific hooks are preserved)
Table::clearGlobalHooks();

Testing

Clear hooks between tests to prevent leakage:

php
use Forjed\InertiaTable\Table;

afterEach(function () {
    Table::clearHooks(); // clears both global and class-specific hooks
});

Octane / Swoole

The hook registry is bound as a scoped singleton in the Laravel container, so it resets automatically between requests in long-running processes like Laravel Octane. No additional configuration needed.