Auto-Discovery vs Manual Mapping in Laravel 12
Laravel auto-discovers policies provided your application structure follows standard framework conventions. If you place a model named Post inside app/Models/Post.php, Laravel 12 expects its corresponding policy at app/Policies/PostPolicy.php. Under the hood, the gate checks whether the policy class exists by appending Policy to the model name and swapping App\Models to App\Policies.
When you stay inside standard directory structures, auto-discovery works without a single line of configuration in your service provider. However, auto-discovery breaks the second you move to a modular or domain-driven structure like app/Domain/Blog/Models/Post.php. Laravel won't find app/Domain/Blog/Policies/PostPolicy.php out of the box.
Instead of manually registering every single policy in an array using Gate::policy(), you can override the policy resolution callback globally. In Laravel 12 running on PHP 8.3, register a custom resolver inside AppServiceProvider::boot() using Gate::guessPolicyNamesUsing().
namespace App\Providers;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
Gate::guessPolicyNamesUsing(function (string $modelClass) {
// Converts App\Domain\Blog\Models\Post to App\Domain\Blog\Policies\PostPolicy
return Str::replace(
'\\Models\\',
'\\Policies\\',
$modelClass
) . 'Policy';
});
}
}This single callback keeps your custom modular architecture clean without bloating service providers with manual mappings. If a project has 80 domain models, manual policy mapping requires maintaining an 80-line array. Custom guessing logic replaces all 80 lines with five.
Before Hooks: Power and Pitfalls
Superadmin access is a standard requirement for internal tools and management dashboards. Developers frequently drop role checks inside every method of a policy class. That creates repetitive code and leaves open doors when someone forgets to add the check to a new authorization method.
Policy before() hooks solve this by running prior to any specific policy method. If the before() hook returns a non-null result, Laravel uses that boolean decision immediately and ignores the target policy method like update() or delete().
Here is where developers hit a major production bug: returning false inside a before() hook prevents all subsequent policy checks from executing for non-admin users. If your before() hook returns false when $user->is_admin is false, no regular user can ever trigger a policy method, even if they own the resource.
namespace App\Policies;
use App\Models\Post;
use App\Models\User;
class PostPolicy
{
/**
* Perform pre-authorization checks.
*/
public function before(User $user, string $ability): ?bool
{
if ($user->is_admin) {
return true;
}
// Return null to allow fall-through to specific policy methods!
// Returning false here would block every non-admin user instantly.
return null;
}
public function update(User $user, Post $post): bool
{
return $user->id === $post->user_id;
}
public function delete(User $user, Post $post): bool
{
return $user->id === $post->user_id || $user->hasPermission('delete-posts');
}
}Notice the explicit return type ?bool. Returning null tells Laravel: "This user isn't an admin, so continue evaluating update() or delete() normally." Returning false hard-stops authorization evaluation immediately.
Bypassing Authorization in Local Environments
Global gates also support before hooks via Gate::before(). This is helpful for local development or full-access system roles defined outside individual policies. However, global gate before hooks execute before policy before hooks.
If you register a global gate before hook in AppServiceProvider, it runs on every single Gate::allows() or $user->can() check across the entire application framework. Keep global gate hooks lightweight. Querying a database table inside a global Gate::before() callback can add 10 to 15 database queries per HTTP request if you evaluate authorization inside template loops or API resource collections.
Testing Authorization Logic Directly
Testing authorization by making full HTTP requests through $this->actingAs($user)->patchJson('/api/posts/1', [...]) is standard in integration test suites, but it's slow. A full request test boots middleware, validates HTTP payloads, mutates database records, and formats JSON responses. When you need to test matrix combinations of 5 roles across 10 policy rules, running 50 HTTP request tests takes seconds instead of milliseconds.
You can unit test policy classes directly as plain PHP classes. Because policy methods are standard PHP methods, you can instantiate them in PHPUnit without booting heavy framework layers or writing records to MySQL or PostgreSQL.
namespace Tests\Unit\Policies;
use App\Models\Post;
use App\Models\User;
use App\Policies\PostPolicy;
use PHPUnit\Framework\TestCase;
class PostPolicyTest extends TestCase
{
public function test_owner_can_update_post(): void
{
$policy = new PostPolicy();
$owner = new User(['id' => 42, 'is_admin' => false]);
$post = new Post(['user_id' => 42]);
$this->assertTrue($policy->update($owner, $post));
}
public function test_non_owner_cannot_update_post(): void
{
$policy = new PostPolicy();
$user = new User(['id' => 99, 'is_admin' => false]);
$post = new Post(['user_id' => 42]);
$this->assertFalse($policy->update($user, $post));
}
public function test_admin_bypasses_update_restriction(): void
{
$policy = new PostPolicy();
$admin = new User(['id' => 1, 'is_admin' => true]);
// Test the before hook directly
$this->assertTrue($policy->before($admin, 'update'));
}
}Running this unit test suite takes roughly 4ms compared to 350ms for equivalent HTTP controller tests. You get instant feedback during local development and keep continuous integration pipelines fast.
For tests requiring full gate evaluation—including global gate hooks and dynamic policy resolution—use the Gate facade directly in feature tests without hitting controller routes:
public function test_gate_allows_user_with_permission(): void
{
$user = User::factory()->create();
$post = Post::factory()->create(['user_id' => $user->id]);
$this->assertTrue(Gate::forUser($user)->allows('update', $post));
}Exposing Policy Results to Next.js 16 and React 19
When building decoupled frontends with Next.js 16 App Router and React 19, authorization checks must happen on both sides of the application boundary. The backend API handles hard enforcement; the frontend uses authorization state to render or hide action controls like "Edit Post" or "Delete Account".
Avoid duplicating policy logic in JavaScript. Instead, serialize policy evaluation directly into API responses using Laravel API Resources. In PHP 8.3, clean class syntax makes mapping permissions straightforward.
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class PostResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'can' => [
'update' => $request->user()?->can('update', $this->resource) ?? false,
'delete' => $request->user()?->can('delete', $this->resource) ?? false,
],
];
}
}In Next.js 16 Server Components or React 19 Client Components, consume this permission map directly without reimplementing authorization rules in TypeScript:
interface PostProps {
post: {
id: number;
title: string;
can: {
update: boolean;
delete: boolean;
};
};
}
export default function PostCard({ post }: PostProps) {
return (
<div className="p-4 border rounded">
<h3>{post.title}</h3>
{post.can.update && (
<button className="bg-blue-500 text-white px-2 py-1">
Edit Post
</button>
)}
</div>
);
}By delegating decision-making to the Laravel policy layer and sending boolean flags down to React 19, you keep security logic in PHP. If business rules change inside PostPolicy, the React UI reflects those changes immediately without requiring frontend redeployments.
Handling Guest Users and Nullable Models
By default, Laravel automatically returns false for any gate or policy check if the authenticated user is null (a guest user). Policy methods won't even execute when an unauthenticated guest makes a request.
If you build public-facing features where guests can view specific resources based on post visibility settings, typehint the User parameter as nullable (?User $user). When you add ?User, Laravel allows unauthenticated requests to flow directly into your policy method.
public function view(?User $user, Post $post): bool
{
if ($post->is_public) {
return true;
}
return $user !== null && $user->id === $post->user_id;
}Without the question mark in ?User $user, unauthenticated guests receive a 403 Forbidden response automatically before entering the method body.










