Build a Task Manager with Laravel

Build a database-driven task manager app using Laravel 12 and MVC.

Introduction

30 Second Summary

Everyone has a to-do list. Whether it is sticky notes on your desk or a mental checklist before bed, managing tasks is something you already do every day.

In this project, you will build a task manager web application using Laravel, one of the most popular PHP frameworks. You will create, view, edit, and delete tasks through a clean browser interface backed by a real database.

What You'll Build

A full-stack task manager application using Laravel 12 with a database, server-side rendering, and complete create-read-update-delete functionality.

By the end of this project, you'll have:

  • A Laravel 12 project with a Task model, migration, and controller following the MVC pattern.
  • A set of Blade templates for listing, creating, and editing tasks with form validation.
  • RESTful routes and Eloquent ORM queries powering full CRUD operations against a database.
  • Secret Mission: Add task priority levels with color-coded badges and the ability to filter tasks by priority.

Are there any prerequisites?

No prior experience with PHP or Laravel is required. Step 1 walks you through installing everything you need from scratch, including PHP, Composer, and the Laravel installer.

Before We Start

Before We Start...

Install PHP, Laravel, and Set Up Your Project

Every Laravel project needs three things on your machine: PHP (the language Laravel is built with), Composer (PHP's package manager), and the Laravel installer.

The good news is that Laravel provides a single command that installs all three at once. In this step, you will get everything installed, create your project, and see the Laravel welcome page in your browser.

In this step, get ready to:
  • Install PHP, Composer, and the Laravel installer.
  • Create a new Laravel project called task-manager.
  • Start the development server and see the Laravel welcome page.
Install PHP, Composer, and the Laravel installer

Laravel provides a single command that downloads and installs PHP, Composer, and the Laravel installer all at once. The command differs depending on your operating system.

  • Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows) and searching for Terminal (macOS) or PowerShell (Windows).
  • On Windows, right-click PowerShell and select Run as administrator.
  • Install PHP, Composer, and the Laravel installer by running the command for your operating system:

macOS

/bin/bash -c "$(curl -fsSL https://php.new/install/mac/8.4)"

Windows

Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; iex ((New-Object System.Net.WebClient).DownloadString('https://php.new/install/windows/8.4'))

What does this command do?

This downloads and runs an installer script from php.new that installs PHP 8.4, Composer, and the Laravel installer as binaries on your machine. It does not interfere with any existing development setup.

  • After the installation finishes, close your terminal completely and reopen it.

Why restart the terminal?

The installer adds PHP, Composer, and the Laravel installer to your system PATH. Your terminal needs to restart to pick up these new paths. Without restarting, you will get "command not found" errors.

  • Verify that PHP installed successfully by running:
php -v

✔️ I see PHP 8.4 or higher

PHP is installed and ready to go.

ⓧ I see an older version

Re-run the install command for your operating system from the step above. The installer will update PHP to 8.4. Close and reopen your terminal after it finishes.

ⓧ Command not found

This means the terminal has not picked up the new PATH yet.

  • Make sure you fully closed and reopened your terminal after running the install command.
  • If it still does not work, try running the install command again.
Create a new Laravel application

With PHP and the Laravel installer ready, you can create a new project. The Laravel installer will ask a few questions about how you want your project configured.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your Laravel project by running:
laravel new task-manager

The installer will ask you several questions. Answer each one as follows:

  • For the Starter Kit prompt, select None.
  • For the Database prompt, select sqlite.
  • For the Run npm install prompt, select yes.

Why these choices?

Selecting None for the starter kit means you will build the views yourself from scratch. Starter kits add authentication scaffolding that would distract from learning the core MVC patterns.

SQLite requires zero configuration. Laravel creates the database file automatically, so you do not need to install MySQL or PostgreSQL.

  • After the installer finishes, move into your new project folder by running:
cd task-manager
  • Confirm the project was created by running:
ls

You should see folders like app, routes, resources, and database in the output.

Seeing a command not found error?

Make sure you restarted your terminal after running the PHP install command. If laravel is not recognized, the installer did not finish correctly. Re-run the install command for your operating system and restart your terminal.

Help me fix a command not found error when running laravel new

Start the development server

Laravel includes a built-in development server so you can preview your app in the browser without any extra setup.

  • Make sure you are inside the task-manager folder on your Desktop, then start the development server by running:
composer run dev

What does this command do?

This command starts three things at once: the PHP development server, the Vite asset compiler for CSS and JavaScript, and a queue worker. Together, they give you a live development environment.

  • Open your browser and navigate to http://localhost:8000.

You should see the Laravel welcome page with the Laravel logo and links to documentation.

Don't see the welcome page?

Make sure the composer run dev command is still running in your terminal. If you see an error about port 8000 being in use, another application is using that port. Close the other application and try again.

Help me troubleshoot the Laravel development server not loading

Keep this terminal running

The development server needs to stay running while you work. When you need to run other commands in future steps, open a second terminal tab or window.

  • Open your code editor by pressing Cmd+Space (macOS) or the Windows key (Windows) and searching for VS Code or Cursor.
  • In your editor, click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop and select the task-manager folder, then click Open.

You should see the full Laravel project structure in the sidebar, including folders like app, routes, and resources.

✔️ Awesome, I've got everything!

Great. Make sure your development server is still running and your editor has the task-manager folder open.

ⓧ I'd like to double check the full code

Here is the default routes/web.php file that comes with a fresh Laravel project. You have not edited this file yet, but confirm it exists in your project:

<?php

use Illuminate\Support\Facades\Route;

Route::get('/', function () {
    return view('welcome');
});

Your development environment is ready. Next up, you will create the Task model and database table that stores your tasks.

Create the Task Model and Migration

Your Laravel project is set up and the welcome page is loading at http://localhost:8000. Now it's time to define the data your app will work with.

Every database-driven app needs a way to define what data it stores and how that data maps to code. In Laravel, you do this with migrations (which create database tables) and models (which let your PHP code interact with those tables).

In this step, get ready to:
  • Generate a Task model, migration, and controller with a single Artisan command.
  • Define the tasks table schema with columns for title, description, and completion status.
  • Run the migration to create the table in your SQLite database.
Generate the Task model with a migration

Laravel's Artisan CLI can generate a model, its corresponding migration file, and a controller all in one command. This saves you from creating each file manually.

Your development server is currently running in your terminal. You need a second terminal window to run Artisan commands.

  • Open a new terminal window or tab.
  • Navigate to your project folder by running this command:
cd ~/Desktop/task-manager
  • Generate the Task model, migration, and controller by running this command:
php artisan make:model Task -mc

What does this command do?

The make:model command creates a new Eloquent model file. The flags add extras.

  • -m creates a database migration file alongside the model.
  • -c creates a controller file where you will later write your request-handling logic.
  • You should see output confirming three files were created: the model, migration, and controller.
  • Confirm the files exist by running:
ls app/Models/Task.php app/Http/Controllers/TaskController.php database/migrations/*create_tasks_table*

You should see all three file paths listed in the output.

Command not found?

Make sure you are inside the task-manager folder. Artisan commands only work from the project root where artisan lives.

If php itself is not found, close and reopen your terminal so the PATH changes from Step 1 take effect.

help me troubleshoot the php artisan make:model command

Define the tasks table schema

The generated migration file contains an empty table definition. You need to tell Laravel what columns the tasks table should have.

  • In your code editor, open the migration file inside database/migrations/. The filename ends with _create_tasks_table.php (it starts with a timestamp).
  • Find the up() method. It currently has just $table->id() and $table->timestamps().
  • Replace the entire up() method with the following code:
public function up(): void
{
    Schema::create('tasks', function (Blueprint $table) {
        $table->id();
        // Task title - required field
        $table->string('title');
        // Optional longer description
        $table->text('description')->nullable();
        // Track whether the task is done
        $table->boolean('is_complete')->default(false);
        $table->timestamps();
    });
}

What does this code do?

  • $table->string('title') creates a VARCHAR column for short text (up to 255 characters).
  • $table->text('description')->nullable() creates a TEXT column that can be left empty. The nullable() call means this field is optional.
  • $table->boolean('is_complete')->default(false) creates a true/false column that starts as false for every new task.
  • Save the migration file.

Now you need to tell the Task model which fields can be filled in when creating or updating a task. Without this, Laravel blocks mass-assignment for security.

  • Open app/Models/Task.php in your code editor.
  • Replace the entire file contents with the following code:
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Task extends Model
{
    // Allow these fields to be set when creating/updating tasks
    protected $fillable = [
        'title',
        'description',
        'is_complete',
    ];

    // Cast is_complete from a database integer to a PHP boolean
    protected function casts(): array
    {
        return [
            'is_complete' => 'boolean',
        ];
    }
}

What does this code do?

  • $fillable lists the columns that can be set using mass assignment (like Task::create([...])). This is a security feature that prevents unexpected fields from being injected.
  • casts() tells Laravel to automatically convert is_complete between a database integer (0 or 1) and a PHP boolean (true or false).
  • Save app/Models/Task.php.

Not sure which file to edit?

In the file explorer panel on the left side of your code editor, expand the app folder, then Models. You should see Task.php there. For the migration, expand database, then migrations.

help me find the right files to edit in my Laravel project

✔️ Awesome, I've got everything!

Great. Double check you have saved both files before moving on.

ⓧ I'd like to double check the full code

Here is the complete migration file:

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('tasks', function (Blueprint $table) {
            $table->id();
            $table->string('title');
            $table->text('description')->nullable();
            $table->boolean('is_complete')->default(false);
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('tasks');
    }
};

Here is the complete Task model:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Task extends Model
{
    protected $fillable = [
        'title',
        'description',
        'is_complete',
    ];

    protected function casts(): array
    {
        return [
            'is_complete' => 'boolean',
        ];
    }
}

Here is the generated TaskController (it stays empty for now):

<?php

namespace App\Http\Controllers;

use App\Models\Task;
use Illuminate\Http\Request;

class TaskController extends Controller
{
}
Run the migration

Your migration file describes the table structure, but the table does not exist in the database yet. Running the migration executes the schema and creates the actual tasks table in your SQLite database.

  • Run the migration by running this command:
php artisan migrate
  • You should see output showing each migration being run, including a line that references create_tasks_table.

What just happened?

Laravel read every migration file in database/migrations/ and executed the ones that have not run before. Your tasks table now exists inside the database/database.sqlite file. Laravel also ran a few default migrations (for sessions, cache, etc.) that ship with every new project.

Seeing a "nothing to migrate" message?

This means the migrations already ran at some point (possibly during project creation). That is fine. Your tasks table already exists.

If you see an error about the table already existing, you can reset and re-run all migrations with php artisan migrate:fresh. This drops all tables and recreates them from scratch.

help me fix my Laravel migration issue

Your data layer is ready. You have a Task model that knows which fields to accept, a migration that built the database table, and a controller stub waiting for logic. Next up, you will wire up routes and fill in that controller so your app can actually create and display tasks.

Build the Task Controller and Routes

Your database has a tasks table ready to store data. Now you need a way for users to actually interact with it through their browser.

In Laravel, controllers handle incoming requests and routes map URLs to controller methods. You'll wire these up so your app knows what to do when someone visits a page or submits a form.

In this step, get ready to:
  • Define resource routes that map URLs to controller actions.
  • Implement controller methods for reading and displaying tasks.
  • Add validation logic for creating and updating tasks.
Define resource routes for tasks

Routes tell Laravel which controller method to call for each URL. A resource route creates multiple routes at once for standard CRUD operations (Create, Read, Update, Delete).

  • Open routes/web.php in your editor.
  • Replace the entire file contents with the following code:
<?php

use App\Http\Controllers\TaskController;
use Illuminate\Support\Facades\Route;

// Redirect the homepage to the task list
Route::get('/', function () {
    return redirect()->route('tasks.index');
});

// Create all standard CRUD routes except 'show'
Route::resource('tasks', TaskController::class)->except(['show']);

// Custom route for toggling task completion
Route::patch('/tasks/{task}/toggle', [TaskController::class, 'toggleComplete'])->name('tasks.toggle');

What does this code do?

Route::get('/') redirects anyone visiting the homepage straight to your task list. No need for a separate landing page.

Route::resource('tasks', TaskController::class) automatically creates routes for index, create, store, edit, update, and destroy actions. The ->except(['show']) part skips the single-task detail page since our app won't need one.

The final Route::patch line adds a custom route for toggling a task's completion status. This isn't part of the standard resource routes, so you define it separately.

  • Save routes/web.php.
  • Verify your routes registered correctly by running this command in a new terminal window:
php artisan route:list

You should see a table listing routes like GET tasks, POST tasks, PUT tasks/{task}, DELETE tasks/{task}, and PATCH tasks/{task}/toggle.

Seeing an error about TaskController?

If you see a "class not found" or "target class does not exist" error, make sure the use statement at the top of web.php matches the exact namespace: App\Http\Controllers\TaskController. Double-check for typos in the class name.

help me fix my route:list error for TaskController

Implement the index and create methods

Your routes now point to methods that don't exist yet. The simplest methods are the ones that just fetch data and return a view.

  • Open app/Http/Controllers/TaskController.php.
  • Find the empty class body (between the curly braces of class TaskController extends Controller).
  • Add the following three methods inside the class:
    public function index()
    {
        // Retrieve all tasks, newest first
        $tasks = Task::latest()->get();

        // Pass the tasks collection to the index view
        return view('tasks.index', compact('tasks'));
    }

    public function create()
    {
        // Show the form for creating a new task
        return view('tasks.create');
    }

    public function edit(Task $task)
    {
        // Show the form for editing an existing task
        return view('tasks.edit', compact('task'));
    }

What does this code do?

The index() method uses Task::latest()->get() to fetch all tasks from the database ordered by newest first. It passes them to a Blade view called tasks.index.

The create() method returns a blank form view. The edit() method receives a Task $task parameter. Laravel automatically looks up the task by its ID from the URL. This is called route model binding.

  • Save TaskController.php.

You won't see a change yet in the browser because the views these methods reference don't exist. You'll build those in the next step.

Implement the store and remaining controller methods

The store, update, destroy, and toggle methods handle data changes. They validate input, modify the database, and redirect the user back to the task list.

  • In TaskController.php, add the following methods below the edit() method you just added:
    public function store(Request $request)
    {
        // Validate that title is present and not too long
        $validated = $request->validate([
            'title' => 'required|max:255',
            'description' => 'nullable|max:1000',
        ]);

        // Create a new task with the validated data
        Task::create($validated);

        // Redirect back to the task list with a success message
        return redirect()->route('tasks.index')->with('success', 'Task created successfully!');
    }

    public function edit(Task $task)
    {
        return view('tasks.edit', compact('task'));
    }

    public function update(Request $request, Task $task)
    {
        // Same validation rules as store
        $validated = $request->validate([
            'title' => 'required|max:255',
            'description' => 'nullable|max:1000',
        ]);

        // Update the existing task with new data
        $task->update($validated);

        return redirect()->route('tasks.index')->with('success', 'Task updated successfully!');
    }

    public function destroy(Task $task)
    {
        // Remove the task from the database
        $task->delete();

        return redirect()->route('tasks.index')->with('success', 'Task deleted successfully!');
    }

    public function toggleComplete(Task $task)
    {
        // Flip the is_complete value
        $task->update(['is_complete' => !$task->is_complete]);

        return redirect()->route('tasks.index');
    }

What does this code do?

The store() method validates incoming form data before saving. The 'required|max:255' rule means the title field cannot be empty and must be 255 characters or fewer. If validation fails, Laravel automatically redirects back with error messages.

The update() method applies the same validation rules but updates an existing task instead of creating a new one. The destroy() method deletes the task entirely.

The toggleComplete() method flips the is_complete boolean. If a task is incomplete, toggling marks it complete, and vice versa.

Every method ends with redirect()->route('tasks.index') to send the user back to the task list after the action completes. The ->with('success', ...) part stores a one-time flash message that disappears on the next page load.

What is form validation?

Validation ensures users can't submit garbage data. Without it, someone could create a task with no title or a description that's thousands of characters long. Laravel's built-in validation handles the checking and error messaging for you.

  • Save TaskController.php.
  • Run php artisan route:list again to confirm no errors appear. Your routes should still display the same table as before.

Seeing a syntax error?

Check that every method has matching opening and closing curly braces. Make sure you didn't accidentally paste the methods outside the TaskController class body.

help me fix a syntax error in my TaskController

✔️ Awesome, I've got everything!

Great. Double check you've saved both files.

ⓧ I'd like to double check the full code

Here is the complete routes/web.php file:

<?php

use App\Http\Controllers\TaskController;
use Illuminate\Support\Facades\Route;

Route::get('/', function () {
    return redirect()->route('tasks.index');
});

Route::resource('tasks', TaskController::class)->except(['show']);
Route::patch('/tasks/{task}/toggle', [TaskController::class, 'toggleComplete'])->name('tasks.toggle');

Here is the complete TaskController.php file:

<?php

namespace App\Http\Controllers;

use App\Models\Task;
use Illuminate\Http\Request;

class TaskController extends Controller
{
    public function index()
    {
        $tasks = Task::latest()->get();

        return view('tasks.index', compact('tasks'));
    }

    public function create()
    {
        return view('tasks.create');
    }

    public function store(Request $request)
    {
        $validated = $request->validate([
            'title' => 'required|max:255',
            'description' => 'nullable|max:1000',
        ]);

        Task::create($validated);

        return redirect()->route('tasks.index')->with('success', 'Task created successfully!');
    }

    public function edit(Task $task)
    {
        return view('tasks.edit', compact('task'));
    }

    public function update(Request $request, Task $task)
    {
        $validated = $request->validate([
            'title' => 'required|max:255',
            'description' => 'nullable|max:1000',
        ]);

        $task->update($validated);

        return redirect()->route('tasks.index')->with('success', 'Task updated successfully!');
    }

    public function destroy(Task $task)
    {
        $task->delete();

        return redirect()->route('tasks.index')->with('success', 'Task deleted successfully!');
    }

    public function toggleComplete(Task $task)
    {
        $task->update(['is_complete' => !$task->is_complete]);

        return redirect()->route('tasks.index');
    }
}

Your controller and routes are wired up. Next, you'll build the Blade views that display your tasks and provide forms for creating new ones.

Create Blade Views for Listing and Creating Tasks

Your controller and routes are ready to handle requests, but right now visiting http://localhost:8000 throws an error because the views don't exist yet.

In Laravel's MVC pattern, views are the layer that users actually see and interact with. Laravel uses a templating engine called Blade that lets you write HTML with special directives for loops, conditionals, and reusable components.

In this step, get ready to:
  • Create a reusable layout component with Tailwind CSS styling.
  • Build a task list view that displays tasks with toggle, edit, and delete controls.
  • Build a create task form with CSRF protection and validation error display.
Create a layout component

Before building individual pages, you need a shared layout that wraps every page with consistent HTML structure and styling. Laravel's anonymous Blade components let you define a layout once and reuse it across all views.

  • In VS Code, open the Explorer sidebar on the left.
  • Right-click on the resources/views folder and select New Folder.
  • Name the folder components.
  • Right-click on the new components folder and select New File.
  • Name the file layout.blade.php.
  • Paste the following code into the file:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{{ $title ?? 'Task Manager' }}</title>
    @vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body class="bg-gray-100 min-h-screen">
    <div class="max-w-2xl mx-auto py-8 px-4">
        <h1 class="text-3xl font-bold text-gray-800 mb-8">Task Manager</h1>

        @if(session('success'))
            <div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4">
                {{ session('success') }}
            </div>
        @endif

        {{ $slot }}
    </div>
</body>
</html>

What does this code do?

This is an anonymous Blade component. Any file inside resources/views/components/ is automatically available as a tag like <x-layout>.

The @vite directive loads Tailwind CSS (included in Laravel 12 out of the box) so all pages get styled automatically. The {{ $slot }} variable is where each page's unique content gets injected.

The @if(session('success')) block displays green flash messages when a task is created, updated, or deleted. Your controller already sends these messages using the ->with('success', ...) method.

  • Save layout.blade.php.

Not sure where to create the file?

The full path from your project root is resources/views/components/layout.blade.php. Make sure you created the components folder inside resources/views/, not inside resources/ directly.

help me find where to put my Blade layout file

Build the task list view

The index view is the main page of your app. It shows all tasks and provides controls to toggle completion, edit, and delete each one.

  • Right-click on the resources/views folder and select New Folder.
  • Name the folder tasks.
  • Right-click on the new tasks folder and select New File.
  • Name the file index.blade.php.
  • Paste the following code into the file:
<x-layout title="All Tasks">
    <div class="mb-4">
        <a href="{{ route('tasks.create') }}" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
            Add New Task
        </a>
    </div>

    @if($tasks->isEmpty())
        <p class="text-gray-500">No tasks yet. Create your first task!</p>
    @else
        <div class="space-y-3">
            @foreach($tasks as $task)
                <div class="bg-white p-4 rounded-lg shadow flex items-center justify-between">
                    <div class="flex items-center gap-3">
                        <form action="{{ route('tasks.toggle', $task) }}" method="POST">
                            @csrf
                            @method('PATCH')
                            <button type="submit" class="w-6 h-6 rounded border-2 flex items-center justify-center {{ $task->is_complete ? 'bg-green-500 border-green-500 text-white' : 'border-gray-300' }}">
                                @if($task->is_complete)
                                    &#10003;
                                @endif
                            </button>
                        </form>
                        <div>
                            <h3 class="font-semibold {{ $task->is_complete ? 'line-through text-gray-400' : 'text-gray-800' }}">
                                {{ $task->title }}
                            </h3>
                            @if($task->description)
                                <p class="text-sm text-gray-500">{{ $task->description }}</p>
                            @endif
                        </div>
                    </div>
                    <div class="flex gap-2">
                        <a href="{{ route('tasks.edit', $task) }}" class="text-blue-500 hover:text-blue-700 text-sm">Edit</a>
                        <form action="{{ route('tasks.destroy', $task) }}" method="POST" onsubmit="return confirm('Delete this task?')">
                            @csrf
                            @method('DELETE')
                            <button type="submit" class="text-red-500 hover:text-red-700 text-sm">Delete</button>
                        </form>
                    </div>
                </div>
            @endforeach
        </div>
    @endif
</x-layout>

What does this code do?

The view wraps everything in <x-layout>, which injects this page's content into the {{ $slot }} of your layout component. The title="All Tasks" attribute sets the browser tab title.

Each task card contains a toggle-complete form that sends a PATCH request, and a delete form that sends a DELETE request. HTML forms only support GET and POST natively, so the @method('PATCH') and @method('DELETE') directives add a hidden field that tells Laravel to route the request correctly.

The @csrf directive is required on every form. It adds a hidden token that protects against cross-site request forgery attacks. Without it, Laravel rejects the form submission with a 419 error.

  • Save index.blade.php.
  • Make sure your development server is still running (composer run dev in your terminal).
  • Open your browser to http://localhost:8000. You should see the "Task Manager" heading and a blue "Add New Task" button with the message "No tasks yet. Create your first task!" below it.

Seeing an error instead of your task list?

If you see "View [tasks.index] not found", check that your file is at exactly resources/views/tasks/index.blade.php. The folder must be named tasks (not task) and the file extension must be .blade.php (not just .php).

If you see unstyled HTML with no Tailwind classes applied, make sure your dev server was started with composer run dev (not php artisan serve). The composer run dev command starts both the Laravel server and the Vite development server that compiles your CSS.

help me debug why my task list view isn't loading

Add a create task form

The create view gives users a form to add new tasks. It needs to handle CSRF protection, display validation errors when the user submits an empty title, and remember previously entered values if validation fails.

  • Right-click on the tasks folder inside resources/views/ and select New File.
  • Name the file create.blade.php.
  • Paste the following code into the file:
<x-layout title="Create Task">
    <div class="bg-white p-6 rounded-lg shadow">
        <h2 class="text-xl font-semibold mb-4">Create New Task</h2>

        <form action="{{ route('tasks.store') }}" method="POST">
            @csrf

            <div class="mb-4">
                <label for="title" class="block text-sm font-medium text-gray-700 mb-1">Title</label>
                <input type="text" name="title" id="title" value="{{ old('title') }}"
                    class="w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
                    required>
                @error('title')
                    <p class="text-red-500 text-sm mt-1">{{ $message }}</p>
                @enderror
            </div>

            <div class="mb-4">
                <label for="description" class="block text-sm font-medium text-gray-700 mb-1">Description (optional)</label>
                <textarea name="description" id="description" rows="3"
                    class="w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">{{ old('description') }}</textarea>
                @error('description')
                    <p class="text-red-500 text-sm mt-1">{{ $message }}</p>
                @enderror
            </div>

            <div class="flex gap-2">
                <button type="submit" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
                    Create Task
                </button>
                <a href="{{ route('tasks.index') }}" class="bg-gray-300 hover:bg-gray-400 text-gray-800 font-bold py-2 px-4 rounded">
                    Cancel
                </a>
            </div>
        </form>
    </div>
</x-layout>

What does this code do?

The form posts to route('tasks.store'), which maps to the store() method in your TaskController. That method validates the input and creates a new task in the database.

The old('title') helper repopulates the input with the user's previous entry if validation fails. This way the user does not lose their typed text when they fix an error.

The @error('title') directive checks if validation failed for that field and displays the error message in red below the input.

  • Save create.blade.php.
  • Go back to your browser at http://localhost:8000 and click the blue Add New Task button.
  • You should see the "Create New Task" form with a title input, description textarea, and Create Task/Cancel buttons.
  • Try submitting the form with a title like Buy groceries and an optional description. You should be redirected back to the task list with your new task displayed and a green success message at the top.

Form submits but nothing happens?

If you see a 419 error after submitting, make sure your form includes the @csrf directive right after the opening <form> tag. Laravel rejects POST requests without a valid CSRF token.

If you see an error about mass assignment, check that your Task model's $fillable array includes 'title' and 'description'.

help me fix my create task form submission

✔️ Awesome, I've got everything!

Great. Double check you have saved all three view files before moving on.

ⓧ I'd like to double check the full code

Here is the full code for all three view files. Compare them with yours to find any differences.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{{ $title ?? 'Task Manager' }}</title>
    @vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body class="bg-gray-100 min-h-screen">
    <div class="max-w-2xl mx-auto py-8 px-4">
        <h1 class="text-3xl font-bold text-gray-800 mb-8">Task Manager</h1>

        @if(session('success'))
            <div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4">
                {{ session('success') }}
            </div>
        @endif

        {{ $slot }}
    </div>
</body>
</html>
<x-layout title="All Tasks">
    <div class="mb-4">
        <a href="{{ route('tasks.create') }}" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
            Add New Task
        </a>
    </div>

    @if($tasks->isEmpty())
        <p class="text-gray-500">No tasks yet. Create your first task!</p>
    @else
        <div class="space-y-3">
            @foreach($tasks as $task)
                <div class="bg-white p-4 rounded-lg shadow flex items-center justify-between">
                    <div class="flex items-center gap-3">
                        <form action="{{ route('tasks.toggle', $task) }}" method="POST">
                            @csrf
                            @method('PATCH')
                            <button type="submit" class="w-6 h-6 rounded border-2 flex items-center justify-center {{ $task->is_complete ? 'bg-green-500 border-green-500 text-white' : 'border-gray-300' }}">
                                @if($task->is_complete)
                                    &#10003;
                                @endif
                            </button>
                        </form>
                        <div>
                            <h3 class="font-semibold {{ $task->is_complete ? 'line-through text-gray-400' : 'text-gray-800' }}">
                                {{ $task->title }}
                            </h3>
                            @if($task->description)
                                <p class="text-sm text-gray-500">{{ $task->description }}</p>
                            @endif
                        </div>
                    </div>
                    <div class="flex gap-2">
                        <a href="{{ route('tasks.edit', $task) }}" class="text-blue-500 hover:text-blue-700 text-sm">Edit</a>
                        <form action="{{ route('tasks.destroy', $task) }}" method="POST" onsubmit="return confirm('Delete this task?')">
                            @csrf
                            @method('DELETE')
                            <button type="submit" class="text-red-500 hover:text-red-700 text-sm">Delete</button>
                        </form>
                    </div>
                </div>
            @endforeach
        </div>
    @endif
</x-layout>
<x-layout title="Create Task">
    <div class="bg-white p-6 rounded-lg shadow">
        <h2 class="text-xl font-semibold mb-4">Create New Task</h2>

        <form action="{{ route('tasks.store') }}" method="POST">
            @csrf

            <div class="mb-4">
                <label for="title" class="block text-sm font-medium text-gray-700 mb-1">Title</label>
                <input type="text" name="title" id="title" value="{{ old('title') }}"
                    class="w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
                    required>
                @error('title')
                    <p class="text-red-500 text-sm mt-1">{{ $message }}</p>
                @enderror
            </div>

            <div class="mb-4">
                <label for="description" class="block text-sm font-medium text-gray-700 mb-1">Description (optional)</label>
                <textarea name="description" id="description" rows="3"
                    class="w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">{{ old('description') }}</textarea>
                @error('description')
                    <p class="text-red-500 text-sm mt-1">{{ $message }}</p>
                @enderror
            </div>

            <div class="flex gap-2">
                <button type="submit" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
                    Create Task
                </button>
                <a href="{{ route('tasks.index') }}" class="bg-gray-300 hover:bg-gray-400 text-gray-800 font-bold py-2 px-4 rounded">
                    Cancel
                </a>
            </div>
        </form>
    </div>
</x-layout>

Your views are working and you can create tasks through the browser. Next up, you will add the ability to edit existing tasks, delete them, and toggle their completion status directly from the task list.

Add Edit, Update, and Delete Functionality

In the last step, you built the task list and create form. Your app can display tasks and add new ones to the database.

Now it is time to complete the picture. The controller methods for editing, deleting, and toggling tasks are already in place. The missing piece is the edit view, which gives users a form to modify existing tasks. Once that is built, your full CRUD lifecycle will be functional.

In this step, get ready to:
  • Create an edit view with a pre-filled form for updating tasks.
  • Understand how HTTP method spoofing enables PUT, DELETE, and PATCH requests.
  • Test the full CRUD lifecycle end-to-end in your browser.
Create the edit view with a pre-filled form

The edit form looks almost identical to the create form. The key differences are that it pre-fills the fields with the existing task data and uses a PUT method to send the update.

  • Create a new file at resources/views/tasks/edit.blade.php and paste the following code:
<x-layout title="Edit Task">
    <div class="bg-white p-6 rounded-lg shadow">
        <h2 class="text-xl font-semibold mb-4">Edit Task</h2>

        <form action="{{ route('tasks.update', $task) }}" method="POST">
            @csrf
            @method('PUT')

            <div class="mb-4">
                <label for="title" class="block text-sm font-medium text-gray-700 mb-1">Title</label>
                <input type="text" name="title" id="title" value="{{ old('title', $task->title) }}"
                    class="w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
                    required>
                @error('title')
                    <p class="text-red-500 text-sm mt-1">{{ $message }}</p>
                @enderror
            </div>

            <div class="mb-4">
                <label for="description" class="block text-sm font-medium text-gray-700 mb-1">Description (optional)</label>
                <textarea name="description" id="description" rows="3"
                    class="w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">{{ old('description', $task->description) }}</textarea>
                @error('description')
                    <p class="text-red-500 text-sm mt-1">{{ $message }}</p>
                @enderror
            </div>

            <div class="flex gap-2">
                <button type="submit" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
                    Update Task
                </button>
                <a href="{{ route('tasks.index') }}" class="bg-gray-300 hover:bg-gray-400 text-gray-800 font-bold py-2 px-4 rounded">
                    Cancel
                </a>
            </div>
        </form>
    </div>
</x-layout>

What does this code do?

This view is structured like the create form, with a few important differences:

  • @method('PUT') tells Laravel to treat this form submission as a PUT request, which routes to the update method in your controller.
  • old('title', $task->title) pre-fills the input with the task's current title. If validation fails and the form reloads, it shows whatever the user last typed instead.
  • The form action points to route('tasks.update', $task), which generates a URL like /tasks/1 based on the task's ID.

Why use @method('PUT') instead of just POST?

HTML forms only support GET and POST natively. Laravel uses method spoofing to support PUT, PATCH, and DELETE. The @method('PUT') directive adds a hidden _method field to the form. Laravel reads this field and routes the request as if it were a real PUT request.

This same technique powers the delete button (@method('DELETE')) and the toggle-complete button (@method('PATCH')) in your task list view.

  • Save edit.blade.php.
  • In your browser, go to your task list at http://localhost:8000 and click Edit on any task. You should see a form pre-filled with the task's current title and description.

Don't see the edit form?

  • Make sure you saved the file as edit.blade.php inside resources/views/tasks/. If the file is in the wrong directory, Laravel cannot find it.
  • Check that your development server is still running. If you closed the terminal, restart it with composer run dev from inside the task-manager folder.
  • If you see a 404 error, make sure you have at least one task created first so the Edit link appears.

Why isn't my edit view loading in Laravel?

Understand how delete and toggle-complete work

Your task list already has working Delete and toggle-complete buttons. These use the same method spoofing technique as your edit form.

  • Open resources/views/tasks/index.blade.php and find the delete form near the bottom of the task card.
<form action="{{ route('tasks.destroy', $task) }}" method="POST" onsubmit="return confirm('Delete this task?')">
    @csrf
    @method('DELETE')
    <button type="submit" class="text-red-500 hover:text-red-700 text-sm">Delete</button>
</form>

What does this code do?

  • @method('DELETE') tells Laravel to route this form submission to the destroy method in your controller.
  • The onsubmit="return confirm(...)" adds a browser confirmation dialog so users don't accidentally delete a task.
  • @csrf protects against cross-site request forgery. Every form in Laravel needs this token, or submissions will fail with a 419 error.
  • Now find the toggle-complete form at the top of the task card:
<form action="{{ route('tasks.toggle', $task) }}" method="POST">
    @csrf
    @method('PATCH')
    <button type="submit" class="w-6 h-6 rounded border-2 flex items-center justify-center {{ $task->is_complete ? 'bg-green-500 border-green-500 text-white' : 'border-gray-300' }}">
        @if($task->is_complete)
            &#10003;
        @endif
    </button>
</form>

What does this code do?

This form uses @method('PATCH') to send a PATCH request to the toggleComplete method. The button styling changes dynamically based on whether $task->is_complete is true or false. A completed task shows a green checkbox with a checkmark. An incomplete task shows an empty gray border.

Test the full CRUD lifecycle

All the pieces are connected. Your task manager can create, read, update, and delete tasks. Time to verify everything works together.

  • Go to http://localhost:8000 in your browser.
  • Click Add New Task and create a task with a title and description.
  • Click Edit on the task you just created. Change the title and click Update Task.
  • Click the checkbox on the left of the task to mark it complete. You should see a green checkmark appear and the title becomes strikethrough text.
  • Click Delete on a task. Confirm the dialog and verify the task disappears from the list.

You should see a green success message at the top of the page after each create, update, and delete action.

Something not working?

  • If editing shows the old values after saving, make sure your edit form uses old('title', $task->title) with both arguments. The second argument is the fallback that pre-fills the form.
  • If delete does nothing, check that the delete form includes both @csrf and @method('DELETE'). Without the method directive, Laravel treats the submission as a regular POST.
  • If toggling does not change the checkbox appearance, verify your development server is still running with composer run dev so that Tailwind CSS classes are being compiled.

Help me debug my Laravel task manager CRUD operations.

✔️ Awesome, I've got everything!

Great. Double check you have saved edit.blade.php and that all CRUD operations work in your browser.

ⓧ I'd like to double check the full code

Here is the complete edit.blade.php file:

<x-layout title="Edit Task">
    <div class="bg-white p-6 rounded-lg shadow">
        <h2 class="text-xl font-semibold mb-4">Edit Task</h2>

        <form action="{{ route('tasks.update', $task) }}" method="POST">
            @csrf
            @method('PUT')

            <div class="mb-4">
                <label for="title" class="block text-sm font-medium text-gray-700 mb-1">Title</label>
                <input type="text" name="title" id="title" value="{{ old('title', $task->title) }}"
                    class="w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
                    required>
                @error('title')
                    <p class="text-red-500 text-sm mt-1">{{ $message }}</p>
                @enderror
            </div>

            <div class="mb-4">
                <label for="description" class="block text-sm font-medium text-gray-700 mb-1">Description (optional)</label>
                <textarea name="description" id="description" rows="3"
                    class="w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">{{ old('description', $task->description) }}</textarea>
                @error('description')
                    <p class="text-red-500 text-sm mt-1">{{ $message }}</p>
                @enderror
            </div>

            <div class="flex gap-2">
                <button type="submit" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
                    Update Task
                </button>
                <a href="{{ route('tasks.index') }}" class="bg-gray-300 hover:bg-gray-400 text-gray-800 font-bold py-2 px-4 rounded">
                    Cancel
                </a>
            </div>
        </form>
    </div>
</x-layout>

Secret mission

Add Priority Levels with Color-Coded Badges

Your task manager works great, but all tasks look the same. What if you could tag tasks as low, medium, or high priority and see them color-coded at a glance? In this secret mission, you'll add a priority system with filtering so you can focus on what matters most.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • The local development server started by composer run dev.
  • The task-manager project folder on your Desktop (includes the SQLite database).

Keep everything running

No action needed. Choose this if you are still actively building or plan to continue working on the project right now.

  • Your development server will keep running until you close the terminal window.
  • Your project files and SQLite database remain on your Desktop.

Pause - I'll come back to this later

Stop the running server to free up your terminal. Your files stay on disk so you can resume anytime.

  • In the terminal where composer run dev is running, press Ctrl+C to stop the server.
  • When you want to resume, open a terminal in the task-manager folder and run composer run dev again.

Delete - I don't want to use this again

Remove all project files and start fresh. This cannot be undone.

  • In the terminal where composer run dev is running, press Ctrl+C to stop the server.
  • Delete the project folder by running this command from your Desktop:

macOS

cd ~/Desktop
rm -rf task-manager

Windows

cd ~\Desktop
Remove-Item -Recurse -Force task-manager

This removes the entire project including the SQLite database and all task data.

That's a Wrap!

That's a Wrap!

Nice work! You just built a fully functional task manager web application with Laravel 12.

Here's what you accomplished:

  • Built a complete Laravel application using the MVC pattern with routes, controllers, models, and Blade views.
  • Created database migrations and used the Eloquent ORM for persistent data storage with SQLite.
  • Implemented full CRUD operations with form validation, CSRF protection, and RESTful routing conventions.
  • Added priority levels with color-coded badges and filtering to extend your task manager.

Ready to quiz yourself?