Building a Laravel-React Todo App: A Comprehensive Beginner's Guide

Building a Laravel-React Todo App: A Comprehensive Beginner's Guide

Creating a full-stack application can seem daunting, especially for beginners. However, with the right guidance, it's entirely achievable. In this blog post, we'll walk you through building a Todo App using Laravel for the backend and React for the frontend. This step-by-step guide is designed to be beginner-friendly, ensuring you grasp each concept as you progress.

Link to complete source code : https://github.com/JC-Coder/laravel-react-todo-app

Prerequisites

Before diving into the development process, ensure you have the following installed on your machine:

  • PHP (version 8.0 or higher)

  • Composer: Dependency Manager for PHP

  • Node.js and npm: For managing frontend dependencies

  • Laravel Installer (optional but recommended)

  • Git: For version control

For detailed installation guides, refer to the Laravel Documentation and Node.js Installation Guide.


Setting Up the Laravel Backend

1. Install Laravel

First, you'll need to set up a new Laravel project. You can either install a fresh Laravel application or clone the starter repository.

# Create a new Laravel project
composer create-project laravel/laravel todo-app

# Or clone the starter repo
git clone https://github.com/JC-Coder/laravel-react-todo-app.git
cd laravel-react-todo-app

2. Install Sanctum for Authentication

Laravel Sanctum provides a robust authentication system for single-page applications (SPA) and simple APIs.

# Install Sanctum
php artisan install:api

3. Update the User Model

To enable API token management, incorporate the HasApiTokens trait into your User model.

<?php
// File: app/Models/User.php

namespace App\Models;

use Laravel\Sanctum\HasApiTokens; // Import HasApiTokens

use Illuminate\Foundation\Auth\User as Authenticatable;

class User extends Authenticatable
{
    use HasFactory, HasApiTokens, Notifiable;

    // Rest of the model...
}

4. Add a Health Route

A health check route ensures your API is operational.

<?php
// File: routes/api.php

use Illuminate\Support\Facades\Route;

Route::get('/health', function () {
    return response()->json(['status' => 'API is working'], 200);
});

5. Create Controllers

We'll need two controllers: BaseController for standardized responses and AuthController for handling authentication.

# Create BaseController and AuthController
php artisan make:controller BaseController
php artisan make:controller AuthController

6. Implement BaseController

The BaseController standardizes API responses.

<?php
// File: app/Http/Controllers/BaseController.php

namespace App\Http\Controllers;

use Illuminate\Http\JsonResponse;

class BaseController extends Controller
{
    /**
     * Send a successful response.
     *
     * @param mixed $result
     * @param string $message
     * @return JsonResponse
     */
    public function sendResponse($result, $message): JsonResponse
    {
        return response()->json([
            'success' => true,
            'data'    => $result,
            'message' => $message,
        ], 200);
    }

    /**
     * Send an error response.
     *
     * @param string $error
     * @param array $errorMessages
     * @param int $code
     * @return JsonResponse
     */
    public function sendError($error, $errorMessages = [], $code = 404): JsonResponse
    {
        $response = [
            'success' => false,
            'message' => $error,
        ];

        if (!empty($errorMessages)) {
            $response['data'] = $errorMessages;
        }

        return response()->json($response, $code);
    }
}

7. Implement AuthController

The AuthController manages user registration, login, and fetching authenticated user details.

<?php
// File: app/Http/Controllers/AuthController.php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use App\Models\User;
use Illuminate\Support\Facades\Hash;

class AuthController extends BaseController
{
    /**
     * Register a new user.
     *
     * @param Request $request
     * @return JsonResponse
     */
    public function register(Request $request)
    {
        try {
            // Validate incoming request
            $validatedData = $request->validate([
                'name'     => 'required|string|max:255',
                'email'    => 'required|string|email|max:255|unique:users',
                'password' => 'required|string|min:8',
            ]);

            // Create new user
            $user = User::create([
                'name'     => $validatedData['name'],
                'email'    => $validatedData['email'],
                'password' => Hash::make($validatedData['password']),
            ]);

            // Generate token
            $token = $user->createToken('auth_token')->plainTextToken;

            // Respond with user data and token
            return $this->sendResponse([
                'user'         => $user,
                'access_token' => $token,
                'token_type'   => 'Bearer',
            ], 'User created successfully');
        } catch (\Exception $e) {
            return $this->sendError($e->getMessage(), [], 422);
        }
    }

    /**
     * Login user and create token.
     *
     * @param Request $request
     * @return JsonResponse
     */
    public function login(Request $request)
    {
        try {
            // Attempt to authenticate
            if (!Auth::attempt($request->only('email', 'password'))) {
                return $this->sendError('Invalid login details', [], 401);
            }

            // Fetch user
            $user = User::where('email', $request['email'])->firstOrFail();

            // Generate token
            $token = $user->createToken('auth_token')->plainTextToken;

            // Respond with user data and token
            return $this->sendResponse([
                'user'         => $user,
                'access_token' => $token,
                'token_type'   => 'Bearer',
            ], 'User logged in successfully');
        } catch (\Exception $e) {
            return $this->sendError($e->getMessage(), [], 422);
        }
    }

    /**
     * Fetch authenticated user.
     *
     * @param Request $request
     * @return JsonResponse
     */
    public function user(Request $request)
    {
        if ($request->user()) {
            return $this->sendResponse($request->user(), 'User fetched successfully');
        } else {
            return response()->json([
                'message' => 'User not authenticated',
            ], 401);
        }
    }
}

Defining Authentication Routes

Let's define the routes for registration and login in routes/api.php.

<?php
// File: routes/api.php

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

// Authentication Routes
Route::post('/register', [AuthController::class, 'register']);
Route::post('/login', [AuthController::class, 'login']);

Testing Authentication

With the authentication setup in place, it's time to test the API endpoints using tools like Postman or cURL.

  1. Registration Endpoint

    • URL: POST http://localhost:8000/api/register

    • Body:

        {
          "name": "John Doe",
          "email": "johndoe@example.com",
          "password": "password123"
        }
      
    • Expected Response:

        {
          "success": true,
          "data": {
            "user": { /* User Details */ },
            "access_token": "token_here",
            "token_type": "Bearer"
          },
          "message": "User created successfully"
        }
      
  2. Login Endpoint

    • URL: POST http://localhost:8000/api/login

    • Body:

        {
          "email": "johndoe@example.com",
          "password": "password123"
        }
      
    • Expected Response:

        {
          "success": true,
          "data": {
            "user": { /* User Details */ },
            "access_token": "token_here",
            "token_type": "Bearer"
          },
          "message": "User logged in successfully"
        }
      
  3. Fetch Authenticated User

    • URL: GET http://localhost:8000/api/me

    • Headers:

        Authorization: Bearer <access_token>
      
    • Expected Response:

        {
          "success": true,
          "data": { /* Authenticated User Details */ },
          "message": "User fetched successfully"
        }
      

Implementing Todo Features

Now that authentication is set up, let's implement the core Todo functionalities.

10. Create Migration, Model, and Controller

Generate the necessary files for the Todo feature.

# Create migration for todos table
php artisan make:migration create_todos_table

# Create Todo model
php artisan make:model Todo

# Create TodoController
php artisan make:controller TodoController

11. Update Migration File

Define the structure of the todos table.

<?php
// File: database/migrations/xxxx_xx_xx_create_todo_table.php

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

class CreateTodoTable extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('todos', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')->constrained()->onDelete('cascade'); // References users table
            $table->string('title');
            $table->text('description')->nullable();
            $table->boolean('completed')->default(false);
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('todos');
    }
}

12. Run Migrations

Apply the migrations to create the todos table.

php artisan migrate

13. Update Todo Model

Specify the fillable fields in the Todo model.

<?php
// File: app/Models/Todo.php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Todo extends Model
{
    use HasFactory;

    /**
     * The attributes that are mass assignable.
     *
     * @var array
     */
    protected $fillable = [
        'title',
        'description',
        'completed',
        'user_id',
    ];
}

14. Implement TodoController

Handle CRUD operations for todos.

<?php
// File: app/Http/Controllers/TodoController.php

namespace App\Http\Controllers;

use App\Models\Todo;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;

class TodoController extends BaseController
{
    /**
     * Retrieve all todos for the authenticated user.
     *
     * @param Request $request
     * @return JsonResponse
     */
    public function index(Request $request)
    {
        $userTodos = Todo::where('user_id', Auth::id())->get();
        return $this->sendResponse($userTodos, 'Todos fetched successfully');
    }

    /**
     * Store a new todo.
     *
     * @param Request $request
     * @return JsonResponse
     */
    public function store(Request $request)
    {
        // Validate incoming request
        $request->validate([
            'title'       => 'required|string|max:255',
            'description' => 'nullable|string',
            'completed'   => 'nullable|boolean',
        ]);

        // Create new todo
        $todo = Todo::create([
            'title'       => $request->title,
            'description' => $request->description,
            'completed'   => $request->completed ?? false,
            'user_id'     => Auth::id(),
        ]);

        return $this->sendResponse($todo, 'Todo created successfully');
    }

    /**
     * Update an existing todo.
     *
     * @param Request $request
     * @param int $id
     * @return JsonResponse
     */
    public function update(Request $request, $id)
    {
        // Validate incoming request
        $request->validate([
            'title'       => 'nullable|string|max:255',
            'description' => 'nullable|string',
            'completed'   => 'nullable|boolean',
        ]);

        $userId = Auth::id();
        $todo = Todo::where('id', $id)->where('user_id', $userId)->first();

        if (!$todo) {
            return $this->sendError('Todo not found', [], 404);
        }

        // Update todo with validated data
        $todo->update($request->all());

        return $this->sendResponse($todo, 'Todo updated successfully');
    }

    /**
     * Delete a todo.
     *
     * @param int $id
     * @return JsonResponse
     */
    public function destroy($id)
    {
        $userId = Auth::id();
        $todo = Todo::where('id', $id)->where('user_id', $userId)->first();

        if (!$todo) {
            return $this->sendError('Todo not found', [], 404);
        }

        // Delete the todo
        $todo->delete();

        return $this->sendResponse([], 'Todo deleted successfully');
    }
}

Defining Todo Routes

Define routes for managing todos in routes/api.php.

<?php
// File: routes/api.php

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

// Todo Routes
Route::get('/todos', [TodoController::class, 'index']);
Route::post('/todos', [TodoController::class, 'store']);
Route::put('/todos/{id}', [TodoController::class, 'update']);
Route::delete('/todos/{id}', [TodoController::class, 'destroy']);

Securing Routes with Sanctum Middleware

Ensure that only authenticated users can access certain routes by applying Sanctum's authentication middleware.

<?php
// File: routes/api.php

use Illuminate\Support\Facades\Route;

// Group routes that require authentication
Route::middleware(['auth:sanctum'])->group(function () {
    // User Routes
    Route::get('/me', [AuthController::class, 'user']);

    // Todo Routes
    Route::get('/todos', [TodoController::class, 'index']);
    Route::post('/todos', [TodoController::class, 'store']);
    Route::put('/todos/{id}', [TodoController::class, 'update']);
    Route::delete('/todos/{id}', [TodoController::class, 'destroy']);
});

Handling Exceptions

To ensure consistent error responses, handle exceptions globally in app/Exceptions/Handler.php.

<?php
// File: bootstrap/app.php
// add it inside the block
//   ->withExceptions(function (Exceptions $exceptions) {
//  })->create();

 $exceptions->renderable(function (Exception $e) {
            return response()->json([
                'message' => 'An error occurred',
                'error' => $e->getMessage(),
            ], 500);
});

Handling Exceptions

Start up your backend server using the php magic wand

php artisan serve

# you should get this message in your terminal if server start 
# successfully 
#  INFO  Server running on [http://127.0.0.1:8000].

Setting Up the Frontend

After setting up the backend, clone the frontend repository and configure it.

# Clone the frontend repo
git clone https://github.com/JC-Coder/laravel-react-todo-app.git
cd laravel-react-todo-app/client

# Install dependencies
npm install

# Configure Base URL
# Open the axios config file located at : src/api/axios.js
# and set the API base URL to your server URL, 
# typically http://localhost:8000/api

npm start

Note: Ensure that the Laravel backend is running to allow the frontend to communicate with the API.


Conclusion

Congratulations! You've successfully built a full-stack Todo application using Laravel and React. This project not only demonstrates essential CRUD operations but also integrates user authentication using Laravel Sanctum. By following this guide, you've gained valuable insights into setting up a robust backend, managing API routes, handling authentication, and securing your application.

Feel free to explore the GitHub repository for the complete source code and further enhancements.


Resources

Feel free to reach out or contribute to the repository for any enhancements or queries!