Use the Gate adapter
Motivation
Your app already calls $user->can(...), @can(...) in Blade, and $this->authorize(...) in controllers.
The Gate adapter makes those calls reach the central PDP for the abilities that belong to IAM — so you
centralize policy without touching call sites.
How it works
The adapter registers a single Gate::before callback. For each ability it decides whether it owns the
ability; if so it returns IAM’s binding verdict, otherwise it returns null to let Laravel’s local
Gates/policies decide.
Gate::before semantics: a non-null return short-circuits the gate. So when the adapter owns an ability,
its true/false is final; when it returns null, your existing policies run as usual.
Enable / disable
It’s registered automatically when iam-client.gate.enabled is true (the default). Set it to false to
leave Laravel’s Gate untouched — for example while running the
spatie migration bridge in shadow mode, where the adapter’s
enforcement would corrupt the decision diffing.
Choosing what IAM owns: intercept
// config/iam-client.php
'gate' => [
'enabled' => true,
'intercept' => 'namespaced', // or 'all'
],
intercept |
Owns | Leaves to Laravel |
|---|---|---|
namespaced (default) |
abilities containing : (e.g. billing:invoices.update) |
everything else — your UserPolicy, PostPolicy, ad-hoc gates |
all |
every ability | nothing |
With namespaced, your local policies keep running for non-namespaced abilities like update-post. Only
app:permission-style abilities are centralized — which is exactly what you want during a gradual rollout.
Scope to your apps: app_keys (IAM-40)
namespaced still owns every app:permission ability — including one from an unrelated package (say a
logging tool’s log:viewer). IAM doesn’t know that permission, so it would deny it and override the local
Gate. List the app prefixes IAM actually owns to fence this off:
'gate' => [
'intercept' => 'namespaced',
'app_keys' => ['warehouse', 'billing'], // env: IAM_CLIENT_APP_KEYS=warehouse,billing
],
Now the adapter owns warehouse:* and billing:* and returns null for any other namespaced ability, so
log:viewer falls through to the local Gate. Empty app_keys (the default) keeps the historic behavior:
all namespaced abilities are IAM’s.
Passing a resource
The first gate argument becomes the decision’s resource. IAM-24: the adapter resolves both a scalar and
an Eloquent model — a model is keyed to (string) $model->getKey(), exactly as the
iam.can middleware does, so a per-resource check on the Gate path is never
silently widened into a global one:
$user->can('warehouse:stock.adjust', 'wh_milan'); // scalar → resource 'wh_milan'
$user->can('billing:invoices.update', $invoice); // model → resource (string) $invoice->getKey()
If the first argument is not a model and not a non-empty scalar — an array, null, or a scalar that casts to
an empty string (e.g. false or '') — no resource is sent and the check is evaluated without a bound
resource.
Worked example
// A controller — no IAM-specific code, just Laravel's authorize()
public function destroy(Invoice $invoice)
{
// The model is keyed to its id automatically (IAM-24); passing (string) $invoice->getKey() also works.
$this->authorize('billing:invoices.delete', $invoice);
$invoice->delete();
return back();
}
{{-- A Blade view --}}
@can('reports:view')
<a href="/reports">Reports</a>
@endcan
Both reach the PDP because the abilities are namespaced. A plain @can('edit-profile') does not — it stays
with your local policy.
Gotchas
$user->can('billing:invoices.update', $invoice) scopes the decision to (string) $invoice->getKey(). If
your ReBAC tuples key the resource by something other than the primary key (a slug, a UUID column), pass that
value explicitly as a string instead of the model. A non-scalar key (or a non-scalar, non-model argument)
resolves to no resource, so the permission is evaluated globally — pass an explicit string when you need
per-resource scoping in that case.
See also
- Protect routes with iam.can
- granted() vs allowed — why the adapter gates on
granted(). - Middleware & Gate reference