livewire #10319: [4.x] Fix authorization denied with class name as the only parameter on `#[Authorize]` attribute
Contributed by ghabriel25
Part of the livewire v4.4.7 release
Category: Fix (PR #10319, fixes #10318)
Pull request #10319 fixes a confusing footgun in Livewire 4's #[Authorize] attribute: passing a model or resource class name as the only argument — #[Authorize(Post::class)] — always resulted in a 403 Forbidden, even though the equivalent $this->authorize(Post::class) call inside the very same action worked fine. The fix routes the attribute through Laravel's AuthorizesRequests machinery so the ability name is guessed from the action's method name, exactly like the trait-based helper you already know. Only one source file changed: src/Features/SupportAuthorization/BaseAuthorize.php. If you're upgrading from v3.8.10, here's what you need to know.
What changed
BaseAuthorize now uses Illuminate\Foundation\Auth\Access\AuthorizesRequests and delegates to $this->authorize() instead of calling Gate::authorize() directly. It also overrides parseAbilityAndArguments() so that when the "ability" is actually a class string (or backed enum), the ability name is guessed from the attribute's target method — your action — rather than from Laravel's default backtrace, which inside the attribute would report the internal call() method instead of createPost:
protected function parseAbilityAndArguments($ability, $arguments)
{
$ability = enum_value($ability);
if (is_string($ability) && ! str_contains($ability, '\\')) {
return [$ability, $arguments];
}
return [$this->normalizeGuessedAbilityName($this->getName()), $ability];
}
In plain terms: if the ability is a normal string like 'create', it is used as-is. If it's a class name like Post::class (contains \), Livewire swaps in the action's method name as the ability and passes the class along as the authorization argument. Usage where you pass an argument (e.g. #[Authorize('update', 'post')]) is untouched.
Why class-name-only authorization failed before
Laravel has a long-standing naming convention: when you call $this->authorize(SomeClass::class) without naming an ability, AuthorizesRequests guesses the ability from the calling method name. Given this policy:
class PostPolicy
{
public function createPost(User $user): bool
{
return true;
}
}
This works as expected:
public function createPost()
{
$this->authorize(Post::class); // ✅ guessed ability: "createPost"
}
But before this PR, BaseAuthorize::call() bypassed all of that:
if (is_null($this->argument)) {
Gate::authorize($this->ability); // "App\Models\Post" used as the ability
return;
}
No guessing happened, so Gate looked for a gate or policy ability literally named App\Models\Post, found none, and denied the request. Even Laravel's built-in backtrace-based guessing couldn't save it, because the stack frame above the Gate call is the attribute's internal call() method — not your createPost() action. The result was an asymmetric experience: identical intent, opposite outcomes depending on whether you used the attribute or the trait method.
What works now
The attribute and the trait method behave identically:
// Before — always 403
#[Authorize(Post::class)] // ❌
public function createPost() { /* ... */ }
// After — guessed ability "createPost", checked against PostPolicy or a named gate
#[Authorize(Post::class)] // ✅
public function createPost() { /* ... */ }
Plain string abilities and named gates work as before, and class-name-only attributes now line up with Laravel's resource-style naming too:
Gate::define('create', fn (User $user) => true);
class Posts extends Component
{
#[Authorize(Post::class)]
public function create() { /* ... */ } // ✅ ability "create"
}
The PR ships with three new tests covering exactly these paths: a defined gate with a matching name, a policy whose method matches the action, and Laravel's resource ability mapping (a store() action resolving to the create policy ability).
Upgrade impact for v3.8.10 users
For the vast majority of applications, there is no action required — if you were using #[Authorize(SomeClass::class)], it simply starts working (or, if you hit the bug and gave up on the attribute, you can now switch back to it). Two edge cases are worth a quick check:
- If you worked around the bug by defining a gate keyed by the fully-qualified class name (e.g.
Gate::define(Post::class, ...)), that workaround is no longer consulted for class-name attributes, since the ability is now guessed from the action name. Replace it with a policy method or gate named after the action, or use an explicit string ability like#[Authorize('create')]. - Behavior of
#[Authorize('ability', 'argument')], enum abilities, and argument resolution (method parameters first, then component properties) is unchanged.
Takeaways
#[Authorize(Post::class)]now guesses the ability from the action's method name, matching$this->authorize(Post::class)exactly.- Works with named gates, policy methods, and Laravel's resource mapping (
store→create,edit→update, etc.). - Argument-based authorization (
#[Authorize('update', 'post')]) is unaffected. - If you registered a class-name-keyed gate as a workaround, remove it — the guessed action-name ability is used instead.