Creating a Todo List in Laravel 12 with Bootstrap Design

Author

Kritim Yantra

Apr 04, 2025

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

  1. Introduction
  2. Prerequisites
  3. Project Setup
  4. Database Configuration
  5. Creating the Todo Model and Migration
  6. Building the Controller
  7. Creating Routes
  8. Designing Views with Bootstrap
  9. Adding Form Validation
  10. Implementing CRUD Operations
  11. Adding Status Updates
  12. Final Touches
  13. 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() and store() methods
  • Read: index() and show() methods
  • Update: edit() and update() 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:

  1. Full CRUD functionality
  2. AJAX status updates
  3. Responsive Bootstrap design
  4. Form validation
  5. Clean, organized code following Laravel best practices

To run your application:

php artisan serve

Then visit http://localhost:8000 in your browser.

Further Improvements

  1. Add user authentication
  2. Implement categories or tags for todos
  3. Add due dates and reminders
  4. Implement drag-and-drop sorting
  5. 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!

Tags

Laravel Php Bootstrap

Comments

No comments yet. Be the first to comment!

Please log in to post a comment:

Continue with Google

Related Posts