Full-Stack CRUD App with Laravel and React
Build a Task Manager app with a Laravel API, React frontend, and Docker.
Introduction
30 Second Summary
Every app you use daily has a frontend talking to a backend through an API. The to-do list on your phone, the project board at work, the shopping cart on your favourite store. They all follow the same pattern: a user interface sends requests to a server, and the server reads and writes data to a database.
In this project, you will build a Task Manager app with a Laravel backend API and a React frontend, all running inside Docker containers.
What You'll Build
A Task Manager application with a Laravel 12 REST API backend and a React 19 frontend, connected via HTTP requests and running in Docker containers managed by Laravel Sail.
By the end of this project, you'll have:
- A Laravel REST API with full CRUD endpoints for creating, reading, updating, and deleting tasks.
- A React frontend built with Vite that communicates with the API to manage tasks through a clean interface.
- A Dockerized development environment with Laravel, MySQL, and React all running via Docker Compose.
- Secret Mission: Add task filtering by status and a search bar to your Task Manager.
Are there any prerequisites?
No prior experience is needed. This guide includes full setup instructions for Docker, Laravel, React, and all other tools.
Set Up Docker and Create the Laravel Project
Every piece of your Task Manager app needs somewhere to run. The Laravel backend needs PHP, the database needs MySQL, and later your React frontend needs Node.js. Installing all of these directly on your machine can lead to version conflicts and messy configurations.
This is where Docker comes in. Docker packages each service into its own isolated container, so everything runs cleanly without interfering with your system. In this step, you'll install Docker, then create your Laravel project using Laravel Sail to get all your containers up and running.
In this step, get ready to:
- Install Docker Desktop and verify it is running.
- Create a new Laravel project with MySQL using Laravel Sail.
- Start the Docker containers and confirm the Laravel welcome page loads.
Install Docker Desktop
Docker Desktop is the application that runs containers on your machine. It provides the Docker engine, CLI tools, and a graphical interface for managing your containers.
- Visit the Docker Desktop download page at docker.com/get-started.
- Download the installer for your operating system.
macOS
- Double-click the downloaded Docker.dmg file to open the installer.
- Drag the Docker icon to the **Applications** folder.Applications folder.
- Open Docker from your Applications folder.
- Accept the Docker Subscription Service Agreement when prompted.
- Wait for Docker Desktop to finish starting. You will see the Docker whale icon in your menu bar when it is ready.
Verify Docker is running by opening Terminal and running this command:
docker version
You should see version information for both Client and Server. This confirms Docker is installed and the engine is running.
Windows
On Windows, Docker Desktop requires WSL2 (Windows Subsystem for Linux) as its backend. You will install WSL2 first, then Docker Desktop.
- Open PowerShell as Administrator by right-clicking the Start menu and selecting Terminal (Admin).
- Install WSL2 by running this command:
wsl --install
What does this command do?
This enables WSL2, installs the default Ubuntu distribution, and sets WSL2 as the default version. You may be prompted to restart your computer after this step.
- Restart your computer if prompted.
- After restarting, run the Docker Desktop installer (Docker Desktop Installer.exe) you downloaded from the Docker website.
- During installation, make sure the Use WSL 2 instead of Hyper-V option is checked.
- Complete the installer and open Docker Desktop.
- Wait for Docker Desktop to finish starting. You will see the Docker whale icon in your system tray when it is ready.
Verify Docker is running by opening the Ubuntu terminal (search for Ubuntu in the Start menu) and running this command:
docker version
You should see version information for both Client and Server. This confirms Docker is installed and the engine is running.
Docker not starting?
Make sure virtualization is enabled in your BIOS/UEFI settings. On Windows, confirm WSL2 is installed by running wsl --status in PowerShell.
If Docker Desktop shows an error about WSL2, restart your computer after the WSL installation completes.
help me troubleshoot Docker Desktop not starting on my machine
Create the Laravel project with Sail
Laravel Sail is Laravel's official Docker development environment. It generates a complete Docker Compose configuration with PHP, MySQL, and other services pre-configured. You create a new project by running a single command.
macOS
- Open Terminal.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create a new Laravel project with MySQL by running this command:
curl -s "https://laravel.build/task-manager?with=mysql" | bash
Windows
- Open the Ubuntu terminal from WSL2 (search for Ubuntu in the Start menu).
- Move to your home directory by running this command:
cd ~
- Create a new Laravel project with MySQL by running this command:
curl -s "https://laravel.build/task-manager?with=mysql" | bash
What does this command do?
This downloads a script from Laravel's servers that creates a new Laravel project in a folder called task-manager. The ?with=mysql part tells Sail to include a MySQL database container in the project's Docker configuration.
The script downloads Docker images and installs PHP dependencies. This may take several minutes depending on your internet speed.
Why Sail over custom Docker Compose?
Sail is Laravel's official Docker wrapper. It provides a pre-configured docker-compose.yml with PHP, MySQL, and other services out of the box.
For learning Docker concepts, Sail lets you see containers, volumes, and networking in action without writing Docker configurations from scratch.
- When the installer asks for your password, enter your system password to complete the setup.
- Move into the project folder by running this command:
cd task-manager
- Confirm the folder was created by running:
ls
You should see Laravel project files and folders like app, routes, vendor, and docker-compose.yml.
Seeing a permission error or download failure?
Make sure Docker Desktop is running before you execute the Sail installer command. The script uses Docker to build the project.
On Windows, confirm you are running the command from the WSL2 Ubuntu terminal, not from PowerShell or Command Prompt.
help me fix the Laravel Sail installer failing
Start the Docker containers and verify
Your Laravel project is set up, but nothing is running yet. Sail manages your Docker containers. Starting Sail boots up the PHP/Laravel container and the MySQL container together.
- Start the containers in the background by running this command:
./vendor/bin/sail up -d
What does this command do?
This starts all the Docker containers defined in your project's docker-compose.yml. The -d flag runs them in detached mode, so your terminal stays free for other commands.
Two containers start: laravel.test (your PHP/Laravel app) and mysql (your database). The first run may take a minute while Docker builds the images.
- Open your browser and go to http://localhost.
You should see the Laravel welcome page. This confirms your Laravel app and MySQL database are both running inside Docker containers.
Not seeing the Laravel welcome page?
Make sure the containers are running by checking Docker Desktop's Containers panel. You should see two containers with green status indicators.
If the command failed, try running ./vendor/bin/sail up (without -d) to see the full output and any error messages.
help me troubleshoot Laravel Sail containers not starting
- Open the project in VS Code by pressing Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Navigate to the task-manager folder (on your Desktop for macOS, or in your WSL2 home directory for Windows) and click Open.
Windows users and WSL2 paths
On Windows, your WSL2 project lives at a path like \\wsl$\Ubuntu\home\yourname\task-manager. You can access this from the VS Code Open Folder dialog by typing the path in the address bar.
Your development environment is fully set up. Docker is running your Laravel app and MySQL database in isolated containers. Next up, you will build the backend API with routes, models, and controllers.
Build the Backend API
Your Laravel project is running inside Docker containers, and a MySQL database is ready and waiting for data.
In this step, you'll build the backend REST API that powers the Task Manager. This means creating a Task model, defining the database schema, writing the controller logic for all CRUD operations, and registering the API routes.
In this step, get ready to:
- Create the Task model, migration, and API resource controller.
- Define the database schema and run the migration.
- Implement CRUD methods and register API routes.
Create the Task model, migration, and controller
Laravel uses Artisan commands to generate boilerplate files. You'll generate three things: a model (represents a task in PHP), a migration (defines the database table), and a controller (handles API requests).
- Generate the Task model and its migration file by running this command in your WSL2 terminal:
./vendor/bin/sail artisan make:model Task -m
What does this command do?
The ./vendor/bin/sail prefix runs the command inside your Docker container. make:model Task creates a new Eloquent model at app/Models/Task.php. The -m flag also generates a database migration file in the database/migrations/ directory.
- You should see output confirming both files were created.
- Generate the API resource controller by running:
./vendor/bin/sail artisan make:controller TaskController --api
What does --api do?
The --api flag creates a controller with five methods (index, store, show, update, destroy) but skips create and edit since APIs don't serve HTML forms.
- You should see a message confirming TaskController was created at app/Http/Controllers/TaskController.php.
Command not found?
Make sure you're running commands from inside your Laravel project directory in the WSL2 terminal. If you get a Docker-related error, confirm your containers are running with ./vendor/bin/sail up -d.
Help me troubleshoot Sail artisan commands
Now you have three generated files. Next, you'll configure the Task model to define which fields can be filled in.
- Open app/Models/Task.php in VS Code.
- Replace the entire contents of the file with this code:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Task extends Model
{
// Allow these fields to be mass-assigned when creating or updating tasks
protected $fillable = [
'title',
'description',
'is_completed',
];
// Cast is_completed to a boolean so it returns true/false instead of 1/0
protected $casts = [
'is_completed' => 'boolean',
];
}
What does this code do?
The $fillable array tells Eloquent which fields are safe to set when creating or updating a Task. This protects against mass-assignment vulnerabilities.
The $casts array converts the is_completed database value (stored as 0 or 1) into a proper PHP boolean (true or false) whenever it's accessed.
- Save app/Models/Task.php.
✔️ Awesome, I've got everything!
Great. Double check you've saved app/Models/Task.php before moving on.
ⓧ I'd like to double check the full code
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Task extends Model
{
protected $fillable = [
'title',
'description',
'is_completed',
];
protected $casts = [
'is_completed' => 'boolean',
];
}
Define the database schema and run the migration
The migration file tells MySQL what columns your tasks table should have. You'll define columns for the task title, an optional description, and a completion status.
- In VS Code, open the migration file in the database/migrations/ folder. It will be named something like xxxx_xx_xx_xxxxxx_create_tasks_table.php (the exact date/time prefix will vary).
- Replace the entire contents of the file with this code:
<?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
{
// Create the tasks table with all required columns
Schema::create('tasks', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->text('description')->nullable();
$table->boolean('is_completed')->default(false);
$table->timestamps();
});
}
public function down(): void
{
// Drop the tasks table if we need to roll back
Schema::dropIfExists('tasks');
}
};
What does this code do?
- id() creates an auto-incrementing primary key.
- string('title') creates a required varchar column for the task name.
- text('description')->nullable() creates an optional text column for longer descriptions.
- boolean('is_completed')->default(false) tracks whether the task is done, defaulting to not completed.
- timestamps() adds created_at and updated_at columns that Laravel manages automatically.
- Save the migration file.
- Run the migration to create the table in your MySQL database:
./vendor/bin/sail artisan migrate
- You should see output that includes a line confirming the create_tasks_table migration ran successfully.
Migration failed?
If you see a connection refused error, your MySQL container might not be ready yet. Wait a few seconds and try again. You can check container status with ./vendor/bin/sail ps.
Help me debug my migration error
✔️ Awesome, I've got everything!
Great. Your tasks table now exists in the MySQL database inside your Docker container.
ⓧ I'd like to double check the full code
<?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_completed')->default(false);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('tasks');
}
};
Implement CRUD methods and register the API route
The controller is where your API logic lives. Each method handles a different type of request: listing tasks, creating a task, showing a single task, updating a task, and deleting a task.
First, you need to enable API routing in your Laravel project. Laravel 12 doesn't create the routes/api.php file by default.
- Enable API routing by running:
./vendor/bin/sail artisan install:api
What does this command do?
This command creates the routes/api.php file and installs Laravel Sanctum (an authentication package). Any routes you define in routes/api.php automatically get the /api prefix.
The command will ask if you want to run pending database migrations. Type yes and press Enter to allow it.
- Open app/Http/Controllers/TaskController.php in VS Code.
- Replace the entire contents of the file with this code:
<?php
namespace App\Http\Controllers;
use App\Models\Task;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
class TaskController extends Controller
{
// GET /api/tasks - Return all tasks, newest first
public function index(): JsonResponse
{
$tasks = Task::orderBy('created_at', 'desc')->get();
return response()->json($tasks);
}
// POST /api/tasks - Create a new task
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'description' => 'nullable|string',
'is_completed' => 'boolean',
]);
$task = Task::create($validated);
return response()->json($task, 201);
}
// GET /api/tasks/{task} - Return a single task
public function show(Task $task): JsonResponse
{
return response()->json($task);
}
// PUT /api/tasks/{task} - Update an existing task
public function update(Request $request, Task $task): JsonResponse
{
$validated = $request->validate([
'title' => 'sometimes|required|string|max:255',
'description' => 'nullable|string',
'is_completed' => 'boolean',
]);
$task->update($validated);
return response()->json($task);
}
// DELETE /api/tasks/{task} - Delete a task
public function destroy(Task $task): JsonResponse
{
$task->delete();
return response()->json(null, 204);
}
}
What does this code do?
- index() fetches all tasks ordered by newest first and returns them as JSON.
- store() validates the incoming request data (title is required, description is optional) and creates a new task. It returns the new task with a 201 status code.
- show() uses route model binding to automatically find a task by its ID. If the task doesn't exist, Laravel returns a 404.
- update() validates and applies changes to an existing task. The sometimes rule means the title only needs to be present if it's included in the request.
- destroy() deletes the task and returns a 204 (No Content) response.
- Save app/Http/Controllers/TaskController.php.
Seeing a syntax error?
Make sure you copied the entire file including the opening <?php tag. Check that there are no extra characters before or after the code.
Help me find the syntax error in my TaskController
Now register the API route so Laravel knows to send task-related requests to your controller.
- Open routes/api.php in VS Code.
- Replace the entire contents of the file with this code:
<?php
use App\Http\Controllers\TaskController;
use Illuminate\Support\Facades\Route;
// Register all CRUD routes for tasks in one line
Route::apiResource('tasks', TaskController::class);
What does apiResource do?
A single Route::apiResource call registers five routes at once: GET /api/tasks, POST /api/tasks, GET /api/tasks/{task}, PUT /api/tasks/{task}, and DELETE /api/tasks/{task}. Each route maps to the corresponding controller method.
- Save routes/api.php.
- Test that your API is working by running:
curl http://localhost/api/tasks
- You should see an empty JSON array [] in the terminal output. This means your API is responding correctly and the task list is empty because no tasks exist yet.
Seeing a 404 or error?
Make sure you saved both routes/api.php and TaskController.php. Also confirm you ran ./vendor/bin/sail artisan install:api earlier. Without that step, the /api prefix won't be registered.
Help me troubleshoot my Laravel API 404 error
✔️ Awesome, I've got everything!
Great. Your backend API is fully functional with all five CRUD endpoints ready.
ⓧ I'd like to double check the full code
<?php
namespace App\Http\Controllers;
use App\Models\Task;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
class TaskController extends Controller
{
public function index(): JsonResponse
{
$tasks = Task::orderBy('created_at', 'desc')->get();
return response()->json($tasks);
}
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'description' => 'nullable|string',
'is_completed' => 'boolean',
]);
$task = Task::create($validated);
return response()->json($task, 201);
}
public function show(Task $task): JsonResponse
{
return response()->json($task);
}
public function update(Request $request, Task $task): JsonResponse
{
$validated = $request->validate([
'title' => 'sometimes|required|string|max:255',
'description' => 'nullable|string',
'is_completed' => 'boolean',
]);
$task->update($validated);
return response()->json($task);
}
public function destroy(Task $task): JsonResponse
{
$task->delete();
return response()->json(null, 204);
}
}
<?php
use App\Http\Controllers\TaskController;
use Illuminate\Support\Facades\Route;
Route::apiResource('tasks', TaskController::class);
Your backend API is complete. Next up, you'll build the React frontend that talks to these endpoints and gives users a visual interface to manage their tasks.
Build the React Frontend
Your backend API is ready and waiting for requests. Now you need a user interface so people can actually create, view, edit, and delete tasks without typing API calls manually.
In this step, you'll build a React frontend using Vite as the build tool. The frontend will communicate with your Laravel API using Axios for HTTP requests.
In this step, get ready to:
- Create a React app with Vite and install Axios.
- Build a task list component that fetches and displays tasks from the API.
- Build a task form component for creating and editing tasks.
Create the React app and install Axios
The React app will live in a frontend/ folder inside your Laravel project. You need Node.js installed to scaffold and run it.
- In your WSL2 terminal, check your Node.js version by running this command:
node --version
✔️ I see version 20.19 or higher
Node.js is ready. Move on to the next instruction below.
ⓧ I see an older version
Vite requires Node.js 20.19+ or 22.12+. Upgrade using nvm by running these commands:
nvm install --lts
nvm use --lts
- Run node --version again to confirm you see 20.19 or higher.
ⓧ Command not found
Install Node.js using nvm (Node Version Manager). Run these commands one at a time:
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.4/install.sh | bash
source ~/.bashrc
nvm install --lts
- Run node --version to confirm you see 20.19 or higher.
- Make sure you are in your Laravel project's root folder (the folder containing artisan). Scaffold a new React app inside a frontend/ directory by running this command:
npm create vite@latest frontend -- --template react
What does this command do?
This uses the official Vite scaffolding tool to create a new React project in a folder called frontend. The --template react flag tells Vite to pre-configure the project for React development.
Confirm the package install
If npm asks you to confirm installing a package, type y and press Enter to continue.
- Move into the new frontend directory and install the base dependencies plus Axios by running these commands:
cd frontend
npm install
npm install axios
Why Axios?
Axios is a popular HTTP client library that makes it simple to send GET, POST, PUT, and DELETE requests to your Laravel API. It handles JSON parsing automatically and provides a clean syntax for async requests.
- Confirm the install succeeded by running:
ls node_modules/axios
- You should see the Axios package folder listed in the output.
npm install failing?
Make sure you ran cd frontend first. The npm install command must run inside the frontend/ directory where package.json lives.
Help me debug my npm install issue
- Open frontend/vite.config.js in VS Code and replace its contents with the following:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// Configure Vite with React plugin and custom dev server port
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
},
});
Why set a specific port?
Your Laravel app runs on port 80 through Docker. Setting the React dev server to port 3000 avoids a conflict and makes it clear which server you are looking at in the browser.
- Open frontend/src/main.jsx and replace its contents with the following:
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
// Mount the App component into the root DOM element
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>
);
What does this file do?
This is the entry point for your React app. It finds the HTML element with id root and renders the App component inside it.
StrictMode enables extra development warnings to help catch bugs early.
- Save both vite.config.js and main.jsx.
Build the task list component
Your app needs two components: one to display the list of tasks, and one to handle the form for creating and editing. You will build the list display first.
- Create a components folder inside frontend/src/ by running this command:
mkdir src/components
- Create a new file at frontend/src/components/TaskList.jsx and add the following code:
// Displays the list of tasks with action buttons for each
function TaskList({ tasks, onDelete, onEdit, onToggleComplete }) {
// Show a message when there are no tasks
if (tasks.length === 0) {
return <p>No tasks yet. Add one above!</p>;
}
return (
<ul style={{ listStyle: 'none', padding: 0 }}>
{tasks.map((task) => (
<li
key={task.id}
style={{
border: '1px solid #ddd',
padding: '15px',
marginBottom: '10px',
borderRadius: '5px',
backgroundColor: task.is_completed ? '#f0fff0' : '#fff',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start' }}>
<div>
<h3 style={{
margin: '0 0 5px 0',
textDecoration: task.is_completed ? 'line-through' : 'none',
}}>
{task.title}
</h3>
{task.description && <p style={{ margin: '0', color: '#666' }}>{task.description}</p>}
</div>
<div>
<button
onClick={() => onToggleComplete(task)}
style={{ marginRight: '5px', padding: '4px 8px' }}
>
{task.is_completed ? 'Undo' : 'Done'}
</button>
<button
onClick={() => onEdit(task)}
style={{ marginRight: '5px', padding: '4px 8px' }}
>
Edit
</button>
<button
onClick={() => onDelete(task.id)}
style={{ padding: '4px 8px', color: 'red' }}
>
Delete
</button>
</div>
</div>
</li>
))}
</ul>
);
}
export default TaskList;
What does this component do?
TaskList receives four props: tasks (the array of task objects), onDelete, onEdit, and onToggleComplete (callback functions for each action).
Each task renders as a card with a green background when completed and a line-through on the title. The three buttons let users mark tasks done, edit them, or delete them.
- Save TaskList.jsx.
Now create the main App component that fetches tasks from your API and passes them to TaskList.
- Open frontend/src/App.jsx and replace its contents with the following:
import { useState, useEffect } from 'react';
import axios from 'axios';
import TaskForm from './components/TaskForm';
import TaskList from './components/TaskList';
// Base URL pointing to the Laravel API running in Docker
const API_URL = 'http://localhost/api/tasks';
function App() {
// State for the task list and the currently editing task
const [tasks, setTasks] = useState([]);
const [editingTask, setEditingTask] = useState(null);
// Fetch all tasks from the API
const fetchTasks = async () => {
const response = await axios.get(API_URL);
setTasks(response.data);
};
// Load tasks when the component first mounts
useEffect(() => {
fetchTasks();
}, []);
// Create a new task via POST request
const handleCreateTask = async (taskData) => {
await axios.post(API_URL, taskData);
fetchTasks();
};
// Update an existing task via PUT request
const handleUpdateTask = async (id, taskData) => {
await axios.put(`${API_URL}/${id}`, taskData);
setEditingTask(null);
fetchTasks();
};
// Delete a task via DELETE request
const handleDeleteTask = async (id) => {
await axios.delete(`${API_URL}/${id}`);
fetchTasks();
};
// Toggle a task's completed status
const handleToggleComplete = async (task) => {
await axios.put(`${API_URL}/${task.id}`, {
is_completed: !task.is_completed,
});
fetchTasks();
};
return (
<div style={{ maxWidth: '700px', margin: '0 auto', padding: '20px' }}>
<h1>Task Manager</h1>
<TaskForm
onSubmit={editingTask ? (data) => handleUpdateTask(editingTask.id, data) : handleCreateTask}
initialData={editingTask}
onCancel={() => setEditingTask(null)}
/>
<TaskList
tasks={tasks}
onDelete={handleDeleteTask}
onEdit={setEditingTask}
onToggleComplete={handleToggleComplete}
/>
</div>
);
}
export default App;
What does this component do?
App.jsx is the brain of your frontend. It holds two pieces of state: tasks (the array of all tasks) and editingTask (the task currently being edited, or null).
The fetchTasks function calls your Laravel API at http://localhost/api/tasks (port 80, served by Sail). Each handler function sends the appropriate HTTP method and then re-fetches to keep the list up to date.
- Save App.jsx.
The app will not load yet because the TaskForm component does not exist. You will create it next.
✔️ Awesome, I've got everything!
Great. Make sure you have saved the file before moving on to the form component.
ⓧ I'd like to double check the full code
import { useState, useEffect } from 'react';
import axios from 'axios';
import TaskForm from './components/TaskForm';
import TaskList from './components/TaskList';
const API_URL = 'http://localhost/api/tasks';
function App() {
const [tasks, setTasks] = useState([]);
const [editingTask, setEditingTask] = useState(null);
const fetchTasks = async () => {
const response = await axios.get(API_URL);
setTasks(response.data);
};
useEffect(() => {
fetchTasks();
}, []);
const handleCreateTask = async (taskData) => {
await axios.post(API_URL, taskData);
fetchTasks();
};
const handleUpdateTask = async (id, taskData) => {
await axios.put(`${API_URL}/${id}`, taskData);
setEditingTask(null);
fetchTasks();
};
const handleDeleteTask = async (id) => {
await axios.delete(`${API_URL}/${id}`);
fetchTasks();
};
const handleToggleComplete = async (task) => {
await axios.put(`${API_URL}/${task.id}`, {
is_completed: !task.is_completed,
});
fetchTasks();
};
return (
<div style={{ maxWidth: '700px', margin: '0 auto', padding: '20px' }}>
<h1>Task Manager</h1>
<TaskForm
onSubmit={editingTask ? (data) => handleUpdateTask(editingTask.id, data) : handleCreateTask}
initialData={editingTask}
onCancel={() => setEditingTask(null)}
/>
<TaskList
tasks={tasks}
onDelete={handleDeleteTask}
onEdit={setEditingTask}
onToggleComplete={handleToggleComplete}
/>
</div>
);
}
export default App;
Build the task form component
The form component handles both creating new tasks and editing existing ones. It switches behavior based on whether initialData is passed in.
- Create a new file at frontend/src/components/TaskForm.jsx and add the following code:
import { useState, useEffect } from 'react';
function TaskForm({ onSubmit, initialData, onCancel }) {
// Local state for the form fields
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
// When initialData changes, populate the form for editing
useEffect(() => {
if (initialData) {
setTitle(initialData.title);
setDescription(initialData.description || '');
} else {
setTitle('');
setDescription('');
}
}, [initialData]);
// Handle form submission for create or update
const handleSubmit = (e) => {
e.preventDefault();
onSubmit({ title, description });
if (!initialData) {
setTitle('');
setDescription('');
}
};
return (
<form onSubmit={handleSubmit} style={{ marginBottom: '20px' }}>
<div style={{ marginBottom: '10px' }}>
<input
type="text"
placeholder="Task title"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
style={{ width: '100%', padding: '8px', fontSize: '16px' }}
/>
</div>
<div style={{ marginBottom: '10px' }}>
<textarea
placeholder="Description (optional)"
value={description}
onChange={(e) => setDescription(e.target.value)}
style={{ width: '100%', padding: '8px', fontSize: '16px' }}
rows={3}
/>
</div>
<button type="submit" style={{ padding: '8px 16px', marginRight: '10px' }}>
{initialData ? 'Update Task' : 'Add Task'}
</button>
{initialData && (
<button type="button" onClick={onCancel} style={{ padding: '8px 16px' }}>
Cancel
</button>
)}
</form>
);
}
export default TaskForm;
What does this component do?
TaskForm uses useEffect to watch the initialData prop. When a user clicks Edit on a task, App passes that task as initialData and the form fields populate with the existing values.
When creating a new task (no initialData), the form clears itself after submission. The button text switches between "Add Task" and "Update Task" to match the current mode.
- Save TaskForm.jsx.
✔️ Awesome, I've got everything!
Great. Double check you have saved all files in the frontend/src/ directory.
ⓧ I'd like to double check the full code
import { useState, useEffect } from 'react';
function TaskForm({ onSubmit, initialData, onCancel }) {
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
useEffect(() => {
if (initialData) {
setTitle(initialData.title);
setDescription(initialData.description || '');
} else {
setTitle('');
setDescription('');
}
}, [initialData]);
const handleSubmit = (e) => {
e.preventDefault();
onSubmit({ title, description });
if (!initialData) {
setTitle('');
setDescription('');
}
};
return (
<form onSubmit={handleSubmit} style={{ marginBottom: '20px' }}>
<div style={{ marginBottom: '10px' }}>
<input
type="text"
placeholder="Task title"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
style={{ width: '100%', padding: '8px', fontSize: '16px' }}
/>
</div>
<div style={{ marginBottom: '10px' }}>
<textarea
placeholder="Description (optional)"
value={description}
onChange={(e) => setDescription(e.target.value)}
style={{ width: '100%', padding: '8px', fontSize: '16px' }}
rows={3}
/>
</div>
<button type="submit" style={{ padding: '8px 16px', marginRight: '10px' }}>
{initialData ? 'Update Task' : 'Add Task'}
</button>
{initialData && (
<button type="button" onClick={onCancel} style={{ padding: '8px 16px' }}>
Cancel
</button>
)}
</form>
);
}
export default TaskForm;
All components are now in place. Start the React dev server to see your app in the browser.
- Make sure you are inside the frontend/ directory, then start the dev server by running:
npm run dev
- Open your browser and go to http://localhost:3000.
- You should see a page with the heading "Task Manager", a form with a title input and description textarea, and an "Add Task" button.
Seeing a blank page or error?
Check your terminal for error messages. A common issue is a typo in one of the import paths. Make sure App.jsx imports from './components/TaskForm' and './components/TaskList' (matching the exact file names you created).
Also confirm your Laravel Sail containers are still running. The React app needs the API at http://localhost/api/tasks to be available.
Help me debug my React frontend
Your React frontend is built and running. Next up, you will configure CORS in Laravel so the frontend can actually communicate with the API and test the full create, read, update, delete flow.
Connect Frontend to Backend and Test
You have a Laravel API running in Docker and a React frontend ready to display tasks. The missing piece is making sure the two can actually talk to each other.
Browsers block requests between different origins by default. Since your React app runs on port 3000 and your Laravel API runs on port 80, the browser treats them as separate origins. You need to configure CORS (Cross-Origin Resource Sharing) in Laravel to explicitly allow your React frontend through.
In this step, get ready to:
- Configure CORS in Laravel to allow requests from the React dev server.
- Confirm the API base URL in your React app points to the Laravel API.
- Test all CRUD operations end-to-end.
Configure CORS in Laravel
Laravel 12 includes CORS middleware in its global middleware stack, but the configuration file is not published to your project by default. You need to publish it so you can control which origins are allowed to make requests to your API.
- Publish the CORS configuration file by running this command in your WSL2 terminal from your Laravel project directory:
./vendor/bin/sail artisan config:publish cors
What does this command do?
This creates a config/cors.php file in your project. The file controls which external origins (websites) can make requests to your Laravel API.
Without publishing this file, Laravel uses internal defaults that allow all origins. Publishing lets you lock it down to only your React app.
- Open config/cors.php in VS Code.
- Find the allowed_origins line:
'allowed_origins' => ['*'],
- Replace it with this line to explicitly allow your React dev server:
'allowed_origins' => ['http://localhost:3000'],
Why not keep the wildcard?
The wildcard * allows any website to call your API. For development this works, but it is a bad habit. Restricting the origin to http://localhost:3000 ensures only your React app can make requests.
In production, you would change this to your real frontend domain.
- Save config/cors.php.
Still seeing CORS errors in the browser console?
Make sure there is no trailing slash on your origin URL. It should be http://localhost:3000 not http://localhost:3000/.
Try clearing the config cache by running:
./vendor/bin/sail artisan config:clear
If it still does not work, help me debug my CORS configuration in Laravel Sail.
Confirm the API base URL in React
Your React app needs to know where to send its requests. Laravel Sail serves the application at http://localhost on port 80. Your API routes are prefixed with /api, so the full base URL for tasks is http://localhost/api/tasks.
- Open frontend/src/App.jsx in VS Code.
- Verify that the API_URL constant at the top of the file is set to:
const API_URL = 'http://localhost/api/tasks';
Why localhost without a port number?
Laravel Sail maps the container's port 80 to your machine's port 80. When you visit http://localhost in a browser, you reach the Laravel app inside Docker. Port 80 is the default for HTTP, so you do not need to specify it.
Your React dev server is separate, running on port 3000. The React app makes requests from the browser to http://localhost/api/tasks, and the CORS config you just set up allows this.
- Make sure your React dev server is still running. If it stopped, start it again from the frontend directory:
npm run dev
- Open http://localhost:3000 in your browser.
You should see the Task Manager heading with an empty task list showing "No tasks yet. Add one above!" text.
Seeing a blank page or network errors?
Open your browser's DevTools (press F12) and check the Console tab for errors. If you see a CORS error, go back and double check your config/cors.php file.
Make sure your Sail containers are still running by checking your terminal where you ran ./vendor/bin/sail up -d.
If you need more help, help me connect my React frontend to my Laravel Sail API.
Test all CRUD operations end-to-end
Now for the exciting part. You will test every operation your app supports to make sure the frontend and backend are fully connected.
- In the Task Manager form, type Buy groceries in the title field.
- Type Milk, eggs, bread in the description field.
- Click Add Task.
The task should appear in the list below the form. This confirms the Create operation is working.
- Click the Edit button on the task you just created.
- Change the title to Buy groceries today.
- Click Update Task.
The task list updates with the new title. This confirms the Update operation.
- Click the Done button on the task.
The task title gets a line-through style and the background turns green. This confirms the toggle-complete operation.
- Click the Delete button (in red) on the task.
The task disappears from the list and you see "No tasks yet. Add one above!" again. This confirms the Delete operation.
What just happened?
Each action you performed sent an HTTP request from your React app to the Laravel API:
- Add Task sent a POST request to /api/tasks.
- Update Task sent a PUT request to /api/tasks/{id}.
- Done sent a PUT request to toggle is_completed.
- Delete sent a DELETE request to /api/tasks/{id}.
Laravel processed each request, updated the MySQL database, and returned the response. React then re-fetched the task list to show the updated state.
✔️ Awesome, I've got everything!
Great. Double check you have saved config/cors.php and that all four operations work in your browser.
ⓧ I'd like to double check the full code
Here is the complete config/cors.php file for reference:
<?php
return [
/*
|--------------------------------------------------------------------------
| Cross-Origin Resource Sharing (CORS) Configuration
|--------------------------------------------------------------------------
|
| Here you may configure your settings for cross-origin resource sharing
| or "CORS". This determines what cross-origin operations may execute
| in web browsers. You are free to adjust these settings as needed.
|
| To learn more: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
|
*/
'paths' => ['api/*', 'sanctum/csrf-cookie'],
'allowed_methods' => ['*'],
'allowed_origins' => ['http://localhost:3000'],
'allowed_origins_patterns' => [],
'allowed_headers' => ['*'],
'exposed_headers' => [],
'max_age' => 0,
'supports_credentials' => false,
];
And here is frontend/src/App.jsx (unchanged from the previous step):
import { useState, useEffect } from 'react';
import axios from 'axios';
import TaskForm from './components/TaskForm';
import TaskList from './components/TaskList';
const API_URL = 'http://localhost/api/tasks';
function App() {
const [tasks, setTasks] = useState([]);
const [editingTask, setEditingTask] = useState(null);
const fetchTasks = async () => {
const response = await axios.get(API_URL);
setTasks(response.data);
};
useEffect(() => {
fetchTasks();
}, []);
const handleCreateTask = async (taskData) => {
await axios.post(API_URL, taskData);
fetchTasks();
};
const handleUpdateTask = async (id, taskData) => {
await axios.put(`${API_URL}/${id}`, taskData);
setEditingTask(null);
fetchTasks();
};
const handleDeleteTask = async (id) => {
await axios.delete(`${API_URL}/${id}`);
fetchTasks();
};
const handleToggleComplete = async (task) => {
await axios.put(`${API_URL}/${task.id}`, {
is_completed: !task.is_completed,
});
fetchTasks();
};
return (
<div style={{ maxWidth: '700px', margin: '0 auto', padding: '20px' }}>
<h1>Task Manager</h1>
<TaskForm
onSubmit={editingTask ? (data) => handleUpdateTask(editingTask.id, data) : handleCreateTask}
initialData={editingTask}
onCancel={() => setEditingTask(null)}
/>
<TaskList
tasks={tasks}
onDelete={handleDeleteTask}
onEdit={setEditingTask}
onToggleComplete={handleToggleComplete}
/>
</div>
);
}
export default App;
Your full-stack application is working. You have a React frontend communicating with a Laravel API, both running in a Dockerized environment. Next up, you will take on the Secret Mission and add filtering and search to make your Task Manager even more powerful.
Secret mission
Add Filtering and Search
Your Task Manager works, but real-world apps need filtering and search. Can you add a status filter (all, completed, pending) and a search bar that filters tasks by title? This challenge touches both the Laravel API and the React frontend.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally using Docker containers, so there are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Docker containers running via Laravel Sail (Laravel app and MySQL database).
- Docker volumes storing your MySQL data.
- The Laravel project folder on your Desktop (includes the React frontend).
- Docker Desktop (installed application).
- Node.js (installed runtime).
Keep everything running
No action needed. Choose this if you're still actively building or plan to continue working on the Task Manager app.
- Your Docker containers will keep running in the background. You can access the Laravel app at localhost and the React frontend at localhost:3000.
- Docker Desktop, Node.js, and all project files remain on your machine.
- Your MySQL data persists in the Docker volume between restarts.
Pause - I'll come back to this later
Stop the running containers to free up system memory and CPU. Your project files and database data remain intact for when you return.
- Stop the React dev server by pressing Ctrl+C in the terminal where it is running.
- Stop the Laravel Sail containers by running this command from your project folder:
./vendor/bin/sail stop
- When you're ready to resume, start the containers again with ./vendor/bin/sail up -d and the React dev server with npm run dev from the frontend folder.
Delete - I don't want to use this again
Remove all project resources and start fresh. This deletes your containers, database data, project files, and optionally the installed tools.
- Stop the React dev server by pressing Ctrl+C in the terminal where it is running.
- Remove the Docker containers and volumes by running this command from your project folder:
./vendor/bin/sail down -v
What does this command do?
The -v flag removes the Docker volumes where your MySQL data is stored. Without it, the database data would persist even after the containers are removed.
- Delete the project folder from your Desktop. On macOS or Linux:
cd ~/Desktop
rm -rf task-manager
On Windows (from WSL2 terminal):
cd ~/Desktop
rm -rf task-manager
- To uninstall Docker Desktop on macOS, open Docker Desktop, click the Docker icon in the menu bar, select Troubleshoot, then click Uninstall.
- To uninstall Docker Desktop on Windows, open Settings, select Apps, then Installed apps. Find Docker Desktop, click the three dots, and select Uninstall.
- To uninstall Node.js on macOS, use Homebrew:
brew uninstall node
- To uninstall Node.js on Windows, open Settings, select Apps, then Installed apps. Find Node.js and select Uninstall.
That's a Wrap!
That's a Wrap!
Nice work! You've just built a full-stack Task Manager application with a Laravel backend API and a React frontend, all running inside Docker containers.
You've learned how to:
- Built a REST API with full CRUD operations using Laravel resource controllers and Eloquent ORM.
- Created a MySQL database with a structured schema managed through database migrations.
- Connected a React 19 frontend to a backend API using Axios HTTP requests to create, read, update, and delete tasks.
- Added task filtering by status and a search bar using query parameters and Eloquent query scopes.
Ready to quiz yourself?