Creating a Todo List in Laravel 12 with Bootstrap Design
In this comprehensive tutorial, we'll walk through building a complete todo list application using Laravel 12 and Bootstrap 5. This guide is perfect for beginners and covers everything from setup to deployment-ready code.
Table of Contents
- Introduction
- Prerequisites
- Project Setup
- Database Configuration
- Creating the Todo Model and Migration
- Building the Controller
- Creating Routes
- Designing Views with Bootstrap
- Adding Form Validation
- Implementing CRUD Operations
- Adding Status Updates
- Final Touches
- Conclusion
Introduction
A todo list is a classic beginner project that helps you understand fundamental web development concepts. We'll use:
- Laravel 12 for backend logic
- Bootstrap 5 for responsive design
- Eloquent ORM for database interactions
- Blade templating for views
Prerequisites
Before we begin, ensure you have:
- PHP 8.2+ installed
- Composer installed
- Node.js (for frontend dependencies)
- A database (MySQL, PostgreSQL, or SQLite)
- Basic understanding of PHP and MVC concepts
Project Setup
Let's start by creating a new Laravel project:
composer create-project laravel/laravel laravel-todo-list
cd laravel-todo-list
Install Bootstrap and its dependencies:
npm install bootstrap @popperjs/core
Database Configuration
Open your .env file and configure your database connection:
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=laravel_todo
DB_USERNAME=root
DB_PASSWORD=
Creating the Todo Model and Migration
Let's create our Todo model and its migration file:
php artisan make:model Todo -m
Now, edit the migration file in database/migrations/xxxx_create_todos_table.php:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up()
{
Schema::create('todos', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->text('description')->nullable();
$table->boolean('completed')->default(false);
$table->timestamps();
});
}
public function down()
{
Schema::dropIfExists('todos');
}
};
Run the migration:
php artisan migrate
Now, let's set up mass assignment protection in our Todo model (app/Models/Todo.php):
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Todo extends Model
{
use HasFactory;
protected $fillable = [
'title',
'description',
'completed'
];
}
Building the Controller
Create a controller to handle our todo operations:
php artisan make:controller TodoController --resource
Now, let's implement all the CRUD methods in app/Http/Controllers/TodoController.php:
<?php
namespace App\Http\Controllers;
use App\Models\Todo;
use Illuminate\Http\Request;
class TodoController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index()
{
$todos = Todo::orderBy('created_at', 'desc')->get();
return view('todos.index', compact('todos'));
}
/**
* Show the form for creating a new resource.
*/
public function create()
{
return view('todos.create');
}
/**
* Store a newly created resource in storage.
*/
public function store(Request $request)
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'description' => 'nullable|string'
]);
Todo::create($validated);
return redirect()->route('todos.index')->with('success', 'Todo created successfully!');
}
/**
* Display the specified resource.
*/
public function show(Todo $todo)
{
return view('todos.show', compact('todo'));
}
/**
* Show the form for editing the specified resource.
*/
public function edit(Todo $todo)
{
return view('todos.edit', compact('todo'));
}
/**
* Update the specified resource in storage.
*/
public function update(Request $request, Todo $todo)
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'description' => 'nullable|string'
]);
$todo->update($validated);
return redirect()->route('todos.index')->with('success', 'Todo updated successfully!');
}
/**
* Remove the specified resource from storage.
*/
public function destroy(Todo $todo)
{
$todo->delete();
return redirect()->route('todos.index')->with('success', 'Todo deleted successfully!');
}
/**
* Update the status of the specified resource.
*/
public function updateStatus(Request $request, Todo $todo)
{
$todo->completed = $request->completed;
$todo->save();
return response()->json(['success' => 'Todo status updated successfully.']);
}
}
Creating Routes
Let's define our routes in routes/web.php:
<?php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\TodoController;
Route::get('/', function () {
return redirect()->route('todos.index');
});
Route::resource('todos', TodoController::class);
Route::put('/todos/{todo}/status', [TodoController::class, 'updateStatus'])
->name('todos.updateStatus');
Designing Views with Bootstrap
First, let's set up our main layout. Create resources/views/layouts/app.blade.php:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Laravel Todo List</title>
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body>
<div class="container py-4">
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card">
<div class="card-header bg-primary text-white">
<h3 class="mb-0">@yield('title')</h3>
</div>
<div class="card-body">
@if(session('success'))
<div class="alert alert-success">
{{ session('success') }}
</div>
@endif
@yield('content')
</div>
</div>
</div>
</div>
</div>
<!-- Bootstrap JS Bundle with Popper -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<!-- jQuery -->
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
@stack('scripts')
</body>
</html>
Now, let's create our index view at resources/views/todos/index.blade.php:
@extends('layouts.app')
@section('title', 'Todo List')
@section('content')
<div class="d-flex justify-content-between mb-4">
<h4>My Todos</h4>
<a href="{{ route('todos.create') }}" class="btn btn-primary">
<i class="fas fa-plus"></i> Add New Todo
</a>
</div>
@if($todos->isEmpty())
<div class="alert alert-info">
No todos found. Create your first todo!
</div>
@else
<div class="list-group">
@foreach($todos as $todo)
<div class="list-group-item list-group-item-action">
<div class="d-flex justify-content-between align-items-center">
<div class="form-check">
<input type="checkbox"
class="form-check-input todo-status"
data-todo-id="{{ $todo->id }}"
{{ $todo->completed ? 'checked' : '' }}>
<label class="form-check-label {{ $todo->completed ? 'text-decoration-line-through' : '' }}">
{{ $todo->title }}
</label>
</div>
<div class="btn-group">
<a href="{{ route('todos.show', $todo->id) }}" class="btn btn-sm btn-info">
<i class="fas fa-eye"></i>
</a>
<a href="{{ route('todos.edit', $todo->id) }}" class="btn btn-sm btn-warning">
<i class="fas fa-edit"></i>
</a>
<form action="{{ route('todos.destroy', $todo->id) }}" method="POST" class="d-inline">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Are you sure?')">
<i class="fas fa-trash"></i>
</button>
</form>
</div>
</div>
@if($todo->description)
<p class="mb-0 mt-2 text-muted small">{{ $todo->description }}</p>
@endif
<p class="mb-0 text-muted small">
<i class="far fa-clock"></i> Created: {{ $todo->created_at->diffForHumans() }}
</p>
</div>
@endforeach
</div>
@endif
@endsection
@push('scripts')
<script>
$(document).ready(function() {
$('.todo-status').change(function() {
const todoId = $(this).data('todo-id');
const completed = $(this).is(':checked') ? 1 : 0;
$.ajax({
url: "{{ route('todos.updateStatus', '') }}/" + todoId,
type: 'PUT',
data: {
completed: completed,
_token: "{{ csrf_token() }}"
},
success: function(response) {
location.reload();
},
error: function(xhr) {
alert('Error updating status');
}
});
});
});
</script>
@endpush
Create the create form at resources/views/todos/create.blade.php:
@extends('layouts.app')
@section('title', 'Create New Todo')
@section('content')
<form action="{{ route('todos.store') }}" method="POST">
@csrf
<div class="mb-3">
<label for="title" class="form-label">Title *</label>
<input type="text" class="form-control @error('title') is-invalid @enderror"
id="title" name="title" value="{{ old('title') }}" required>
@error('title')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="mb-3">
<label for="description" class="form-label">Description</label>
<textarea class="form-control @error('description') is-invalid @enderror"
id="description" name="description" rows="3">{{ old('description') }}</textarea>
@error('description')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
<a href="{{ route('todos.index') }}" class="btn btn-secondary me-md-2">
<i class="fas fa-arrow-left"></i> Cancel
</a>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> Save Todo
</button>
</div>
</form>
@endsection
Create the edit form at resources/views/todos/edit.blade.php:
@extends('layouts.app')
@section('title', 'Edit Todo')
@section('content')
<form action="{{ route('todos.update', $todo->id) }}" method="POST">
@csrf
@method('PUT')
<div class="mb-3">
<label for="title" class="form-label">Title *</label>
<input type="text" class="form-control @error('title') is-invalid @enderror"
id="title" name="title" value="{{ old('title', $todo->title) }}" required>
@error('title')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="mb-3">
<label for="description" class="form-label">Description</label>
<textarea class="form-control @error('description') is-invalid @enderror"
id="description" name="description" rows="3">{{ old('description', $todo->description) }}</textarea>
@error('description')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
<a href="{{ route('todos.index') }}" class="btn btn-secondary me-md-2">
<i class="fas fa-arrow-left"></i> Cancel
</a>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> Update Todo
</button>
</div>
</form>
@endsection
Create the show view at resources/views/todos/show.blade.php:
@extends('layouts.app')
@section('title', 'Todo Details')
@section('content')
<div class="mb-3">
<h5>{{ $todo->title }}</h5>
<div class="form-check">
<input type="checkbox"
class="form-check-input todo-status"
data-todo-id="{{ $todo->id }}"
{{ $todo->completed ? 'checked' : '' }}>
<label class="form-check-label {{ $todo->completed ? 'text-decoration-line-through' : '' }}">
{{ $todo->completed ? 'Completed' : 'Pending' }}
</label>
</div>
</div>
@if($todo->description)
<div class="mb-3">
<label class="form-label">Description:</label>
<p class="border p-2 rounded">{{ $todo->description }}</p>
</div>
@endif
<div class="mb-3">
<label class="form-label">Created:</label>
<p>{{ $todo->created_at->format('M d, Y h:i A') }}</p>
</div>
<div class="mb-3">
<label class="form-label">Last Updated:</label>
<p>{{ $todo->updated_at->format('M d, Y h:i A') }}</p>
</div>
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
<a href="{{ route('todos.index') }}" class="btn btn-secondary me-md-2">
<i class="fas fa-arrow-left"></i> Back to List
</a>
<a href="{{ route('todos.edit', $todo->id) }}" class="btn btn-warning me-md-2">
<i class="fas fa-edit"></i> Edit
</a>
<form action="{{ route('todos.destroy', $todo->id) }}" method="POST" class="d-inline">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-danger" onclick="return confirm('Are you sure?')">
<i class="fas fa-trash"></i> Delete
</button>
</form>
</div>
@endsection
@push('scripts')
<script>
$(document).ready(function() {
$('.todo-status').change(function() {
const todoId = $(this).data('todo-id');
const completed = $(this).is(':checked') ? 1 : 0;
$.ajax({
url: "{{ route('todos.updateStatus', '') }}/" + todoId,
type: 'PUT',
data: {
completed: completed,
_token: "{{ csrf_token() }}"
},
success: function(response) {
location.reload();
},
error: function(xhr) {
alert('Error updating status');
}
});
});
});
</script>
@endpush
Adding Form Validation
We've already added basic validation in our controller's store and update methods. Laravel will automatically redirect back with errors if validation fails, and our views are set up to display these errors using the @error directive.
Implementing CRUD Operations
Our TodoController already implements all CRUD operations:
- Create:
create()andstore()methods - Read:
index()andshow()methods - Update:
edit()andupdate()methods - Delete:
destroy()method
Adding Status Updates
We've added a special updateStatus method to handle AJAX requests for changing the completion status of todos. This is triggered by the checkbox in our views and uses jQuery to send an AJAX request.
Final Touches
Let's add some custom CSS to make our app look better. Create resources/css/app.css:
/* Custom styles for our todo app */
body {
background-color: #f8f9fa;
}
.card {
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
}
.list-group-item {
transition: all 0.3s ease;
}
.list-group-item:hover {
background-color: #f8f9fa;
}
.form-check-label.text-decoration-line-through {
color: #6c757d;
}
.btn-group .btn {
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
}
Update your vite.config.js to include Bootstrap:
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
export default defineConfig({
plugins: [
laravel({
input: [
'resources/css/app.css',
'resources/js/app.js'
],
refresh: true,
}),
],
});
Create resources/js/app.js:
// Import Bootstrap JS
import 'bootstrap';
Now compile your assets:
npm run build
Conclusion
Congratulations! You've built a complete todo list application with Laravel 12 and Bootstrap 5. This application includes:
- Full CRUD functionality
- AJAX status updates
- Responsive Bootstrap design
- Form validation
- Clean, organized code following Laravel best practices
To run your application:
php artisan serve
Then visit http://localhost:8000 in your browser.
Further Improvements
- Add user authentication
- Implement categories or tags for todos
- Add due dates and reminders
- Implement drag-and-drop sorting
- Add search functionality
This tutorial provides a solid foundation for building Laravel applications with Bootstrap. You can now expand this basic todo list into a more sophisticated productivity application!
Projects to explore next
Source-code projects from the KritiMyantra catalogue. Check each project's details before buying.
Flask Authentication System (Free Version) - Kritim Yantra
🛠 Features✔ Google & GitHub Authentication✔ User Registration & Login✔ Profile/Dashboard Access✔ Secure & Mi...
Flask Blog CMS with Admin Dashboard — Python Source Code
A Flask-based blogging website with a built-in admin dashboard. Manage articles, images, categories, tags and featured p...
Laravel 12 + ReactJS Starter Kit: Add Multi-Language Support (Arabic, English, Spanish) with Blog CRUD
🚀 Installation & Setup Download & Extract Get the ZIP from kritimyantra.com and extract it. Includes preconfigu...
Comments
No comments yet. Be the first to comment.
Sign in to join the discussion.