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.
Related Docs
| 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 migratephp artisan wire:tenants:create --all --no-migratephp artisan wire:tenants:migrate # every tenantphp 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.