⌘K

Start Here

Authorization

Every refusal goes through Laravel's Gate — what a table, a form, an action and a page each ask it, and when.

On this page

Wire uses Laravel Gate and policies. This keeps authorization compatible with native Laravel policies, Spatie Permission, and packages that register abilities into Gate.

Shared Component Rules

Columns, filters, actions, fields, and widgets can use the shared authorization methods when the component supports visibility or authorization.

Action::make('approve')
->label('Approve')
->authorize('approve')
->action(fn (Order $record) => $record->approve());
 
TextColumn::make('internal_note')
->label('Internal note')
->permission('orders.internal-notes.view');
 
SelectFilter::make('department_id')
->authorizeUsing(fn (User $user) => $user->is_admin);

Resolution order:

Rule Behavior
No authorization configured Allowed
No authenticated user Denied
authorizeUsing() Custom callback has priority
authorize() Checks a Laravel Gate ability
permission() Checks a permission string through Gate

Per-record authorization

The authorizeUsing() callback receives the authenticated user and, where the surface has one, the row's record — so authorization can be scoped per record:

Action::make('approve')
->authorizeUsing(fn (User $user, $record) => $user->id === $record?->manager_id)
->action(fn (Order $record) => $record->approve());

The record is present for row actions; it is null for record-less surfaces (structural column/filter visibility, fields, widgets), so a one-argument closure fn ($user) => … stays valid everywhere.

This governs whether the whole column/action exists structurally (evaluated once). To hide or redact a single cell per row — e.g. show salary only on records the user may see — use the column's visibleForRecord() instead, which runs at cell render with that row's record:

TextColumn::make('salary')
->visibleForRecord(fn ($record) => auth()->user()->can('viewSalary', $record));

Table Policies

Enable policy checks on a table with authorize().

use NyonCode\WireTable\Table;
 
public function table(Table $table): Table
{
return $table
->model(Order::class)
->authorize()
->columns([
// ...
]);
}

Wire checks these policy methods when they are needed:

Table capability Policy ability
Create record create
Update record update
Delete record delete
View record view

Table Overrides

Use overrides when a table needs rules that are different from the model policy.

return $table
->model(Order::class)
->authorize()
->authorizeCreate(fn () => auth()->user()?->can('create', Order::class) ?? false)
->authorizeUpdate(fn (Order $record) => ! $record->is_locked)
->authorizeDelete(fn (Order $record) => $record->status === 'draft')
->authorizeView(fn (Order $record) => $record->tenant_id === auth()->user()?->tenant_id);

Each override accepts a boolean or a closure.

Inline Editing

Editable columns can require a Gate ability for inline editing.

TextInputColumn::make('price')
->authorizeInline('orders.update-price');

If the user does not pass the ability check, the column remains visible but the inline edit is denied.

Actions

Actions can be hidden or denied with Gate abilities, permission strings, or custom callbacks.

Action::make('refund')
->label('Refund')
->authorize('refund')
->visible(fn (Order $record) => $record->is_paid)
->requiresConfirmation()
->action(fn (Order $record) => $record->refund());

For simple permission strings:

Action::make('export')
->permission('orders.export')
->action(fn () => $this->exportTable());

Forms

Forms can use model policies for create and update.

use NyonCode\WireForms\Forms\Form;
 
public function form(Form $form): Form
{
return $form
->model($this->user ?? User::class)
->authorize()
->schema([
// ...
]);
}

When authorize() is enabled:

Form state Policy ability
Model class or unsaved model create
Existing model instance update

If denied, the form is read-only and cannot be saved.

For custom form rules:

return $form
->model($this->user)
->authorizeUsing(fn (User $user) => $user->hasRole('editor'))
->schema([
// ...
]);

Sortable

Sortable operations should be protected in your Livewire component hooks.

public function beforeRowsReordered(array $orderedIds): void
{
$this->authorize('reorder', Task::class);
}

See Sortable Row Reordering for lifecycle hooks.

Document What It Covers
Core Actions Row, bulk, header actions and modal actions
Table Overview Table setup and table-level API
Forms Overview Form setup and save behavior
Audit Log Recording model changes after authorization succeeds

Multi-tenancy

Off by default — most applications have one tenant, and scoping them would be a WHERE clause bought for nothing. Once on it is strict.

A multi-tenant application is three pieces, and each has a page of its own:

Piece What it does Where
The tenant zone Puts the company in the URL, checks the person against it, enters it, carries it into every link Tenancy
The data Scopes every tenant-owned model to the company entered — rows or a database of its own this section
The companies Registering one, its profile, inviting and managing its members Companies

The zone and the data are enough; the companies module is the screens an application would otherwise write itself.

// config/wire-core.php
'tenancy' => [
'enabled' => true,
'column' => 'tenant_id',
'isolation' => 'column', // or a class implementing IsolatesTenants
],

The current tenant is whatever was entered (see below), and nothing entered means no tenant — with tenancy on, an empty page. An application with no tenant in the URL — every person in exactly one company, say — answers the question another way: it binds a resolver of its own, which replaces the default entirely:

use NyonCode\WireCore\Core\Tenancy\Contracts\TenantResolver;
 
app()->bind(TenantResolver::class, fn () => new class implements TenantResolver {
public function resolve(): int|string|null
{
return auth()->user()?->tenant_id;
}
});

Then mark the models that belong to a tenant:

use NyonCode\WireCore\Core\Tenancy\Concerns\BelongsToTenant;
 
class Invoice extends Model
{
use BelongsToTenant;
}

Opt-in per model, because the framework cannot know which of your tables are tenant-owned, and guessing would be a guess about who may see what.

Entering a tenant

CurrentTenant holds the tenant being worked in, and the default resolver reads it — so entering a tenant is all it takes for every scoped model to follow. A tenant zone's middleware does it for a request; a job, a command, a seeder or a test does it with runAs():

use NyonCode\WireCore\Core\Tenancy\Tenancy;
 
app(Tenancy::class)->runAs($company, function (Company $company) {
Invoice::query()->where('due_at', '<', now())->each->remind();
});

runAs() restores whatever was current before — another tenant, or none — even when the callback throws, and nested calls unwind in order. The holder is scoped: an Octane request and a queued job each start with no tenant, so one that forgets to enter sees no rows rather than the previous job's.

Queued work carries its tenant. A job dispatched inside a tenant records the tenant's class and key in its payload, and the worker enters it before handle() — so a notification, an export or an import started in Acme runs in Acme, on a worker that knows nothing of the request. A job dispatched outside any tenant runs outside one and, by the fail-safe, sees no rows. A job whose tenant has been deleted in the meantime fails rather than running in none. A sync job runs inside the request, and the request's own tenant is handed back when it finishes.

runAs($company, fn () => SendReport::dispatch()) works too. dispatch() returns a PendingDispatch that queues when it is destroyed, and returned out of the callback it would be destroyed after the tenant is left — so runAs() drops it while still inside, the job is queued in the tenant, and the call returns null.

How tenants are kept apart is isolation. column — one database, the tenant column — is the default and needs nothing entered beyond the tenant itself; a class implementing IsolatesTenants (enter(Model $tenant), leave()) is how another strategy plugs in, a database per tenant among them.

A database per tenant

When a contract asks for tenants kept apart by more than a WHERE clause, set isolation to database. Each tenant then has a database of its own, and the connection is the boundary rather than a column:

// config/wire-core.php
'tenancy' => [
'enabled' => true,
'isolation' => 'database',
'model' => App\Models\Company::class,
'database' => [
'connection' => 'tenant', // defined in config/database.php
'name' => 'tenant_{key}', // {key}, {slug}; a file path for SQLite
'admin_connection' => null, // who runs CREATE DATABASE
'migrations' => 'database/migrations/tenant',
],
],

Tenant-owned models use BelongsToTenantDatabase instead of BelongsToTenant — no column and no scope; they are read through the tenant connection, which entering a tenant points at that tenant's database. Companies, users and who belongs where stay on the application's own connection.

php artisan wire:tenants:create acme globex # create and migrate
php artisan wire:tenants:create --all --no-migrate
php artisan wire:tenants:migrate # every tenant
php artisan wire:tenants:migrate --tenant=acme --fresh --seed

The database is created through the admin connection's schema builder — a CREATE DATABASE on MySQL and PostgreSQL, an empty file on SQLite — so that connection's user needs the right to create one. With no tenant entered the tenant connection names no database and the first query throws: the same direction as the column scope's empty result, and louder. Everything else — runAs(), the tenant zone, queued jobs — works the same under either.

The fail-safe

Tenancy on with no tenant resolved returns nothing, never everything.

That is the whole security story in one line. Every ordinary state produces a null tenant — before login, on a queue worker, in a console command — so a scope that read null as "no constraint" would hand every row to every one of them. The scope constrains to 0 = 1 instead.

It is deliberately not where tenant_id is null either: a row nobody owns would then be visible to everybody, which is the same leak wearing a different shape.

What is scoped

A global scope, not a plugin hook — and that choice is the point. A hook covers one read path, and only while a plugin manager happens to be bound. A global scope covers every query Eloquent builds:

Scoped
Invoice::query(), a table listing, a relation yes
Invoice::find($id) in your own controller yes
->update() and ->delete() yes
A queued job resolving records by key yes
Invoice::create() attributed to the current tenant

A create with no tenant resolved throws rather than writing a row with a null tenant — that row would be invisible to every scoped query afterwards, so the user's work would be gone with nothing said. An explicitly set tenant column is left alone, for a seeder or a deliberate cross-tenant move.

The column is qualified, because a scoped model is routinely joined and a joined table commonly has a tenant_id of its own.

Reading across tenants

Invoice::acrossAllTenants()->count();

Verbose on purpose, and easy to grep for: an admin report or a console command has a real need, and a review should be able to find every place that claimed one.

A source Eloquent cannot scope

A non-Eloquent DataSource — a CollectionDataSource, an API-backed source — builds no Eloquent query, so no global scope reaches it. Wrap it instead:

use NyonCode\WireCore\Core\Tenancy\TenantScopedDataSource;
 
$source = new TenantScopedDataSource(new CollectionDataSource($rows), app(Tenancy::class));

It constrains the plan, not the rows that come back, which is what makes it safe on every method rather than only on the one that returns rows: count() and paginate() are answered by the source without ever handing rows over, and a source that cannot honour the filter refuses out loud (UnsupportedQueryAspectException) instead of returning rows nobody constrained.

resolveRecord() takes a key rather than a plan, so there the record is fetched and then checked — without which a tenant reaches another tenant's row by typing its id into a URL.

The fail-safe is the one TenantScope has: tenancy on with no tenant resolved answers with nothing. Tenancy off delegates untouched, so wrapping a source costs a single-tenant application nothing.

Pass a column as the third argument where the source names its tenant something other than the configured column.