soyhuce/laravel-fluent-policy
Define Laravel authorization policies as fluent, reusable method chains instead of repetitive gate checks. soyhuce/laravel-fluent-policy keeps access rules readable and composable, integrating with Laravel's Gate so complex permissions stay inline, testable, and easy to maintain.
soyhuce/laravel-fluent-policy is a small, single-purpose package: it adds a FluentPolicy trait you use inside a normal Laravel policy class, and a tiny response builder that lets you express "allow/deny" rules as a readable chain instead of nested ifs. There is no config file to publish and no service provider wiring to do — that is the whole appeal.
First steps:
composer require soyhuce/laravel-fluent-policy (1.8.0 requires Laravel 12; stay on 1.7.x if you are pinned to Laravel 11 or lower).php artisan make:policy PostPolicy --model=Post) and add use FluentPolicy; to the class.src/ — it is a few hundred lines and you can read it in one sitting. That is faster than guessing semantics from the docs.update on a tenant-scoped model) and run your existing policy tests against it.Do not convert your whole app/Policies directory on day one. The trait only changes how you write a policy method; Laravel's discovery (App\Policies\*Policy), Gate::policy(), @can, authorize(), and form requests are all unchanged, so a mixed codebase works fine and lets you migrate method by method.
Condition chain instead of early returns. The core pattern is turning a branch tree into a single expression:
// before
public function update(User $user, Post $post): bool
{
if ($user->isAdmin()) {
return true;
}
return $post->author_id === $user->id && ! $post->is_locked;
}
// after
public function update(User $user, Post $post): Response
{
return $this->allow()
->when($user->isAdmin())
->orWhen($post->author_id === $user->id && ! $post->is_locked);
}
Return Response (Laravel's Illuminate\Auth\Access\Response) rather than bool on methods where you want a deny reason — the message lands in the AuthorizationException and therefore in your 403 body when APP_DEBUG is on and in whatever handler you write.
denyAsNotFound() for existence-leaking resources. For multi-tenant or private-by-default models, use the not-found variant instead of a plain deny so $user->can(...) inside authorize()/Gate::authorize() produces a 404. This is the single biggest day-to-day win over hand-written Response::denyAsNotFound() scattered through policies.
Separate "who" from "what". Keep the condition helpers doing pure checks ($user->isAdmin(), $post->isPublished(), $user->owns($post)) and let the policy read as a sentence. That keeps the chain testable and greppable.
Use before() for the super-admin bypass. The trait does not replace before(); it still runs first and true there short-circuits everything, so a global admin override belongs there, not in every chain.
Compose with scopes, not queries. If a policy condition needs data ($user->teams()->whereKey($team)->exists()), load it in the controller or a DTO and pass a bool into the chain — otherwise you get N+1s inside @can loops in Blade.
Testing stays identical. Gate::forUser($user)->allows('update', $post) and $this->actingAs($user)->getJson(...)->assertForbidden()/assertNotFound() cover it; the fluent builder is an implementation detail, so port your existing policy tests verbatim before refactoring.
composer update with no code changes.composer.json still allows 8.1, resolve that before bumping.dump()ing it mid-chain gives you a builder object, not a verdict; Gate will then evaluate it as truthy. Always return the final call directly.Gate check has no user and the policy is type-hinted User; if you want guests to get a 403 with a message rather than a blanket deny, type-hint ?User and handle it as the first condition.before() returning false in a global base policy silently kills every fluent chain. Keep it returning null for "no opinion".Response; a method returning bool discards it. If your 403s are mysteriously generic, check the return type first.$user->can(...) on the same ability from inside a condition is an infinite loop — assert preconditions instead of re-entering the gate.App\Policies\Policy class that uses FluentPolicy and adds your own helpers (tenant checks, isOwner() sugar). Do not fork the package for that; the source is small enough to read but not worth vendoring.How can I help you explore Laravel packages today?