Lesson 21 — Gates and Policies: after identifying users through login, TaskFlow must decide which projects they may access. This milestone adds actual project creation, task listing, task creation and completion with server-side authorization.
1. Define the permission model first
Each project currently has one owner. Only that owner may view it, create tasks or complete its tasks. Any authenticated user may create a personal project. Assignment does not grant access; team membership, shared roles and admin bypass do not exist yet.
Authentication identifies the caller; authorization evaluates their access to a resource. Hiding a Blade button is only a UI decision because callers can construct HTTP requests directly. Controllers must enforce access before returning private content or accepting writes.
2. Group model permissions into policies
app/Policies/ProjectPolicy.php
<?php
namespace App\Policies;
use App\Models\Project;
use App\Models\User;
class ProjectPolicy
{
public function create(User $user): bool
{
return true;
}
public function view(User $user, Project $project): bool
{
return (int) $project->owner_id === (int) $user->id;
}
public function update(User $user, Project $project): bool
{
return $this->view($user, $project);
}
}app/Policies/TaskPolicy.php
<?php
namespace App\Policies;
use App\Models\Task;
use App\Models\User;
class TaskPolicy
{
public function update(User $user, Task $task): bool
{
return (int) $task->project->owner_id === (int) $user->id;
}
}Laravel discovers these policies through the conventional App/Models and App/Policies structure. The HTTP tests exercise real Gate resolution, confirming that wiring works. Avoid duplicating the same rules through competing registrations.
Gate::authorize delegates to the policy and throws on denial. Standalone Gate::define rules can serve non-model abilities such as an operations page. TaskFlow has no administrative role yet, so we do not invent an admin flag or broadly bypass policies with before.
3. Enforce the boundary in controllers
app/Http/Controllers/ProjectController.php
<?php
namespace App\Http\Controllers;
use App\Models\Project;
use App\Queries\TaskListQuery;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Str;
class ProjectController extends Controller
{
public function index(Request $request)
{
return view('projects.index', [
'projects' => $request->user()->ownedProjects()->latest('id')->paginate(20),
]);
}
public function store(Request $request)
{
Gate::authorize('create', Project::class);
$data = $request->validate(['name' => ['required', 'string', 'max:120']]);
$project = $request->user()->ownedProjects()->create([
'name' => $data['name'], 'slug' => (string) Str::uuid(),
]);
return to_route('projects.show', $project);
}
public function show(Request $request, Project $project, TaskListQuery $query)
{
Gate::authorize('view', $project);
$tasks = $query->paginate($project, $request->query());
foreach ($tasks as $task) {
$task->setRelation('project', $project);
}
return view('projects.show', [
'project' => $project, 'tasks' => $tasks,
]);
}
}app/Http/Controllers/ProjectTaskController.php
<?php
namespace App\Http\Controllers;
use App\Actions\CompleteTask;
use App\Actions\CreateTask;
use App\Data\CreateTaskData;
use App\Models\Project;
use App\Models\Task;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Validation\Rule;
class ProjectTaskController extends Controller
{
public function store(Request $request, Project $project, CreateTask $createTask)
{
Gate::authorize('update', $project);
$data = $request->validate([
'title' => ['required', 'string', 'max:120'],
'description' => ['nullable', 'string', 'max:2000'],
'priority' => ['required', Rule::in(['low', 'normal', 'high'])],
]);
$createTask->handle($project, new CreateTaskData(
$data['title'], $data['description'] ?? null, $data['priority'],
));
return to_route('projects.show', $project);
}
public function complete(Project $project, Task $task, CompleteTask $completeTask)
{
Gate::authorize('update', $task);
$completeTask->handle($task);
return to_route('projects.show', $project);
}
}The index queries the current user's ownedProjects rather than loading every project and hiding rows visually. Creation assigns ownership through the relationship and generates a UUID slug on the server. Client-supplied owner_id cannot choose another owner. UUIDs provide convenient slug generation, not authorization.
Task creation authorizes the project before validating and constructing its DTO. Client status and assignee_id are not forwarded; new tasks remain todo and unassigned. Completion invokes its Action only after Task policy approval. Non-HTTP callers of those Actions still need appropriate authorization for their context.
The show controller attaches the known project relationship to each scoped task so Blade policy checks do not query it once per row. This is valid because the list query already restricts tasks to that project. Never attach an arbitrary project to unverified task records.
4. Nested binding does not replace policies
routes/web.php
<?php
use App\Http\Controllers\TaskFlowOverviewController;
use App\Http\Controllers\TaskPreviewController;
use App\Http\Controllers\SessionController;
use App\Http\Controllers\ProjectController;
use App\Http\Controllers\ProjectTaskController;
use App\Http\Middleware\AssignRequestId;
use Illuminate\Support\Facades\Route;
Route::get('/', function () {
return view('welcome');
});
Route::get('/taskflow', TaskFlowOverviewController::class)
->middleware(AssignRequestId::class)
->name('taskflow.overview');
Route::get('/tasks/create', [TaskPreviewController::class, 'create'])->name('tasks.create');
Route::post('/tasks/preview', [TaskPreviewController::class, 'preview'])
->middleware('throttle:task-preview')->name('tasks.preview');
Route::middleware('guest')->group(function () {
Route::get('/login', [SessionController::class, 'create'])->name('login');
Route::post('/login', [SessionController::class, 'store'])
->middleware('throttle:login')->name('login.store');
});
Route::view('/dashboard', 'dashboard')->middleware('auth')->name('dashboard');
Route::post('/logout', [SessionController::class, 'destroy'])->middleware('auth')->name('logout');
Route::middleware('auth')->scopeBindings()->group(function () {
Route::get('/projects', [ProjectController::class, 'index'])->name('projects.index');
Route::post('/projects', [ProjectController::class, 'store'])->name('projects.store');
Route::get('/projects/{project}', [ProjectController::class, 'show'])->name('projects.show');
Route::post('/projects/{project}/tasks', [ProjectTaskController::class, 'store'])->name('project-tasks.store');
Route::patch('/projects/{project}/tasks/{task}/complete', [ProjectTaskController::class, 'complete'])
->name('project-tasks.complete');
});scopeBindings requires the URL task to belong to its accompanying project. A mismatched pair returns 404 even if the caller owns both projects. Policies still verify ownership after binding; the two mechanisms answer different questions.
An existing unauthorized project returns 403, while a missing ID returns 404. This contract may reveal existence without exposing content. Products that must hide existence should adopt a consistent 404 strategy and update tests rather than change only one endpoint accidentally.
5. Use the same rule in the interface
The dashboard links to /projects. The index creates projects; the detail page creates tasks, searches titles and offers Complete for unfinished tasks. POST forms contain @csrf, and completion adds @method('PATCH'). Blade escapes user-provided names and descriptions.
@can('update', $task)
<form method="POST" action="{{ route('project-tasks.complete', [$project, $task]) }}">
@csrf
@method('PATCH')
<button>Complete</button>
</form>
@endcan
This is a view excerpt; enforcement remains in the controller. Sign in with the development account from lesson 20 and create a personal project from the dashboard. No reassignment of seeded demo ownership is needed. Renaming/deleting projects, deleting tasks and sharing projects are not implemented here.
6. Adversarial tests with separate accounts
tests/Feature/ProjectAuthorizationTest.php
<?php
namespace Tests\Feature;
use App\Models\Project;
use App\Models\Task;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class ProjectAuthorizationTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config(['database.default' => 'sqlite', 'database.connections.sqlite.database' => ':memory:',
'database.connections.sqlite.url' => null]);
DB::purge('sqlite');
$this->artisan('migrate', ['--force' => true])->assertSuccessful();
$this->withoutVite();
}
public function test_owner_can_create_project_task_and_complete_it(): void
{
$user = User::factory()->create();
$other = User::factory()->create();
$this->actingAs($user)->post('/projects', ['name' => 'My project', 'owner_id' => $other->id])
->assertRedirect();
$project = $user->ownedProjects()->sole();
$this->post('/projects/'.$project->id.'/tasks', [
'title' => 'My task', 'priority' => 'high', 'status' => 'done', 'assignee_id' => $other->id,
])->assertRedirect(route('projects.show', $project));
$task = $project->tasks()->sole();
$this->assertSame('todo', $task->status);
$this->assertNull($task->assignee_id);
$this->get(route('projects.show', $project))->assertOk()->assertSeeText('My task');
$this->patch(route('project-tasks.complete', [$project, $task]))->assertRedirect();
$this->assertSame('done', $task->fresh()->status);
$this->patchJson(route('project-tasks.complete', [$project, $task]))->assertStatus(409);
}
public function test_other_user_cannot_read_create_or_complete_tasks(): void
{
$project = Project::factory()->create();
$task = Task::factory()->for($project)->create();
$this->actingAs(User::factory()->create());
$this->get(route('projects.index'))->assertOk()->assertDontSeeText($project->name);
$this->get(route('projects.show', $project))->assertForbidden();
$this->postJson(route('project-tasks.store', $project), ['title' => 'Intrusion', 'priority' => 'normal'])
->assertForbidden();
$this->patchJson(route('project-tasks.complete', [$project, $task]))->assertForbidden();
$this->assertDatabaseCount('tasks', 1);
$this->assertSame('todo', $task->fresh()->status);
}
public function test_nested_binding_rejects_a_task_from_another_project(): void
{
$first = Project::factory()->create();
$second = Project::factory()->for($first->owner, 'owner')->create();
$task = Task::factory()->for($second)->create();
$this->actingAs($first->owner)->patchJson(route('project-tasks.complete', [$first, $task]))
->assertNotFound();
$this->assertSame('todo', $task->fresh()->status);
}
public function test_guest_cannot_write_and_owner_validation_does_not_insert(): void
{
$project = Project::factory()->create();
$this->postJson(route('project-tasks.store', $project), [])->assertUnauthorized();
$this->actingAs($project->owner)->postJson(route('project-tasks.store', $project), [
'title' => ' ', 'priority' => 'urgent',
])->assertUnprocessable()->assertJsonValidationErrors(['title', 'priority']);
$this->assertDatabaseCount('tasks', 0);
}
}php artisan test --filter=ProjectAuthorizationTest
php artisan test
The suite passed 61 tests with 258 assertions. Owners persist real data; forged ownership/status/assignment fields are ignored; other users cannot read or write; mismatched nested routes return 404; guests receive JSON 401. Invalid input inserts nothing, and repeated completion preserves lesson 18's 409 contract.
Concurrent permission changes are not tested. Future ownership transfers or membership revocation require analysis of the race between authorization and writing, transaction boundaries and data design. A policy check at one moment does not permanently lock a permission.
Exercises: deny an assignee who is not the owner, prove search cannot leak another project's tasks, and draft a permission matrix before implementing membership. Reference: Laravel Authorization.
Navigation: Lesson 20 · Roadmap. Next: sessions, cookies and CSRF.




No comments yet. Be the first to share your thoughts.