Building a CRUD Feature
Routes, controller, model, views and validation together: list, create, edit and delete.
CRUD (create, read, update, delete) is the most common feature you'll build in Laravel. This lesson puts the earlier basics together into one complete Tasks feature: migration, model, routes, controller, validation, authorization, views, flash messages and tests.
The model, validation rules, policy, factory and tests are this app's real Projects & Tasks code. The browser controller and Blade views are Illustrative — not in this app, because the public demo only exposes a read-only API. The demos below use that API so you can see each step's real data. For a working CRUD screen in this app, look at the Filament admin, covered at the end.
The pieces and how they connect
Artisan can generate most of the files in one go:
Illustrative — not in this app.
php artisan make:model Task --migration --factory --policy --requests --resource
# app/Models/Task.php
# database/migrations/xxxx_create_tasks_table.php
# database/factories/TaskFactory.php
# app/Policies/TaskPolicy.php
# app/Http/Requests/StoreTaskRequest.php, UpdateTaskRequest.php
# app/Http/Controllers/TaskController.php (with the 7 resource methods)
Step 1: the table and model
The migration defines the columns. The foreign keys say what happens to tasks when their project or user is deleted:
database/migrations/2026_10_18_151928_create_tasks_table.php
Schema::create('tasks', function (Blueprint $table) {
$table->id();
$table->foreignId('project_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
$table->string('title');
$table->text('description')->nullable();
$table->string('priority')->default(TaskPriority::Medium->value);
$table->date('due_on')->nullable();
$table->timestamp('completed_at')->nullable();
$table->timestamps();
$table->index(['project_id', 'completed_at']);
});
The model whitelists the fields that can be mass-assigned from a form, and casts columns to useful types:
app/Models/Task.php
protected $fillable = ['project_id', 'user_id', 'title', 'description', 'priority', 'due_on', 'completed_at'];
protected function casts(): array
{
return [
'priority' => TaskPriority::class,
'due_on' => 'date',
'completed_at' => 'datetime',
];
}
Step 2: the routes
One line registers all seven conventional routes. Click a row to see the controller method behind it:
Illustrative — not in this app.
Route::resource('tasks', TaskController::class)->middleware('auth');
php artisan route:list --name=tasks prints the same table for your app. Use ->only(['index', 'show']) or ->except([...]) to register a subset, and Route::apiResource() to skip create and edit, which only exist to show HTML forms.
Step 3: Read, the index and show pages
The list page is a query plus pagination. Load relationships with with() so the view doesn't run a query per row (the N+1 problem):
Illustrative — not in this app.
public function index(Request $request): View
{
$tasks = Task::query()
->with(['project', 'assignee'])
->when($request->string('status')->toString() === 'open', fn ($query) => $query->open())
->latest()
->paginate(15)
->withQueryString();
return view('tasks.index', compact('tasks'));
}
public function show(Task $task): View
{
$task->load(['project', 'assignee', 'tags']);
return view('tasks.show', compact('task'));
}
This app's real API does the same work and returns JSON. Here is its index with filters, paginated 10 per page:
Illustrative — not in this app. The Blade view loops over the paginator, and links() renders the page numbers:
<table>
@foreach ($tasks as $task)
<tr>
<td><a href="{{ route('tasks.show', $task) }}">{{ $task->title }}</a></td>
<td>{{ $task->project->name }}</td>
<td>{{ $task->priority->label() }}</td>
<td>{{ $task->due_on?->format('j M') ?? '—' }}</td>
</tr>
@endforeach
</table>
{{ $tasks->links() }}
Step 4: Create, the form and store
create() shows an empty form; store() receives the submission. Validation lives in a Form Request, so the controller stays short. Forms & Validation explains how these rules work; here they are simply put to use. These are the real rules this app uses:
app/Http/Requests/StoreTaskRequest.php
public function rules(): array
{
return [
'project_id' => ['required', 'integer', 'exists:projects,id'],
'title' => ['required', 'string', 'min:3', 'max:120'],
'description' => ['nullable', 'string', 'max:2000'],
'priority' => ['required', Rule::enum(TaskPriority::class)],
'due_on' => ['nullable', 'date', 'after_or_equal:today'],
'tag_ids' => ['array'],
'tag_ids.*' => ['integer', 'exists:tags,id'],
];
}
Illustrative — not in this app.
public function create(): View
{
return view('tasks.create', [
'projects' => Project::orderBy('name')->pluck('name', 'id'),
]);
}
public function store(StoreTaskRequest $request): RedirectResponse
{
// By the time this runs, validation has passed
$task = Task::create($request->safe()->except('tag_ids'));
$task->tags()->sync($request->validated('tag_ids', []));
return redirect()->route('tasks.show', $task)->with('status', 'Task created.');
}
If validation fails, Laravel redirects back to the form with the errors and old input, and store() never runs. Try it below. The form loads the real projects and submits to the app's /api/demo/tasks/validate endpoint, which runs StoreTaskRequest and reports the result without saving.
$request->safe()->except('tag_ids')? tag_ids isn't a column on tasks; it goes to the pivot table through sync(). safe() gives you only validated data, so a user can't sneak in extra fields like completed_at.
Step 5: Update, the edit form
edit() shows the form filled in; update() saves it. An UpdateTaskRequest usually mirrors the store rules, often with sometimes so partial updates work. The edit form uses old('field', $task->field) so it shows the user's last attempt after an error, and the saved value otherwise.
Illustrative — not in this app.
public function update(UpdateTaskRequest $request, Task $task): RedirectResponse
{
Gate::authorize('update', $task);
$task->update($request->safe()->except('tag_ids'));
$task->tags()->sync($request->validated('tag_ids', []));
return redirect()->route('tasks.show', $task)->with('status', 'Task updated.');
}
Two pieces here come from later lessons. $task->tags()->sync([...]) replaces a task's tags in a many-to-many relationship; it's covered in Eloquent Relationships. Gate::authorize() and the policy behind it are covered in Middleware, Auth & Policies. For now, read them as "save the chosen tags" and "stop users who aren't allowed".
<form method="POST" action="{{ route('tasks.update', $task) }}">
@csrf
@method('PUT')
<input name="title" value="{{ old('title', $task->title) }}">
@error('title') <p class="error">{{ $message }}</p> @enderror
{{-- ... --}}
</form>
HTML forms can only send GET and POST. @method('PUT') adds a hidden _method field, and Laravel routes the request to update().
Step 6: Delete
Illustrative — not in this app.
public function destroy(Task $task): RedirectResponse
{
Gate::authorize('delete', $task);
$task->delete();
return redirect()->route('tasks.index')->with('status', 'Task deleted.');
}
<form method="POST" action="{{ route('tasks.destroy', $task) }}" onsubmit="return confirm('Delete this task?')">
@csrf
@method('DELETE')
<button>Delete</button>
</form>
Deletes are a form, never a link: links are GET requests, and browsers, crawlers and link previews follow those freely. For "undo" or audit trails, add the SoftDeletes trait so delete() sets a deleted_at timestamp instead of removing the row.
Step 7: Authorization
Validation checks the data; a policy checks the user. This app's real policy lets a task's assignee or the project owner edit it, and only the owner delete it:
app/Policies/TaskPolicy.php
public function update(User $user, Task $task): bool
{
return $user->id === $task->user_id
|| $user->id === $task->project->user_id;
}
public function delete(User $user, Task $task): bool
{
return $user->id === $task->project->user_id;
}
Gate::authorize('update', $task) in the controller throws a 403 if the policy says no. In Blade, @can('delete', $task) ... @endcan hides the button from users who can't use it. Hiding the button is a convenience; the controller check is the real protection.
Step 8: Tests
A feature test sends real requests through the app and checks the result. These are from this app's test suite, using factories to build the data:
tests/Feature/Demo/DemoApiTest.php
public function test_validate_endpoint_returns_json_errors_even_for_plain_form_posts(): void
{
$this->post(route('api.demo.tasks.validate'), ['title' => 'x', 'priority' => 'urgent'])
->assertUnprocessable()
->assertJsonValidationErrors(['project_id', 'title', 'priority']);
}
tests/Feature/Demo/TaskPolicyTest.php
public function test_assignee_can_update_but_not_delete_their_task(): void
{
$task = Task::factory()->create();
$this->assertTrue($task->assignee->can('update', $task));
$this->assertFalse($task->assignee->can('delete', $task));
}
Illustrative — not in this app. The same style covers the web routes:
public function test_owner_can_create_a_task(): void
{
$project = Project::factory()->create();
$this->actingAs($project->owner)
->post(route('tasks.store'), [
'project_id' => $project->id,
'title' => 'Write release notes',
'priority' => 'high',
])
->assertRedirect()
->assertSessionHas('status', 'Task created.');
$this->assertDatabaseHas('tasks', ['title' => 'Write release notes', 'priority' => 'high']);
}
The shortcut: an admin panel
This app's own content (pages, posts, blog types, media) is managed with Filament, which generates the full CRUD screens (list with search and sorting, create and edit forms, delete with confirmation) from one resource class. app/Filament/Resources/PageResource.php defines the page form, including the block editor with a match on block type. It's the fastest route for internal tools; hand-written controllers and views like the ones above are for the public-facing parts of an app, where you need full control.
Key takeaways
- A CRUD feature is a migration, model, resource route, controller, Form Request, policy, views and tests.
Route::resource()gives seven routes;apiResource()drops the two HTML form routes.- Put validation in Form Requests and save with
$request->safe()orvalidated(). - Redirect with a flash message after
store,updateanddestroy. - Use
@csrfon every form and@method('PUT'/'DELETE')for updates and deletes; never delete via a link. - Authorize in the controller with policies;
@canin Blade is only cosmetic. - Feature tests with factories prove each action works end to end.