A step-by-step Laravel 12 tutorial that walks you through building a simple todo list app from scratch—models, migrations, routes, and Blade views in one focused pass.

The fastest way to learn a framework is to build something real with it. A todo list is that project: small enough to finish in one sitting, big enough to touch migrations, Eloquent models, routes, controllers, and Blade views. This Laravel 12 todo list tutorial walks you through a simple CRUD app from scratch—no auth, no API. By the end you’ll have a running app and a clear picture of Laravel’s MVC flow.
A simple todo list app with:
You need PHP 8.2+, Composer, and Laravel 12 (or the current stable Laravel). A local database is required; SQLite is the fastest way to get going—no server setup. If you prefer MySQL or PostgreSQL, the steps are the same; only your .env and config differ. To check your PHP version, run php --version in the terminal.
Create a new Laravel application and point it at your database.
composer create-project laravel/laravel todo-appcd todo-appA fresh install already includes .env and an application key. You only need to configure the database. For SQLite, set DB_CONNECTION=sqlite in .env and create the database file if it doesn’t exist:
DB_CONNECTION=sqlitetouch database/database.sqliteA fresh install may have already run migrations for you. Either way, run php artisan migrate yourself so you see how it works (it creates the default tables, e.g. users):
php artisan migrateYou’re ready to add the todo feature.
Create a migration for the todos table and a Todo model.
php artisan make:model Todo -mThis creates two files: app/Models/Todo.php (the Todo model) and database/migrations/..._create_todos_table.php (the migration for the todos table). The -m flag tells Artisan to generate the migration together with the model.
Edit the migration (in database/migrations/..._create_todos_table.php). We add two columns: title (string, the task text) and completed (boolean, default false). Plus id and timestamps:
Schema::create('todos', function (Blueprint $table) { $table->id(); $table->string('title'); // the task text $table->boolean('completed')->default(false); // done or not $table->timestamps();});Run the migration:
php artisan migrateIn app/Models/Todo.php, add two properties:
$fillable — Laravel blocks mass assignment by default (e.g. Todo::create(['title' => '...', 'completed' => false])). Listing title and completed here allows you to set them in one go from the request.
protected $fillable = ['title', 'completed'];$casts — Tells Eloquent to turn the completed column (stored as 0/1 in the database) into a real PHP boolean when you read the model. In Blade you can then use @if($todo->completed) instead of checking for 1 or "1".
protected $casts = [ 'completed' => 'boolean',];Create the controller first so the routes have something to point to:
php artisan make:controller TodoControllerThis creates app/Http/Controllers/TodoController.php. Then in routes/web.php, add the web routes:
use Illuminate\Support\Facades\Route;use App\Http\Controllers\TodoController;
Route::get('/', [TodoController::class, 'index'])->name('todos.index');Route::post('/todos', [TodoController::class, 'store'])->name('todos.store');Route::patch('/todos/{todo}', [TodoController::class, 'update'])->name('todos.update');Route::delete('/todos/{todo}', [TodoController::class, 'destroy'])->name('todos.destroy');Implement the four methods in TodoController:
At the top of the class, add use App\Models\Todo; and use Illuminate\Http\Request; if they're not there.
index — Shows the main todo list page. Fetches all todos from the database (newest first), then passes them to the view todos.index so Blade can render the list.
public function index(){ $todos = Todo::orderBy('created_at', 'desc')->get(); return view('todos.index', compact('todos'));}store — Creates a new todo. Validates the title input from the form, creates a new record in the todos table with completed = false, then redirects to the index page with a flash message.
public function store(Request $request){ $validated = $request->validate([ 'title' => ['required', 'string', 'max:255'], ]); Todo::create([ 'title' => $validated['title'], 'completed' => false, ]); return redirect()->route('todos.index')->with('message', 'Todo added.');}update — Toggles the completed status. Receives one todo via route model binding, flips completed (true ↔ false), saves to the database, then redirects back with a message.
public function update(Todo $todo){ $todo->update(['completed' => !$todo->completed]); return redirect()->back()->with('message', 'Todo updated.');}destroy — Deletes one todo. Receives the todo via route model binding, deletes the record from the database, then redirects back with a message.
public function destroy(Todo $todo){ $todo->delete(); return redirect()->back()->with('message', 'Todo deleted.');}Why view() vs redirect()? — index returns a view because the job is to show a page: you pass data ($todos) to a Blade template and Laravel sends back HTML. store, update, and destroy return a redirect because they change data (create/update/delete); after that you send the user to a URL (index or back) instead of rendering a new page.
Redirecting also avoids the browser re‑submitting the form when the user refreshes. The ->with('message', ...) puts a one-time flash message in the session so the next page can show “Todo added,” “Todo updated,” or “Todo deleted.”
In Blade, use route('todos.update', $todo) and route('todos.destroy', $todo) for the form actions; use @method('PATCH') or @method('DELETE') inside the form for non-GET requests.
Create the layout file first, then the todo list view.
Create resources/views/layouts/app.blade.php (create the layouts folder if it doesn’t exist). This file wraps every page: a basic HTML shell with <head>, a container <div>, and @yield('content') where each page’s content will go. We use Tailwind CSS for styling—you can load it via the CDN script below, or install it with npm/Vite if you prefer; the layout and views in this tutorial use Tailwind utility classes.
<!DOCTYPE html><html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>@yield('title', 'Todo')</title> <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script> <style> /* Smooth, readable base */ body { font-feature-settings: 'kern' 1, 'liga' 1; } </style> </head>
<body class="min-h-screen bg-stone-50 font-sans text-stone-800 antialiased"> @yield('content') </body></html>Create resources/views/todos/index.blade.php (create the todos folder if needed). You can use either version below: with Tailwind classes (styled) or without classes (plain HTML, good if you want to add your own CSS or none).
Option A — With Tailwind classes (styled):
@extends('layouts.app') @section('content')<div class="min-h-screen bg-stone-50/80 px-4 py-16 sm:py-24"> <div class="mx-auto max-w-md"> <h1 class="mb-12 text-center text-2xl font-normal tracking-tight text-stone-800" > Todo </h1>
@if (session('message')) <p class="mb-6 text-center text-sm text-stone-500"> {{ session('message') }} </p> @endif
<form action="{{ route('todos.store') }}" method="POST" class="mb-10"> @csrf <div class="border-b border-stone-300 focus-within:border-stone-600 transition-colors" > <input type="text" name="title" value="{{ old('title') }}" placeholder="What to do?" class="w-full bg-transparent py-3 text-stone-800 placeholder-stone-400 focus:outline-none @error('title') placeholder-red-400 @enderror" required autofocus /> </div> @error('title') <p class="mt-1 text-xs text-red-600/90">{{ $message }}</p> @enderror </form>
<ul class="border-t border-stone-200"> @forelse ($todos as $todo) <li class="group flex items-center gap-4 border-b border-stone-100 py-4"> <form action="{{ route('todos.update', $todo) }}" method="POST" class="flex-shrink-0" > @csrf @method('PATCH') <button type="submit" class="block focus:outline-none focus:ring-0" title="{{ $todo->completed ? 'Mark incomplete' : 'Mark complete' }}" > @if ($todo->completed) <span class="flex h-5 w-5 items-center justify-center rounded-full border border-stone-400 bg-stone-400 text-[10px] font-medium text-white" >✓</span > @else <span class="flex h-5 w-5 items-center justify-center rounded-full border border-stone-300 text-transparent hover:border-stone-500" >·</span > @endif </button> </form> <span class="min-w-0 flex-1 text-[15px] {{ $todo->completed ? 'text-stone-400 line-through' : 'text-stone-700' }}" >{{ $todo->title }}</span > <form action="{{ route('todos.destroy', $todo) }}" method="POST" class="flex-shrink-0 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100" onsubmit="return confirm('Delete this todo?');" > @csrf @method('DELETE') <button type="submit" class="text-xs text-stone-400 hover:text-stone-600 focus:outline-none" title="Delete" > Remove </button> </form> </li> @empty <li class="border-b border-stone-100 py-12 text-center text-sm text-stone-400" > Nothing yet. Type above and press Enter. </li> @endforelse </ul> </div></div>@endsectionOption B — Without classes (plain HTML):
@extends('layouts.app') @section('content')<div> <h1>Todo</h1>
@if (session('message')) <p>{{ session('message') }}</p> @endif
<form action="{{ route('todos.store') }}" method="POST"> @csrf <input type="text" name="title" value="{{ old('title') }}" placeholder="What to do?" required autofocus /> @error('title') <p>{{ $message }}</p> @enderror </form>
<ul> @forelse ($todos as $todo) <li> <form action="{{ route('todos.update', $todo) }}" method="POST" style="display: inline;" > @csrf @method('PATCH') <button type="submit" title="{{ $todo->completed ? 'Mark incomplete' : 'Mark complete' }}" > @if ($todo->completed) ✓ @else ○ @endif </button> </form> <span style="{{ $todo->completed ? 'text-decoration: line-through;' : '' }}" >{{ $todo->title }}</span > <form action="{{ route('todos.destroy', $todo) }}" method="POST" style="display: inline;" onsubmit="return confirm('Delete this todo?');" > @csrf @method('DELETE') <button type="submit" title="Delete">Delete</button> </form> </li> @empty <li>Nothing yet. Add one above.</li> @endforelse </ul></div>@endsectionOption B uses no CSS classes except minimal inline style for layout (inline forms) and strikethrough so completed todos are still visible. You can replace those with your own stylesheet or leave the browser default look.
This view extends the layout, shows a flash message from session('message'), a form to add todos (POST to todos.store), and a list where each todo has a toggle (PATCH to todos.update) and a delete button (DELETE to todos.destroy) with a confirm dialog. No JavaScript is required beyond the optional delete confirm; plain forms and redirects handle the rest.
From the project root, run:
php artisan serveThen open http://localhost:8000 in your browser. Add a few todos, toggle them complete/incomplete, and delete one. You should see flash messages after each action. If anything errors, check that migrations have run (php artisan migrate) and that the layout and view files are in the paths above.

Request flow:
TodoController@index → load all todos → todos.index view.TodoController@store → validate → Todo::create() → redirect to index.TodoController@update → toggle completed → redirect back.TodoController@destroy → $todo->delete() → redirect back.Laravel’s route model binding resolves {todo} to a Todo instance, so in update and destroy you receive $todo directly. If the id doesn’t exist, Laravel returns 404.
Run the app with php artisan serve, open the browser, add a few todos, toggle completion, and delete one. You’ve just used migrations, Eloquent, validation, Blade, and redirects—the core of a typical Laravel CRUD flow.
You’ve set up a Laravel 12 app, added a migration and model, defined web routes, implemented a simple controller with validation, and rendered a todo list with Blade. That’s the same pattern you’ll use for larger features: model → migration → routes → controller → view.
From here you can add due dates, categories, or user ownership (e.g. tie todos to User). For production-style concerns—testing, deployment, and scaling—see Scaling Laravel Applications and Laravel Octane in Real Production for when and how to optimize.
No comments yet
Loading comments...