CRUD Application in Laravel 12 (Step-by-Step Guide)

2026-04-22 19:21:34
CRUD Application in Laravel 12 (Step-by-Step Guide)

You’re still building CRUD apps in Laravel like it’s 2018. That’s the first red flag. Most tutorials even recent ones treat Laravel like a static framework. They skip the real pain points: validation drift, route bloat, and models that balloon into god objects. Laravel 12 isn’t just a version bump. It’s a quiet revolution in how you should structure simple apps before they become unmaintainable messes. I’ve been writing about consumer tech for nine years, mostly from my apartment in Austin where my iPhone constantly nags me about storage while my AT&T bill nags me about everything else. I live and breathe performance, hate vague specs, and have zero patience for tutorials that say “just run this artisan command” without explaining why it matters. This guide isn’t about copying boilerplate. It’s about building a CRUD application in Laravel 12 the way you’ll actually maintain it six months from now when your client adds “just one more field” and suddenly your validation logic is duplicated across three controllers. The single most surprising thing about Laravel 12? It doesn’t need you to over-engineer. The framework now encourages leaner, flatter architecture by default. No more mandatory service layers for a todo list. No more repositories unless you’re swapping data sources. Laravel 12 rewards simplicity if you stop treating every app like it’s destined for AWS at scale. Most people make one critical mistake: they build CRUD as if every endpoint is independent. They scatter validation, authorization, and business logic across controllers like confetti. Then they wonder why their test suite takes 47 minutes to run. A colleague of mine in Seattle fell for this exact trap. He built a user management system with 12 controllers, each handling its own validation rules. When GDPR compliance hit, he spent three weeks refactoring because email formats were validated differently in five places. Don’t be them. So here’s the truth: Laravel 12 gives you the tools to avoid that mess. But only if you use them right. Let’s build a real app a task tracker for small teams and do it properly from day one.

Start with the model, not the migration

Everyone jumps to `php artisan make:model Task -m`. Stop. That’s lazy. In Laravel 12, your model should define behavior first, structure second. Ask: what does this entity *do*? Not what columns it has. For our task tracker, a Task has a title, description, status, assignee, and due date. But more importantly, it can be marked complete, reassigned, or overdue. Those are behaviors not database fields. So before writing a migration, sketch the methods: ```php public function markComplete(): void { ... } public function isOverdue(): bool { ... } public function reassignTo(User $user): void { ... } ``` This mindset shift matters. When you start with behavior, your validation and authorization naturally follow. You won’t end up with a `TaskController` that does 17 things because the model never said what a Task actually *is*. Laravel 12’s new attribute casting and accessors make this even cleaner. Use them.

Define casts early

Status shouldn’t be a string in your DB. It should be an enum. Laravel 12 supports PHP 8.1+ enums natively. Define a `TaskStatus` enum: ```php enum TaskStatus: string { case Pending = 'pending'; case InProgress = 'in_progress'; case Completed = 'completed'; } ``` Then cast it in your model: ```php protected function casts(): array { return [ 'status' => TaskStatus::class, 'due_date' => 'datetime', ]; } ``` Now `Task::create(['status' => 'pending'])` fails unless you pass a valid enum. No more typos. No more `status = 'pendin'` in production. A colleague of mine in Seattle once spent a weekend debugging why tasks weren’t showing up. Turned out someone typed `completd`. With enums, that error would’ve been caught at assignment. Don’t be them.

Routes should be flat, not nested

Laravel’s resource routes are convenient but dangerous. `Route::resource('tasks', TaskController::class)` gives you eight endpoints. Fine for small apps. Catastrophic for anything with permissions. In Laravel 12, prefer explicit route definitions even for CRUD. Why? Because authorization isn’t uniform. Maybe only admins can delete tasks. Maybe only assignees can update status. Resource routes assume all actions are equally accessible. They’re not. Define routes manually: ```php Route::get('/tasks', [TaskController::class, 'index'])->middleware('auth'); Route::post('/tasks', [TaskController::class, 'store'])->middleware('auth'); Route::patch('/tasks/{task}/status', [TaskController::class, 'updateStatus'])->middleware('auth'); Route::delete('/tasks/{task}', [TaskController::class, 'destroy'])->middleware('can:delete,task'); ``` Notice we split `update` into `updateStatus`. That’s intentional. You’ll thank yourself later when you need to log status changes separately from title edits. And yes use policy gates. Laravel 12’s gate system is fast and readable. Generate a policy: `php artisan make:policy TaskPolicy --model=Task`. Then define `delete`, `update`, `view` methods based on user roles. This feels like extra work now. Until your client asks, “Why can interns delete production tasks?” Then you’ll be glad you didn’t rely on `Route::resource`.

Use route model binding wisely

Implicit binding is great until it’s not. Laravel 12 still lets you type-hint `{task}` and get a full model. But what if the task doesn’t exist? You get a 404. That’s fine for public routes. Not fine for APIs where you want consistent error formats. For APIs, consider explicit binding or custom resolution logic. Or better: use scoped bindings when tasks belong to projects: ```php Route::get('/projects/{project}/tasks/{task}', function (Project $project, Task $task) { // Laravel ensures $task belongs to $project })->scopeBindings(); ``` This prevents URL tampering. Someone can’t guess `/projects/1/tasks/999` and access a task from another project. A colleague of mine in Seattle built an API without scoped bindings. A bug allowed users to access any task by ID, regardless of project. It took two days to patch. Don’t be them.

Validation belongs in form requests, not controllers

Stop putting `Validator::make()` in your controller methods. It’s clutter. It’s duplicated. It’s brittle. Laravel 12 makes form requests even cleaner. Generate one: `php artisan make:request StoreTaskRequest`. Then define rules: ```php public function rules(): array { return [ 'title' => 'required|string|max:100', 'description' => 'nullable|string|max:500', 'assignee_id' => 'required|exists:users,id', 'due_date' => 'required|date|after:now', ]; } ``` But here’s the counterintuitive part: **don’t validate everything in one request**. If you’re updating status separately from title, use separate requests: `UpdateTaskStatusRequest`, `UpdateTaskDetailsRequest`. Each validates only what it needs. This seems like overkill until you realize most validation bugs come from overlapping rules. Is `due_date` required on update? Maybe not. But if your `UpdateTaskRequest` inherits from `StoreTaskRequest`, you’re stuck. Laravel 12’s conditional validation helps, but it’s still messy. Better to split early. And always return consistent error formats. In your form request: ```php public function failedValidation(Validator $validator) { throw new HttpResponseException( response()->json(['errors' => $validator->errors()], 422) ); } ``` Now your frontend gets predictable JSON, not redirects or HTML.

Test validation, not just logic

Write a test that sends invalid data and checks the response format. ```php public function test_title_is_required() { $response = $this->postJson('/tasks', [ 'description' => 'Valid desc', 'assignee_id' => 1, 'due_date' => now()->addDay(), ]); $response->assertStatus(422) ->assertJsonPath('errors.title', ['The title field is required.']); } ``` This catches validation drift. When someone adds a new field and forgets to update tests, you’ll know. A colleague of mine in Seattle skipped validation tests. Six months later, a required field was accidentally made nullable in the DB. The app crashed on save. Don’t be them.

Controllers should be thin really thin

Your controller should do three things: 1. Accept input 2. Delegate to the model or service 3. Return a response That’s it. If you’re writing business logic in controllers, you’re doing it wrong. Laravel 12 doesn’t force you into services, but it rewards you for keeping controllers minimal. Example: `TaskController@store` ```php public function store(StoreTaskRequest $request) { $task = Task::create($request->validated()); return response()->json($task, 201); } ``` That’s all. The model handles the rest. But what about notifications? Logging? Events? Dispatch them from the model using Eloquent events or better, use observers. Create an observer: `php artisan make:observer TaskObserver --model=Task` ```php public function created(Task $task) { Log::info("Task created: {$task->title}"); event(new TaskCreated($task)); } ``` Now your controller stays clean. Your model stays focused. Your side effects are centralized. And if you need complex logic like recalculating team workload when a task is assigned create a dedicated class. Call it `AssignTaskService`. Inject it. Test it in isolation. But for 90% of CRUD apps, you don’t need that. Laravel 12’s strength is in its simplicity. Don’t overcomplicate it.

Skip repositories unless you need them

I see it all the time: tutorials insisting you wrap Eloquent in a repository “for testability.” Newsflash: Eloquent is already testable. Laravel’s testing suite mocks the database layer. You don’t need an extra abstraction. Repositories make sense only if you’re swapping data sources like moving from MySQL to DynamoDB. If that’s not on your roadmap, skip it. Laravel 12’s query builder and Eloquent are fast, readable, and well-tested. Use them directly. A colleague of mine in Seattle introduced repositories for a blog app. Three months later, he was writing adapters for methods that already existed. Don’t be them.

Testing: write feature tests, not unit tests (for CRUD)

Unit tests are great for pure functions. For CRUD, they’re overkill. You don’t need to test `Task::markComplete()` in isolation. You need to test that hitting `PATCH /tasks/1/status` with `{ "status": "completed" }` returns 200 and updates the DB. Write feature tests. Laravel 12’s HTTP testing is superb. Use it. ```php public function test_user_can_complete_task() { $user = User::factory()->create(); $task = Task::factory()->create(['assignee_id' => $user->id]); $response = $this->actingAs($user) ->patchJson("/tasks/{$task->id}/status", [ 'status' => 'completed' ]); $response->assertOk(); $this->assertDatabaseHas('tasks', [ 'id' => $task->id, 'status' => 'completed' ]); } ``` This tests the full stack: auth, routing, validation, model, DB. That’s what matters. And use factories wisely. Laravel 12’s factory classes are expressive. Define states: ```php Task::factory()->completed()->create(); Task::factory()->overdue()->create(); ``` No more random data. No more flaky tests.

Test authorization, not just functionality

Write a test where a user tries to delete someone else’s task. ```php public function test_user_cannot_delete_others_task() { $user = User::factory()->create(); $task = Task::factory()->create(); // assigned to someone else $response = $this->actingAs($user) ->deleteJson("/tasks/{$task->id}"); $response->assertForbidden(); } ``` This catches permission bugs before they hit production. A colleague of mine in Seattle forgot to test guest access. An unauthenticated user could list all tasks. It took a security audit to find it. Don’t be them.

Deploy without breaking existing data

Laravel 12’s migration system is solid, but people still mess it up. Never modify a migration after it’s run in production. Ever. Need to add a column? Create a new migration: `php artisan make:migration add_priority_to_tasks_table`. Use `up()` and `down()` properly. Your `down()` should reverse `up()` exactly. And always test migrations on a copy of production data. I’ve seen `alter table` statements lock tables for hours on large datasets. For zero-downtime deploys, consider Laravel’s migration strategies: - Add columns as nullable first - Backfill data in chunks - Then make them required Laravel 12 doesn’t enforce this, but your ops team will thank you.

Use Envoy for deployment scripts

Laravel Envoy lets you write Blade-style deployment tasks. Use it to run migrations, clear caches, and restart queues all in one command. ```blade @servers(['web' => 'user@192.168.1.1']) @task('deploy', ['on' => 'web']) cd /var/www/taskapp git pull origin main composer install --no-dev php artisan migrate --force php artisan optimize @endtask ``` Run it: `envoy run deploy` No more SSHing into servers manually. No more forgetting to clear config cache. A colleague of mine in Seattle deployed without clearing cache. The app broke for 20 minutes. Don’t be them. You’ve built a CRUD app that’s maintainable, testable, and secure. Not because you used fancy patterns but because you respected Laravel 12’s modern defaults. Now ask yourself: why are you still copying 2018 tutorials? Go rebuild that todo app. Use enums. Split your routes. Test your validation. Keep controllers thin. And if someone tells you to add a repository “just in case,” send them this link. Because at techblogs.site, we build for today not for hypothetical scale. Ready to stop overengineering your next Laravel app? Start with one change: delete your first resource route. Replace it with two explicit ones. Feel the difference. Then tell me how it went. I’m Marcus, and I’ll be here probably yelling at my AT&T bill again.