Full-Stack CRUD App with Laravel and Docker

Build a Dockerized project management app with role-based access control.

Introduction

30 Second Summary

Think about how workplace tools like project trackers decide who gets to edit, delete, or just view information. That access system keeps things organized and secure for teams of any size.

In this project, you will build a full-stack project management app with two modules, role-based access control, and a custom design system. Everything runs inside Docker containers, just like a production application.

What You'll Build

A Dockerized Laravel and React application featuring Projects and Tasks modules, three user roles with different permissions, custom Tailwind styling, and industry-standard code architecture.

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

  • A Dockerized development environment with PHP, MySQL, Redis, and Node.js running via Laravel Sail.
  • Two CRUD modules (Projects and Tasks) with role-based access for three user types: admin, developer, and user.
  • A custom Tailwind 4 design system with branded colors, dark mode, and reusable utility classes across a React 19 frontend with TypeScript.
  • Secret Mission: Add a dashboard home page with statistics cards, completion metrics, and a recent activity feed.

Are there any prerequisites?

Basic familiarity with PHP and React concepts is helpful. No prior Docker or Laravel experience is required.

Install Docker Desktop and WSL2

This project runs entirely inside Docker containers. Docker needs a Linux environment to run its containers, and on Windows that means using WSL2 (Windows Subsystem for Linux 2).

In this step, you will install the three foundational tools: WSL2 for the Linux layer, Docker Desktop for containerization, and VS Code for editing files inside your Linux filesystem.

In this step, get ready to:
  • Install WSL2 with Ubuntu on your Windows machine.
  • Install Docker Desktop and connect it to your WSL2 environment.
  • Install VS Code with the WSL extension for editing Linux files.
Install WSL2 on Windows

WSL2 lets you run a full Linux kernel inside Windows. Docker Desktop uses this Linux kernel to run containers efficiently.

  • Check if WSL2 is already installed by opening PowerShell and running this command:
wsl -l -v

What does this command do?

This lists all installed Linux distributions and shows whether each one is running WSL version 1 or version 2. Look at the VERSION column in the output.

✔️ I see Ubuntu with VERSION 2

Your WSL2 environment is already set up with Ubuntu. Skip ahead to the Docker Desktop section below.

ⓧ I see a distribution but VERSION shows 1

You have WSL installed, but your distribution is running the older WSL 1 engine. You need to upgrade it to version 2.

  • Upgrade your distribution to WSL 2 by running this command (replace Ubuntu with your distribution name from the list):
wsl --set-version Ubuntu 2

The conversion may take a few minutes. Once complete, run wsl -l -v again to confirm VERSION now shows 2.

ⓧ Command not recognized or no distributions listed

WSL is not installed on your machine yet. Follow the steps below to install it.

  • Press the Windows key, type PowerShell, right-click Windows PowerShell, and select Run as administrator.
  • Install WSL2 with Ubuntu by running this command:
wsl --install

What does this command do?

This enables the WSL feature, downloads the WSL2 Linux kernel, and installs Ubuntu as your default Linux distribution. It handles everything in one command.

  • Restart your computer if prompted.
  • After restarting, an Ubuntu terminal window opens automatically. Set your Linux username and password when prompted.

Remember your Linux password

Choose something simple you will remember. You will need this password when running commands with sudo (administrator-level commands) inside Ubuntu.

  • Verify the installation succeeded by opening a new PowerShell window and running:
wsl -l -v

You should see Ubuntu listed with VERSION 2 and a STATE of Running or Stopped.

WSL install stuck or erroring?

Make sure you are running PowerShell as Administrator. The wsl --install command requires elevated privileges.

If the install hangs at 0%, close it and try running wsl --install --web-download instead, which downloads the distribution from the web rather than the Microsoft Store.

Help me troubleshoot my WSL2 installation on Windows.

Install Docker Desktop

Docker Desktop provides the graphical interface and engine for running containers on your machine. It integrates directly with WSL2 so containers run inside your Linux environment.

  • Check if Docker Desktop is already installed by pressing the Windows key and typing Docker Desktop.

✔️ I already have Docker Desktop

Docker Desktop is installed. Skip to the WSL2 integration configuration below to make sure it is connected to your Ubuntu distribution.

ⓧ I don't see Docker Desktop

You need to download and install Docker Desktop.

  • Open your browser and go to https://www.docker.com/products/docker-desktop/.
  • Click the download button for Windows and run the installer.
  • During installation, the installer shows a Configuration page. Ensure the Use WSL 2 instead of Hyper-V option is checked.
  • Complete the installation and restart your computer if prompted.

Is Docker Desktop free?

Docker Desktop is free for personal use, education, and small businesses. You do not need a paid subscription for this project.

Now configure Docker Desktop to integrate with your Ubuntu distribution.

  • Press the Windows key, type Docker Desktop, and press Enter to open it.
  • Click the Settings icon (gear icon) in the top-right area of the Docker Desktop dashboard.
  • Select the General tab.
  • Confirm that Use the WSL 2 based engine is checked. On systems that support WSL2, this is enabled by default.
  • Select Resources in the left sidebar, then click WSL Integration.
  • Enable integration with your Ubuntu distribution by toggling it on.
  • Click Apply & restart.

What is WSL Integration?

WSL Integration makes the docker command available directly inside your Ubuntu terminal. Without it, you would only be able to run Docker commands from Windows PowerShell.

Since Laravel Sail runs all commands from inside WSL2, this integration is essential for the project.

Don't see WSL Integration in Settings?

If you do not see the WSL Integration option under Resources, Docker Desktop may be running in Windows container mode. Right-click the Docker icon in your system tray and select Switch to Linux containers.

Help me configure Docker Desktop WSL2 integration on Windows.

Install VS Code with the WSL extension

VS Code is the code editor you will use throughout this project. The WSL extension lets VS Code open and edit files that live inside your Ubuntu Linux filesystem directly from Windows.

  • Check if VS Code is already installed by pressing the Windows key and typing Visual Studio Code.

✔️ I already have VS Code

VS Code is installed. Skip to the extension installation below.

ⓧ I don't see VS Code

You need to download and install VS Code.

  • Open your browser and go to https://code.visualstudio.com/.
  • Click the download button for Windows and run the installer.
  • Accept the defaults during installation.

Now install the WSL extension so VS Code can connect to your Ubuntu environment.

  • Press the Windows key, type Visual Studio Code, and press Enter to open it.
  • Open the Extensions panel by pressing Ctrl+Shift+X.
  • Type WSL in the search bar.
  • Find the extension named WSL by Microsoft (identifier: ms-vscode-remote.remote-wsl).
  • Click Install.

What does the WSL extension do?

This extension lets VS Code act as a remote editor. It runs a lightweight server inside your Ubuntu distribution while you interact with the familiar VS Code interface on Windows. File operations, terminal commands, and debugging all happen inside Linux.

  • Verify everything works by opening Windows Terminal, launching an Ubuntu session, and running:
docker --version

You should see a Docker version number printed. This confirms Docker Desktop is accessible from inside your WSL2 Ubuntu terminal.

docker command not found in Ubuntu?

Make sure Docker Desktop is running (check for the whale icon in your system tray). Then confirm that WSL Integration is enabled for your Ubuntu distribution under Settings > Resources > WSL Integration in Docker Desktop.

After changing settings, close your Ubuntu terminal and open a new one for the changes to take effect.

Help me get the docker command working inside my WSL2 Ubuntu terminal.

Your development environment is ready. Next up, you will create your Laravel project using Sail and watch your first Docker containers come to life.

Create the Laravel Project with Sail

Your development environment is set up with Docker, WSL2, and VS Code. Now you need to create the actual Laravel application that will run inside Docker containers.

Laravel provides a tool called Sail that wraps your entire app in pre-configured Docker containers. You will use Sail to spin up PHP, MySQL, and Redis without installing any of them directly on your machine.

In this step, get ready to:
  • Create a new Laravel project with MySQL and Redis containers.
  • Start the Docker containers and verify the app loads in your browser.
  • Configure Vite for hot-reloading inside WSL2.
Create the Laravel project inside WSL2

Laravel's build script downloads a fresh project and configures Sail with the services you specify. You need to run this inside your WSL2 terminal so the project lives on the Linux filesystem for best performance.

  • Open Windows Terminal.
  • Click the dropdown arrow next to the tab bar and select Ubuntu to launch your WSL2 session.
  • Navigate to your home directory by running this command:
cd ~
  • Create the Laravel project with Sail, MySQL, and Redis by running this command:
curl -s "https://laravel.build/project-manager?with=mysql,redis" | bash

What does this command do?

This downloads a fresh Laravel project into a folder called project-manager.

The ?with=mysql,redis part tells Sail to include MySQL (your database) and Redis (a cache layer) as Docker services alongside the PHP application container.

The script will ask for your WSL2 password at the end to set file permissions. This may take several minutes on the first run as Docker pulls the required images.

Seeing a permission denied or Docker error?

Make sure Docker Desktop is running on your Windows machine. The whale icon should be visible in your system tray.

Confirm WSL integration is enabled in Docker Desktop by opening Settings then Resources then WSL Integration and toggling on your Ubuntu distribution.

help me fix a Docker permission error when creating a Laravel Sail project in WSL2

Start the containers and verify the app

With the project created, you can now boot up all the Docker containers. Sail provides a convenient wrapper around Docker Compose so you do not need to memorize Docker commands.

  • Move into the project folder by running:
cd project-manager
  • Start all Docker containers in detached mode by running:
./vendor/bin/sail up -d

What does this command do?

This boots three containers: the Laravel application (PHP), a MySQL 8 database, and a Redis cache server.

The -d flag runs them in the background so your terminal stays free for other commands.

  • Open your Windows browser and visit http://localhost. You should see the Laravel welcome page with the Laravel logo and links to documentation.

Not seeing the Laravel welcome page?

If the page does not load, wait 30 seconds and try again. The containers may still be starting up.

Check if all containers are running by opening your WSL2 terminal and running:

./vendor/bin/sail ps

You should see entries for the app, MySQL, and Redis containers with a status of running.

help me troubleshoot why http://localhost is not loading my Laravel Sail app

  • Open the project in VS Code by running this command from the project-manager directory:
code .

Why run code . from WSL2?

The code . command opens VS Code on Windows but connects it to your WSL2 Linux filesystem via the WSL extension you installed earlier. This means VS Code edits files directly inside Linux where your project lives.

Configure Vite for WSL2 hot-reloading

Vite is the frontend build tool that bundles your JavaScript and CSS. When running inside Sail on WSL2, the development server needs an extra setting so your Windows browser can receive live updates as you edit files.

  • In VS Code, open vite.config.js from the project root.
  • Find the existing code that looks like this:
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';

export default defineConfig({
    plugins: [
        laravel([
            'resources/css/app.css',
            'resources/js/app.js',
        ]),
    ],
});
  • Add a server block after the plugins array so the full file looks like this:
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';

export default defineConfig({
    plugins: [
        laravel([
            'resources/css/app.css',
            'resources/js/app.js',
        ]),
    ],
    server: {
        hmr: {
            host: 'localhost',
        },
    },
});

What does this change do?

The hmr: { host: 'localhost' } setting tells Vite's Hot Module Replacement to communicate through localhost instead of the container's internal network address.

Without this, your Windows browser cannot reach the Vite dev server running inside WSL2. File changes would not appear instantly in the browser.

  • Save vite.config.js.

Hot-reloading not working later?

If you notice file changes do not appear in the browser when you eventually run the Vite dev server, double check that the server block is placed inside defineConfig({}) at the same level as plugins. A misplaced comma or bracket is the most common cause.

help me fix my Vite HMR configuration in vite.config.js for Laravel Sail on WSL2

✔️ Awesome, I've got everything!

Great. Double check you have saved vite.config.js and that your Docker containers are still running.

ⓧ I'd like to double check the full code

Here is what your vite.config.js should look like at the end of this step:

import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';

export default defineConfig({
    plugins: [
        laravel([
            'resources/css/app.css',
            'resources/js/app.js',
        ]),
    ],
    server: {
        hmr: {
            host: 'localhost',
        },
    },
});

Your Laravel project is running inside Docker containers and your editor is connected to the WSL2 filesystem. Next up, you will install the React starter kit and set up role-based permissions.

Install the React Starter Kit and Spatie Permissions

Your Laravel project is running in Docker, but right now it only shows the default welcome page. To build a real application, you need authentication (login, register) and a modern frontend framework.

Laravel's official React starter kit gives you both in a single command. It scaffolds a full authentication system powered by Laravel Fortify, with a React 19 frontend using Inertia, TypeScript, Tailwind 4, and shadcn/ui. You will also install Spatie laravel-permission, the industry-standard package for role-based access control in Laravel.

In this step, get ready to:
  • Install the Laravel React starter kit with authentication scaffolding.
  • Install Spatie laravel-permission and run database migrations.
  • Add the HasRoles trait to the User model.
Install the Laravel React starter kit

The starter kit replaces the default Laravel welcome page with a complete authentication system. It includes login, registration, password reset, and user settings pages built with React and shadcn/ui components.

  • In your WSL2 terminal, make sure you are inside the project-manager directory.
  • Install the React starter kit by running this command:
./vendor/bin/sail php artisan starter-kit:install react

What will this command ask?

The installer will prompt you to select an authentication provider. Choose the built-in Laravel authentication option (not WorkOS).

It may also ask about a testing framework. You can accept the default (Pest) by pressing Enter.

The command will overwrite some existing files to scaffold the full frontend and authentication system. Confirm when prompted.

  • After the starter kit finishes, install the frontend dependencies by running:
./vendor/bin/sail npm install

What did the starter kit scaffold?

Your project now includes a complete resources/js/ directory with React components, pages, layouts, hooks, and TypeScript type definitions.

It also added Fortify-powered authentication routes (/login, /register, /forgot-password) and action classes in app/Actions/Fortify/.

  • Verify the installation worked by checking that the resources/js/pages/ directory exists in the VS Code Explorer sidebar.

Don't see the resources/js/pages folder?

Make sure you ran the command from inside the project-manager directory in WSL2. If the command failed, check that your Sail containers are still running with ./vendor/bin/sail ps.

help me troubleshoot why the starter kit install failed

Install Spatie laravel-permission

Spatie laravel-permission is the most widely used package for managing roles and permissions in Laravel applications. It integrates with Laravel's built-in Gate system, so you can use standard authorization checks throughout your app.

  • Install the package via Composer by running:
./vendor/bin/sail composer require spatie/laravel-permission
  • Publish the package's configuration file and migration by running:
./vendor/bin/sail php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider"

What does publishing do?

This copies the package's config file to config/permission.php and a migration file to your database/migrations/ directory.

The migration creates five tables that Spatie uses: roles, permissions, model_has_roles, model_has_permissions, and role_has_permissions.

  • Run all pending migrations to create the database tables:
./vendor/bin/sail php artisan migrate
  • You should see output listing the migrations that ran, including the Spatie permission tables.

Seeing a connection refused error?

This usually means the MySQL container is not running yet. Run ./vendor/bin/sail up -d to restart all containers, then try the migrate command again.

help me fix a database connection error when running migrations in Laravel Sail

Add the HasRoles trait to the User model

For Spatie's role and permission methods to work on your users, the User model needs the HasRoles trait. This trait adds methods like assignRole(), hasRole(), and can() to every User instance.

  • In VS Code, open app/Models/User.php from the Explorer sidebar.
  • Find the import statements near the top of the file (the lines starting with use before the class definition).
  • Add the following import line after the existing use imports:
use Spatie\Permission\Traits\HasRoles;
  • Find the line inside the class that lists the existing traits:
use HasFactory, Notifiable;
  • Add HasRoles to the trait list so it looks like this:
use HasFactory, Notifiable, HasRoles;

What does the HasRoles trait do?

This trait registers Eloquent relationships between your User model and Spatie's roles and permissions tables. It enables methods like $user->assignRole('admin') and $user->can('edit articles') that integrate directly with Laravel's authorization Gate.

  • Save app/Models/User.php.

✔️ Awesome, I've got everything!

Great. Double check you have saved app/Models/User.php with the new import and trait.

ⓧ I'd like to double check the full code

Here is what the top of your app/Models/User.php should look like with the Spatie trait added:

<?php

namespace App\Models;

// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Spatie\Permission\Traits\HasRoles;

class User extends Authenticatable
{
    /** @use HasFactory<\Database\Factories\UserFactory> */
    use HasFactory, Notifiable, HasRoles;

    /**
     * The attributes that are mass assignable.
     *
     * @var list<string>
     */
    protected $fillable = [
        'name',
        'email',
        'password',
    ];

    /**
     * The attributes that should be hidden for serialization.
     *
     * @var list<string>
     */
    protected $hidden = [
        'password',
        'remember_token',
    ];

    /**
     * Get the attributes that should be cast.
     *
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'email_verified_at' => 'datetime',
            'password' => 'hashed',
        ];
    }
}

Seeing a class not found error?

Make sure you spelled the import exactly as use Spatie\Permission\Traits\HasRoles; with proper backslashes. Also verify that the Composer install step completed successfully by checking that vendor/spatie/laravel-permission/ exists in your project.

help me fix a Spatie HasRoles class not found error in my Laravel User model

Your app now has a complete authentication system and the foundation for role-based access control. Next up, you will create the industry-standard folder structure that keeps your code organized as the project grows.

Set Up Industry Folder Structure

With authentication and permissions in place, you're ready to start building features. Before writing any business logic, you need a clean place to put it.

Production Laravel teams separate concerns into dedicated layers. In this step, you'll create directories for Services, Form Requests, and Enums following this industry-standard pattern.

In this step, get ready to:
  • Create the industry-standard folder structure for Services, Form Requests, and Enums.
  • Define type-safe enums for project status and task priority.
  • Build a ProjectService class with documented methods that will hold all project business logic.
Create the service layer directories

Laravel uses PSR-4 autoloading, which means any new folder you create inside app/ is automatically discovered. You don't need to register these folders anywhere.

  • Create all four directories at once by running this command in your WSL2 terminal:
./vendor/bin/sail exec laravel.test mkdir -p app/Services app/Http/Requests/Project app/Http/Requests/Task app/Enums

What do these directories hold?

  • app/Services/ holds business logic. Controllers delegate work here instead of containing logic themselves.
  • app/Http/Requests/Project/ and app/Http/Requests/Task/ hold validation rules for each module, keeping controllers clean.
  • app/Enums/ stores type-safe constants like status values and priority levels.
  • Confirm the directories exist by running:
find app/Services app/Enums app/Http/Requests/Project app/Http/Requests/Task -type d

You should see all four directory paths listed in the output.

Don't see all four directories?

Make sure you're running the command from inside the project-manager directory. If you see a "No such file or directory" error, re-run the mkdir -p command and check for typos in the paths.

help me create the service layer directories for my Laravel project

Create the enums for project status and task priority

PHP backed enums give you type-safe constants that your database columns and frontend can share. Instead of passing raw strings like 'active' around your codebase, you reference ProjectStatus::Active and let PHP enforce valid values.

  • Create the ProjectStatus enum by creating a new file at app/Enums/ProjectStatus.php and adding the following code:
<?php

namespace App\Enums;

// Defines the possible states a project can be in
enum ProjectStatus: string
{
    case Active = 'active';
    case Completed = 'completed';
    case OnHold = 'on_hold';
}

What does this code do?

The : string after the enum name makes this a "backed enum." Each case stores a string value that gets saved to the database.

When you query projects later, Laravel can cast the raw database string back into this enum automatically. This prevents bugs from typos in status strings.

  • Save app/Enums/ProjectStatus.php.

✔️ Awesome, I've got everything!

Great. Make sure you've saved the file before moving on.

ⓧ I'd like to double check the full code

<?php

namespace App\Enums;

// Defines the possible states a project can be in
enum ProjectStatus: string
{
    case Active = 'active';
    case Completed = 'completed';
    case OnHold = 'on_hold';
}
  • Create the TaskPriority enum by creating a new file at app/Enums/TaskPriority.php and adding the following code:
<?php

namespace App\Enums;

// Defines urgency levels for tasks
enum TaskPriority: string
{
    case Low = 'low';
    case Medium = 'medium';
    case High = 'high';
    case Critical = 'critical';
}

What does this code do?

This enum defines four priority levels for tasks. The backed string values ('low', 'medium', 'high', 'critical') are what get stored in the database.

Both enums will be shared with the React frontend via Inertia later, so your frontend and backend always agree on valid values.

  • Save app/Enums/TaskPriority.php.

✔️ Awesome, I've got everything!

Great. Make sure you've saved the file before moving on.

ⓧ I'd like to double check the full code

<?php

namespace App\Enums;

// Defines urgency levels for tasks
enum TaskPriority: string
{
    case Low = 'low';
    case Medium = 'medium';
    case High = 'high';
    case Critical = 'critical';
}

Seeing a syntax error in your enum file?

Make sure the file starts with <?php on the very first line with no blank lines or spaces before it. Also confirm the namespace matches App\Enums exactly, including the capital letters.

help me fix a syntax error in my PHP enum file

Create the ProjectService class

The "thin controllers, fat services" pattern keeps your controllers focused on one job: accept a request, delegate to a service, and return a response. All the actual business logic lives in the Service class, making it reusable across controllers, CLI commands, and queued jobs.

  • Create a new file at app/Services/ProjectService.php and add the following code:
<?php

namespace App\Services;

use App\Enums\ProjectStatus;
use App\Models\Project;
use Illuminate\Database\Eloquent\Collection;

/**
 * Encapsulates all project business logic.
 * Controllers delegate to this service to stay thin.
 */
class ProjectService
{
    /**
     * List projects with optional filters.
     *
     * @param array $filters Optional filters (status, user_id)
     * @return Collection
     */
    public function list(array $filters = []): Collection
    {
        $query = Project::query();

        // Apply status filter if provided
        if (isset($filters['status'])) {
            $query->where('status', $filters['status']);
        }

        // Apply user filter if provided
        if (isset($filters['user_id'])) {
            $query->where('user_id', $filters['user_id']);
        }

        return $query->latest()->get();
    }

    /**
     * Create a new project.
     *
     * @param array $data Validated project data
     * @return Project
     */
    public function create(array $data): Project
    {
        // Assign the authenticated user as the project owner
        $data['user_id'] = auth()->id();

        return Project::create($data);
    }

    /**
     * Update an existing project.
     *
     * @param Project $project The project to update
     * @param array $data Validated update data
     * @return Project
     */
    public function update(Project $project, array $data): Project
    {
        $project->update($data);

        return $project->fresh();
    }

    /**
     * Delete a project.
     *
     * @param Project $project The project to delete
     * @return bool
     */
    public function delete(Project $project): bool
    {
        return $project->delete();
    }
}

What does this code do?

This service class has four public methods that cover all project operations. Each method has a PHPDoc block documenting its purpose, parameters, and return type.

  • list() retrieves projects with optional filters for status and user.
  • create() takes validated data and assigns the current user as the owner.
  • update() applies changes to an existing project and returns the refreshed model.
  • delete() removes a project from the database.

The Project model referenced here doesn't exist yet. You'll create it in a later step. Your IDE may show a warning, but the file is valid PHP and won't cause errors until runtime.

  • Save app/Services/ProjectService.php.
  • In the VS Code Explorer sidebar, expand the app/ folder. You should see Enums/, Services/, and the new subdirectories inside Http/Requests/.

✔️ Awesome, I've got everything!

Great. Double check you've saved all three files before moving on.

ⓧ I'd like to double check the full code

<?php

namespace App\Services;

use App\Enums\ProjectStatus;
use App\Models\Project;
use Illuminate\Database\Eloquent\Collection;

/**
 * Encapsulates all project business logic.
 * Controllers delegate to this service to stay thin.
 */
class ProjectService
{
    /**
     * List projects with optional filters.
     *
     * @param array $filters Optional filters (status, user_id)
     * @return Collection
     */
    public function list(array $filters = []): Collection
    {
        $query = Project::query();

        // Apply status filter if provided
        if (isset($filters['status'])) {
            $query->where('status', $filters['status']);
        }

        // Apply user filter if provided
        if (isset($filters['user_id'])) {
            $query->where('user_id', $filters['user_id']);
        }

        return $query->latest()->get();
    }

    /**
     * Create a new project.
     *
     * @param array $data Validated project data
     * @return Project
     */
    public function create(array $data): Project
    {
        // Assign the authenticated user as the project owner
        $data['user_id'] = auth()->id();

        return Project::create($data);
    }

    /**
     * Update an existing project.
     *
     * @param Project $project The project to update
     * @param array $data Validated update data
     * @return Project
     */
    public function update(Project $project, array $data): Project
    {
        $project->update($data);

        return $project->fresh();
    }

    /**
     * Delete a project.
     *
     * @param Project $project The project to delete
     * @return bool
     */
    public function delete(Project $project): bool
    {
        return $project->delete();
    }
}

Seeing a class not found warning for Project?

This is expected. The App\Models\Project class doesn't exist yet. Your IDE may underline the import, but this won't cause any runtime issues until you try to call these methods. You'll create the Project model in a later step.

help me understand why my Laravel service shows a class not found warning

Why thin controllers and fat services?

In the default Laravel structure, controllers often contain validation, business logic, and database queries all in one method. This works for small apps but becomes tangled as your codebase grows.

By extracting logic into a Service class, you can reuse the same operations from multiple entry points. A controller, an Artisan command, and a queued job can all call the same ProjectService::create() method.

Your project now has a professional folder structure ready for real business logic. Next up, you'll define a custom Tailwind 4 theme to give your app a polished design system.

Configure Custom Tailwind 4 Theme

Your backend architecture is in place with services, enums, and validation directories ready to go. Now you need a visual design system that gives both CRUD modules a consistent, professional look.

In Tailwind 4, design tokens live directly in your CSS using the @theme directive. This replaces the old JavaScript config file with a CSS-first approach. You will define custom colors, fonts, spacing, dark mode variables, and reusable utility classes for status badges and priority indicators.

In this step, get ready to:
  • Define custom brand colors, fonts, and spacing in a @theme block.
  • Add dark mode support with CSS variables under :root and .dark selectors.
  • Create reusable @utility classes for status badges and priority indicators.
Define custom design tokens with @theme

Every visual decision in your app (brand colors, typography, spacing) needs a single source of truth. In Tailwind 4, the @theme directive creates CSS variables that automatically generate corresponding utility classes.

  • Open resources/css/app.css in VS Code.
  • Find the existing @import "tailwindcss" line at the top of the file.
  • Add the following @theme block directly below that import:
/* Custom design tokens for the project management dashboard */
@theme {
  /* Brand colors - generates utilities like bg-primary, text-secondary */
  --color-primary: #4f46e5;
  --color-secondary: #0891b2;
  --color-accent: #f59e0b;

  /* Font families - generates utilities like font-heading, font-mono */
  --font-heading: "Inter", sans-serif;
  --font-mono: "JetBrains Mono", monospace;

  /* Custom spacing - generates utilities like p-18, m-88 */
  --spacing-18: 4.5rem;
  --spacing-88: 22rem;
}

What does this code do?

The @theme directive tells Tailwind to register these CSS variables as design tokens. Each namespace maps to a set of utility classes automatically.

  • --color-primary generates bg-primary, text-primary, border-primary, and all other color utilities.
  • --font-heading generates the font-heading utility class for font-family.
  • --spacing-18 generates utilities like p-18, m-18, w-18 for padding, margin, and width.
  • Save resources/css/app.css.
  • You will not see a visible change yet because no HTML is using these tokens. The next chunks add dark mode variables and utility classes that build on these tokens.

Seeing a CSS syntax error?

Make sure your @theme block sits directly after the @import "tailwindcss" line at the top level of the file. It must not be nested inside any other block or selector.

Check that all lines inside the @theme block end with a semicolon.

help me fix a CSS syntax error in my @theme block

Add dark mode support and a card utility

The Laravel React starter kit already includes a dark mode toggle in the UI. You need to define the color variables that switch between light and dark, plus a reusable card utility that references them.

  • In resources/css/app.css, add the following code below the @theme block you just created:
/* Light mode surface and text colors */
:root {
  --color-surface: #ffffff;
  --color-surface-alt: #f8fafc;
  --color-text-primary: #1e293b;
  --color-text-secondary: #64748b;
  --color-border: #e2e8f0;
}

/* Dark mode overrides - applied when .dark class is on <html> */
.dark {
  --color-surface: #1e293b;
  --color-surface-alt: #0f172a;
  --color-text-primary: #f1f5f9;
  --color-text-secondary: #94a3b8;
  --color-border: #334155;
}

/* Reusable card container utility */
@utility card {
  background-color: var(--color-surface);
  border: 1px solid var(--color-border);
  border-radius: 0.5rem;
  padding: 1.5rem;
}

What does this code do?

The :root block defines default (light mode) surface colors as regular CSS variables. The .dark block overrides them when the user toggles dark mode. These are regular CSS variables (not @theme tokens) because they control runtime appearance rather than generating new utility classes.

The @utility card directive creates a custom utility class called card that you can apply to any element. It automatically works with Tailwind variants like hover:card and dark:card.

  • Save resources/css/app.css.
  • You will not see a visible change yet because no component uses the card utility class. The next chunk adds the status and priority utilities that complete your design system.

Dark mode not toggling later?

Verify that your :root and .dark blocks sit outside the @theme block. The @theme directive is only for design tokens that generate utility classes. Runtime-switching variables belong in standard CSS selectors.

help me debug dark mode CSS variable switching

Create status badge and priority indicator utilities

Your Projects module uses three statuses (Active, Completed, On Hold) and your Tasks module uses four priority levels (Low, Medium, High, Critical). Each needs a distinct visual indicator that works in both light and dark mode.

  • In resources/css/app.css, add the following status badge utilities below the @utility card block:
/* Status badge utilities for the Projects module */
@utility badge-active {
  background-color: #dcfce7;
  color: #166534;
  padding: 0.25rem 0.75rem;
  border-radius: 9999px;
  font-size: 0.75rem;
  font-weight: 600;
}

@utility badge-completed {
  background-color: #dbeafe;
  color: #1e40af;
  padding: 0.25rem 0.75rem;
  border-radius: 9999px;
  font-size: 0.75rem;
  font-weight: 600;
}

@utility badge-on-hold {
  background-color: #fef3c7;
  color: #92400e;
  padding: 0.25rem 0.75rem;
  border-radius: 9999px;
  font-size: 0.75rem;
  font-weight: 600;
}

What do these utilities do?

Each @utility block creates a single-class utility you can apply to any element in your React components. For example, badge-active gives a green pill shape, badge-completed gives a blue pill, and badge-on-hold gives a yellow pill.

Because these are proper Tailwind utilities (not just CSS classes), they work with all Tailwind variants. You could write hover:badge-active or dark:badge-active to conditionally apply them.

  • Now add the priority indicator utilities below the badge utilities:
/* Priority indicator utilities for the Tasks module */
@utility priority-low {
  background-color: #e0f2fe;
  color: #0369a1;
  padding: 0.25rem 0.75rem;
  border-radius: 9999px;
  font-size: 0.75rem;
  font-weight: 600;
}

@utility priority-medium {
  background-color: #fef9c3;
  color: #854d0e;
  padding: 0.25rem 0.75rem;
  border-radius: 9999px;
  font-size: 0.75rem;
  font-weight: 600;
}

@utility priority-high {
  background-color: #fee2e2;
  color: #991b1b;
  padding: 0.25rem 0.75rem;
  border-radius: 9999px;
  font-size: 0.75rem;
  font-weight: 600;
}

@utility priority-critical {
  background-color: #991b1b;
  color: #ffffff;
  padding: 0.25rem 0.75rem;
  border-radius: 9999px;
  font-size: 0.75rem;
  font-weight: 600;
}

Why separate utilities for each priority?

Each priority level uses a distinct color to communicate urgency at a glance. Low is cool blue, Medium is warm yellow, High is red, and Critical is inverted red (white text on red background) to demand attention.

These map directly to the TaskPriority enum you created in the previous step. Your React components will conditionally apply priority-low, priority-medium, etc. based on each task's priority value.

  • Save resources/css/app.css.
  • If the Vite dev server is running (./vendor/bin/sail npm run dev), Vite will recompile the CSS automatically. You will not see visible changes in your browser until you build components that use these utility classes in later steps.

Seeing an error about @utility?

The @utility directive is a Tailwind 4 feature. If you see an "unknown at-rule" error, confirm your project is using Tailwind 4 (installed by the React starter kit). Check package.json for a tailwindcss dependency with a version 4.x or higher.

help me fix an unknown at-rule error for @utility in Tailwind

✔️ Awesome, I've got everything!

Great. Double check you have saved resources/css/app.css with all three sections: the @theme block, the dark mode variables, and all @utility classes.

ⓧ I'd like to double check the full code

@import "tailwindcss";

/* Custom design tokens for the project management dashboard */
@theme {
  /* Brand colors - generates utilities like bg-primary, text-secondary */
  --color-primary: #4f46e5;
  --color-secondary: #0891b2;
  --color-accent: #f59e0b;

  /* Font families - generates utilities like font-heading, font-mono */
  --font-heading: "Inter", sans-serif;
  --font-mono: "JetBrains Mono", monospace;

  /* Custom spacing - generates utilities like p-18, m-88 */
  --spacing-18: 4.5rem;
  --spacing-88: 22rem;
}

/* Light mode surface and text colors */
:root {
  --color-surface: #ffffff;
  --color-surface-alt: #f8fafc;
  --color-text-primary: #1e293b;
  --color-text-secondary: #64748b;
  --color-border: #e2e8f0;
}

/* Dark mode overrides - applied when .dark class is on <html> */
.dark {
  --color-surface: #1e293b;
  --color-surface-alt: #0f172a;
  --color-text-primary: #f1f5f9;
  --color-text-secondary: #94a3b8;
  --color-border: #334155;
}

/* Reusable card container utility */
@utility card {
  background-color: var(--color-surface);
  border: 1px solid var(--color-border);
  border-radius: 0.5rem;
  padding: 1.5rem;
}

/* Status badge utilities for the Projects module */
@utility badge-active {
  background-color: #dcfce7;
  color: #166534;
  padding: 0.25rem 0.75rem;
  border-radius: 9999px;
  font-size: 0.75rem;
  font-weight: 600;
}

@utility badge-completed {
  background-color: #dbeafe;
  color: #1e40af;
  padding: 0.25rem 0.75rem;
  border-radius: 9999px;
  font-size: 0.75rem;
  font-weight: 600;
}

@utility badge-on-hold {
  background-color: #fef3c7;
  color: #92400e;
  padding: 0.25rem 0.75rem;
  border-radius: 9999px;
  font-size: 0.75rem;
  font-weight: 600;
}

/* Priority indicator utilities for the Tasks module */
@utility priority-low {
  background-color: #e0f2fe;
  color: #0369a1;
  padding: 0.25rem 0.75rem;
  border-radius: 9999px;
  font-size: 0.75rem;
  font-weight: 600;
}

@utility priority-medium {
  background-color: #fef9c3;
  color: #854d0e;
  padding: 0.25rem 0.75rem;
  border-radius: 9999px;
  font-size: 0.75rem;
  font-weight: 600;
}

@utility priority-high {
  background-color: #fee2e2;
  color: #991b1b;
  padding: 0.25rem 0.75rem;
  border-radius: 9999px;
  font-size: 0.75rem;
  font-weight: 600;
}

@utility priority-critical {
  background-color: #991b1b;
  color: #ffffff;
  padding: 0.25rem 0.75rem;
  border-radius: 9999px;
  font-size: 0.75rem;
  font-weight: 600;
}

Your design system is locked in. Next up, you will create roles, permissions, and seed test users so you can see these visual indicators in action across different access levels.

Create Roles, Permissions, and Seed Users

In the previous step, you built a custom Tailwind 4 design system with brand colors, dark mode support, and reusable utility classes. Now it's time to define who can do what inside your application.

Your project management app needs three types of users: admins who control everything, developers who can create and manage their own work, and regular users who can only browse. You'll use database seeders to pre-populate your app with these roles, permissions, and test accounts so you can verify access control as you build each module.

In this step, get ready to:
  • Create a seeder that defines three roles and eight permissions across both modules.
  • Create a seeder that generates three demo user accounts with assigned roles.
  • Run the seeders and verify the role assignments in Tinker.
Create the role and permission seeder

Before you can gate access to routes and UI elements, the database needs to know which roles and permissions exist. A seeder file lets you define these once and reproduce them any time you reset the database.

  • Generate the seeder file by running this command in your WSL2 terminal:
./vendor/bin/sail php artisan make:seeder RolePermissionSeeder
  • You should see a message confirming the seeder was created successfully.
  • Open the newly created file at database/seeders/RolePermissionSeeder.php in VS Code.
  • Replace the entire file contents with this code:
<?php

namespace Database\Seeders;

use Illuminate\Database\Seeder;
use Spatie\Permission\Models\Role;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\PermissionRegistrar;

class RolePermissionSeeder extends Seeder
{
    /**
     * Seed roles and permissions for both project and task modules.
     */
    public function run(): void
    {
        // Reset cached roles and permissions
        app()[PermissionRegistrar::class]->forgetCachedPermissions();

        // Define permissions for both modules
        $permissions = [
            'view projects',
            'create projects',
            'edit projects',
            'delete projects',
            'view tasks',
            'create tasks',
            'edit tasks',
            'delete tasks',
        ];

        // Create each permission in the database
        foreach ($permissions as $permission) {
            Permission::create(['name' => $permission]);
        }

        // Create admin role with full access to everything
        $admin = Role::create(['name' => 'admin']);
        $admin->givePermissionTo(Permission::all());

        // Create developer role with create/edit/view access
        $developer = Role::create(['name' => 'developer']);
        $developer->givePermissionTo([
            'view projects',
            'create projects',
            'edit projects',
            'view tasks',
            'create tasks',
            'edit tasks',
        ]);

        // Create user role with view-only access
        $user = Role::create(['name' => 'user']);
        $user->givePermissionTo([
            'view projects',
            'view tasks',
        ]);
    }
}

What does this code do?

The seeder first clears the Spatie permission cache to avoid conflicts when creating new entries. This is a best practice recommended in the Spatie docs.

It then creates eight permissions covering CRUD operations for both the Projects and Tasks modules. Finally, it creates three roles with different permission levels: admin gets everything, developer gets view/create/edit (no delete), and user gets view-only.

  • Save database/seeders/RolePermissionSeeder.php.

Why clear the cache before seeding?

Spatie laravel-permission caches roles and permissions for performance. If you seed new permissions without clearing the cache first, your app might not recognize them until you manually reset. Calling forgetCachedPermissions() at the start of the seeder prevents this issue.

Create the user seeder

With roles defined, you need test accounts to log in as each role. The user seeder creates three accounts with known credentials so you can verify role-based behavior throughout development.

  • Generate the user seeder by running this command:
./vendor/bin/sail php artisan make:seeder UserSeeder
  • Open database/seeders/UserSeeder.php in VS Code.
  • Replace the entire file contents with this code:
<?php

namespace Database\Seeders;

use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;

class UserSeeder extends Seeder
{
    /**
     * Seed demo user accounts for each role.
     */
    public function run(): void
    {
        // Create admin user with full access
        $admin = User::create([
            'name' => 'Admin User',
            'email' => 'admin@example.com',
            'password' => Hash::make('password'),
        ]);
        $admin->assignRole('admin');

        // Create developer user with create/edit access
        $developer = User::create([
            'name' => 'Developer User',
            'email' => 'developer@example.com',
            'password' => Hash::make('password'),
        ]);
        $developer->assignRole('developer');

        // Create regular user with view-only access
        $user = User::create([
            'name' => 'Regular User',
            'email' => 'user@example.com',
            'password' => Hash::make('password'),
        ]);
        $user->assignRole('user');
    }
}

What does this code do?

Each user is created with a name, email, and hashed password. The assignRole() method comes from the HasRoles trait you added to the User model in a previous step.

All three accounts use password as the password for easy testing. In production, you would never seed real user credentials this way.

  • Save database/seeders/UserSeeder.php.

Seeing a role not found error later?

The UserSeeder depends on roles already existing in the database. Make sure you always run the RolePermissionSeeder before the UserSeeder. The next substep handles this ordering automatically.

help me debug a role not found error in my Laravel seeder

Register and run the seeders

Laravel only runs seeders that are registered in the main DatabaseSeeder.php file. You need to wire up both seeders in the correct order, then execute them.

  • Open database/seeders/DatabaseSeeder.php in VS Code.
  • Replace the entire file contents with this code:
<?php

namespace Database\Seeders;

use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
    /**
     * Seed the application's database.
     */
    public function run(): void
    {
        // Run role and permission seeder first (users depend on roles existing)
        $this->call([
            RolePermissionSeeder::class,
            UserSeeder::class,
        ]);
    }
}

Why does order matter here?

The $this->call() method runs seeders in the order they appear in the array. RolePermissionSeeder must come first because UserSeeder calls assignRole(), which requires the role to already exist in the database.

  • Save database/seeders/DatabaseSeeder.php.
  • Run the seeders by executing this command:
./vendor/bin/sail php artisan db:seed
  • You should see output confirming both seeders ran successfully, with lines mentioning RolePermissionSeeder and UserSeeder.
  • Verify the seeding worked by opening Tinker:
./vendor/bin/sail php artisan tinker
  • Inside Tinker, run this command to check the first user's role:
\App\Models\User::first()->getRoleNames()
  • You should see a collection containing "admin" returned. This confirms the admin user was created and assigned the admin role.
  • Type exit to leave Tinker.

Seeing a table not found error?

Make sure you ran the Spatie migration in the previous step with ./vendor/bin/sail php artisan migrate. The seeder needs the roles, permissions, and model_has_roles tables to exist.

If you need to reset and re-run everything, use ./vendor/bin/sail php artisan migrate:fresh --seed to drop all tables, recreate them, and seed in one command.

help me fix a table not found error when running my Laravel database seeders

✔️ Awesome, I've got everything!

Great. Double check you have saved all three seeder files.

ⓧ I'd like to double check the full code

Here is the full code for each file:

database/seeders/RolePermissionSeeder.php:

<?php

namespace Database\Seeders;

use Illuminate\Database\Seeder;
use Spatie\Permission\Models\Role;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\PermissionRegistrar;

class RolePermissionSeeder extends Seeder
{
    /**
     * Seed roles and permissions for both project and task modules.
     */
    public function run(): void
    {
        // Reset cached roles and permissions
        app()[PermissionRegistrar::class]->forgetCachedPermissions();

        // Define permissions for both modules
        $permissions = [
            'view projects',
            'create projects',
            'edit projects',
            'delete projects',
            'view tasks',
            'create tasks',
            'edit tasks',
            'delete tasks',
        ];

        // Create each permission in the database
        foreach ($permissions as $permission) {
            Permission::create(['name' => $permission]);
        }

        // Create admin role with full access to everything
        $admin = Role::create(['name' => 'admin']);
        $admin->givePermissionTo(Permission::all());

        // Create developer role with create/edit/view access
        $developer = Role::create(['name' => 'developer']);
        $developer->givePermissionTo([
            'view projects',
            'create projects',
            'edit projects',
            'view tasks',
            'create tasks',
            'edit tasks',
        ]);

        // Create user role with view-only access
        $user = Role::create(['name' => 'user']);
        $user->givePermissionTo([
            'view projects',
            'view tasks',
        ]);
    }
}

database/seeders/UserSeeder.php:

<?php

namespace Database\Seeders;

use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;

class UserSeeder extends Seeder
{
    /**
     * Seed demo user accounts for each role.
     */
    public function run(): void
    {
        // Create admin user with full access
        $admin = User::create([
            'name' => 'Admin User',
            'email' => 'admin@example.com',
            'password' => Hash::make('password'),
        ]);
        $admin->assignRole('admin');

        // Create developer user with create/edit access
        $developer = User::create([
            'name' => 'Developer User',
            'email' => 'developer@example.com',
            'password' => Hash::make('password'),
        ]);
        $developer->assignRole('developer');

        // Create regular user with view-only access
        $user = User::create([
            'name' => 'Regular User',
            'email' => 'user@example.com',
            'password' => Hash::make('password'),
        ]);
        $user->assignRole('user');
    }
}

database/seeders/DatabaseSeeder.php:

<?php

namespace Database\Seeders;

use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
    /**
     * Seed the application's database.
     */
    public function run(): void
    {
        // Run role and permission seeder first (users depend on roles existing)
        $this->call([
            RolePermissionSeeder::class,
            UserSeeder::class,
        ]);
    }
}

Your database now has three roles, eight permissions, and three test accounts ready to go. Next up, you'll build the Projects module backend with controllers, services, and form requests that check these permissions before allowing any action.

Build the Projects Module Backend

Your roles and permissions are seeded, so now you need something for those permissions to protect. The Projects module is the first of two CRUD modules in this app.

In this step, you will create the Project model with its database table, add validation via Form Requests, and wire up a controller that delegates business logic to a service and checks permissions via a policy. This is the "thin controller, fat service" pattern used in production Laravel applications.

In this step, get ready to:
  • Create the Project model, migration, and resource controller.
  • Add Form Request classes for create and update validation.
  • Implement the controller with service layer delegation and policy authorization.
Create the Project model, migration, and controller

Laravel's Artisan CLI can scaffold a model, migration, and resource controller in a single command.

  • Generate the Project model along with its migration and resource controller by running:
./vendor/bin/sail php artisan make:model Project -mcr

What does this command do?

The -m flag creates a database migration, -c creates a controller, and -r makes it a resource controller with index, create, store, edit, update, and destroy methods pre-scaffolded.

Now define the columns for the projects table.

  • Open the new migration file in database/migrations/ (it ends with _create_projects_table.php).
  • Replace the contents of the up() method with the following:
public function up(): void
{
    Schema::create('projects', function (Blueprint $table) {
        // Primary key
        $table->id();

        // Project fields
        $table->string('name');
        $table->text('description')->nullable();
        $table->string('status');

        // Owner relationship
        $table->foreignId('user_id')->constrained('users');

        $table->timestamps();
    });
}

What does this migration do?

Each column maps to a property on the Project model. The status column stores the string value of the ProjectStatus enum you created earlier.

The foreignId('user_id')->constrained('users') creates a foreign key linking each project to the user who owns it.

Next, set up the model so Laravel knows which fields are mass-assignable and how to cast the status.

  • Open app/Models/Project.php and replace the entire file with:
<?php

namespace App\Models;

use App\Enums\ProjectStatus;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Project extends Model
{
    // Fields that can be mass-assigned
    protected $fillable = [
        'name',
        'description',
        'status',
        'user_id',
    ];

    // Cast the status string to the ProjectStatus enum
    protected $casts = [
        'status' => ProjectStatus::class,
    ];

    // Each project belongs to a user (the owner)
    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }
}

What does this model do?

The $fillable array protects against mass-assignment vulnerabilities by whitelisting which fields can be set via Project::create().

The $casts array tells Laravel to automatically convert the raw status string from the database into a ProjectStatus enum instance. The user() relationship lets you access the project owner via $project->user.

  • Run the migration to create the projects table in your database:
./vendor/bin/sail php artisan migrate
  • Save app/Models/Project.php.
  • Confirm the migration succeeded. You should see output like DONE next to create_projects_table in your terminal.

Migration failed?

Check that your Sail containers are running with ./vendor/bin/sail up -d. Also ensure the foreignId references the users table, which must exist already from the starter kit migrations.

help me debug my migration error

✔️ Awesome, I've got everything!

Great. Make sure you have saved app/Models/Project.php and your migration file.

ⓧ I'd like to double check the full code

Here is the complete app/Models/Project.php:

<?php

namespace App\Models;

use App\Enums\ProjectStatus;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Project extends Model
{
    // Fields that can be mass-assigned
    protected $fillable = [
        'name',
        'description',
        'status',
        'user_id',
    ];

    // Cast the status string to the ProjectStatus enum
    protected $casts = [
        'status' => ProjectStatus::class,
    ];

    // Each project belongs to a user (the owner)
    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }
}
Create Form Request classes for validation

Form Requests move validation logic out of the controller and into dedicated classes. Each request class defines the rules that incoming data must satisfy before the controller ever sees it.

  • Generate the two Form Request classes by running these commands:
./vendor/bin/sail php artisan make:request Project/StoreProjectRequest
./vendor/bin/sail php artisan make:request Project/UpdateProjectRequest

Why two separate request classes?

Even though both share the same rules in this project, separating them lets you customize validation for each operation independently in the future. For example, you might make name required on create but optional on update (partial updates).

  • Open app/Http/Requests/Project/StoreProjectRequest.php and replace the entire file with:
<?php

namespace App\Http\Requests\Project;

use App\Enums\ProjectStatus;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class StoreProjectRequest extends FormRequest
{
    // Authorization is handled by the Policy, so always allow here
    public function authorize(): bool
    {
        return true;
    }

    // Define validation rules for creating a project
    public function rules(): array
    {
        return [
            'name' => ['required', 'string', 'max:255'],
            'description' => ['nullable', 'string'],
            'status' => ['required', Rule::in(array_column(ProjectStatus::cases(), 'value'))],
        ];
    }
}

What does this code do?

The authorize() method returns true because permission checks happen in the controller via the Policy. The rules() method enforces that name is required with a maximum of 255 characters, description can be null, and status must match a valid ProjectStatus enum value.

  • Open app/Http/Requests/Project/UpdateProjectRequest.php and replace the entire file with:
<?php

namespace App\Http\Requests\Project;

use App\Enums\ProjectStatus;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class UpdateProjectRequest extends FormRequest
{
    // Authorization is handled by the Policy, so always allow here
    public function authorize(): bool
    {
        return true;
    }

    // Define validation rules for updating a project
    public function rules(): array
    {
        return [
            'name' => ['required', 'string', 'max:255'],
            'description' => ['nullable', 'string'],
            'status' => ['required', Rule::in(array_column(ProjectStatus::cases(), 'value'))],
        ];
    }
}
  • Save both files.
  • Confirm they exist by running:
ls app/Http/Requests/Project/
  • You should see StoreProjectRequest.php and UpdateProjectRequest.php listed.

Files not appearing?

Make sure you ran the make:request commands from your WSL terminal inside the project-manager directory. The Project/ prefix in the command creates a subdirectory automatically.

help me find my Form Request files

✔️ Awesome, I've got everything!

Great. Both Form Request classes are ready for use in the controller.

ⓧ I'd like to double check the full code

Here is the complete StoreProjectRequest.php:

<?php

namespace App\Http\Requests\Project;

use App\Enums\ProjectStatus;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class StoreProjectRequest extends FormRequest
{
    // Authorization is handled by the Policy, so always allow here
    public function authorize(): bool
    {
        return true;
    }

    // Define validation rules for creating a project
    public function rules(): array
    {
        return [
            'name' => ['required', 'string', 'max:255'],
            'description' => ['nullable', 'string'],
            'status' => ['required', Rule::in(array_column(ProjectStatus::cases(), 'value'))],
        ];
    }
}

And the complete UpdateProjectRequest.php:

<?php

namespace App\Http\Requests\Project;

use App\Enums\ProjectStatus;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class UpdateProjectRequest extends FormRequest
{
    // Authorization is handled by the Policy, so always allow here
    public function authorize(): bool
    {
        return true;
    }

    // Define validation rules for updating a project
    public function rules(): array
    {
        return [
            'name' => ['required', 'string', 'max:255'],
            'description' => ['nullable', 'string'],
            'status' => ['required', Rule::in(array_column(ProjectStatus::cases(), 'value'))],
        ];
    }
}
Implement the controller with service layer and policy

The controller is the traffic director. It receives requests, checks permissions via a Policy, delegates business logic to the ProjectService, and returns responses. This keeps the controller thin and testable.

You will update the ProjectService you created earlier (now that the model exists), create a Policy for authorization, implement the controller, and register the routes.

  • Open app/Services/ProjectService.php and replace the entire file with the full implementation:
<?php

namespace App\Services;

use App\Models\Project;
use Illuminate\Database\Eloquent\Collection;

class ProjectService
{
    /**
     * List projects with optional filters.
     *
     * @param array $filters Optional filters (status, user_id)
     * @return Collection
     */
    public function list(array $filters = []): Collection
    {
        $query = Project::with('user');

        if (isset($filters['status'])) {
            $query->where('status', $filters['status']);
        }

        if (isset($filters['user_id'])) {
            $query->where('user_id', $filters['user_id']);
        }

        return $query->latest()->get();
    }

    /**
     * Create a new project.
     *
     * @param array $data Validated project data
     * @return Project
     */
    public function create(array $data): Project
    {
        return Project::create($data);
    }

    /**
     * Update an existing project.
     *
     * @param Project $project The project to update
     * @param array $data Validated update data
     * @return Project
     */
    public function update(Project $project, array $data): Project
    {
        $project->update($data);
        return $project->fresh();
    }

    /**
     * Delete a project.
     *
     * @param Project $project The project to delete
     * @return bool
     */
    public function delete(Project $project): bool
    {
        return $project->delete();
    }
}

What does this service do?

The service encapsulates all Project business logic. The list() method eager-loads the user relationship and supports filtering by status or owner. The create(), update(), and delete() methods handle persistence.

By keeping this logic in the service rather than the controller, you can reuse it from CLI commands, queued jobs, or API controllers later without duplicating code.

  • Generate the ProjectPolicy with model scaffolding by running:
./vendor/bin/sail php artisan make:policy ProjectPolicy --model=Project
  • Open app/Policies/ProjectPolicy.php and replace the entire file with:
<?php

namespace App\Policies;

use App\Models\Project;
use App\Models\User;

class ProjectPolicy
{
    // Check if user can view any projects
    public function viewAny(User $user): bool
    {
        return $user->can('view projects');
    }

    // Check if user can view a specific project
    public function view(User $user, Project $project): bool
    {
        return $user->can('view projects');
    }

    // Check if user can create projects
    public function create(User $user): bool
    {
        return $user->can('create projects');
    }

    // Check if user can update this project
    public function update(User $user, Project $project): bool
    {
        return $user->can('edit projects');
    }

    // Check if user can delete this project
    public function delete(User $user, Project $project): bool
    {
        return $user->can('delete projects');
    }
}

What does this policy do?

Each method checks a Spatie permission. The $user->can('view projects') call uses Laravel's Gate integration that Spatie provides automatically. The permission names match the ones you defined in RolePermissionSeeder.

  • Open app/Http/Controllers/ProjectController.php and replace the entire file with:
<?php

namespace App\Http\Controllers;

use App\Http\Requests\Project\StoreProjectRequest;
use App\Http\Requests\Project\UpdateProjectRequest;
use App\Models\Project;
use App\Services\ProjectService;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;

class ProjectController extends Controller
{
    // Inject the service via constructor
    public function __construct(
        private ProjectService $projectService
    ) {}

    // List all projects
    public function index(): Response
    {
        $this->authorize('viewAny', Project::class);

        $projects = $this->projectService->list();

        return Inertia::render('projects/index', [
            'projects' => $projects,
        ]);
    }

    // Show the create form
    public function create(): Response
    {
        $this->authorize('create', Project::class);

        return Inertia::render('projects/create');
    }

    // Store a new project
    public function store(StoreProjectRequest $request): RedirectResponse
    {
        $this->authorize('create', Project::class);

        $this->projectService->create([
            ...$request->validated(),
            'user_id' => $request->user()->id,
        ]);

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

    // Show the edit form
    public function edit(Project $project): Response
    {
        $this->authorize('update', $project);

        return Inertia::render('projects/edit', [
            'project' => $project,
        ]);
    }

    // Update an existing project
    public function update(UpdateProjectRequest $request, Project $project): RedirectResponse
    {
        $this->authorize('update', $project);

        $this->projectService->update($project, $request->validated());

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

    // Delete a project
    public function destroy(Project $project): RedirectResponse
    {
        $this->authorize('delete', $project);

        $this->projectService->delete($project);

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

What does this controller do?

Each method calls $this->authorize() which checks the ProjectPolicy. If the user lacks permission, Laravel returns a 403 response automatically.

The store() method merges the authenticated user's ID with the validated form data before creating the project. The StoreProjectRequest type-hint triggers automatic validation before the method body executes.

Finally, register the resource route so Laravel knows how to reach the controller.

  • Open routes/web.php and add the following import at the top of the file with the other use statements:
use App\Http\Controllers\ProjectController;
  • Then add the resource route inside the file (after the existing routes):
// Project CRUD routes - requires authentication
Route::resource('projects', ProjectController::class)->middleware(['auth']);

What does this route do?

A single Route::resource() call registers all seven RESTful routes (index, create, store, show, edit, update, destroy) for the Projects module. The auth middleware ensures only logged-in users can access these routes.

  • Save all files: ProjectService.php, ProjectPolicy.php, ProjectController.php, and routes/web.php.
  • Verify the routes are registered by running:
./vendor/bin/sail php artisan route:list --path=projects
  • You should see a list of routes including GET /projects, POST /projects, GET /projects/{project}/edit, PUT /projects/{project}, and DELETE /projects/{project}.

Routes not showing up?

Make sure you added the use App\Http\Controllers\ProjectController; import at the top of routes/web.php. Without the import, Laravel cannot find the class and the route registration silently fails.

help me debug my project routes

✔️ Awesome, I've got everything!

Great. Your Projects module backend is fully wired up with model, migration, validation, service, policy, controller, and routes.

ⓧ I'd like to double check the full code

Here is the complete app/Services/ProjectService.php:

<?php

namespace App\Services;

use App\Models\Project;
use Illuminate\Database\Eloquent\Collection;

class ProjectService
{
    /**
     * List projects with optional filters.
     *
     * @param array $filters Optional filters (status, user_id)
     * @return Collection
     */
    public function list(array $filters = []): Collection
    {
        $query = Project::with('user');

        if (isset($filters['status'])) {
            $query->where('status', $filters['status']);
        }

        if (isset($filters['user_id'])) {
            $query->where('user_id', $filters['user_id']);
        }

        return $query->latest()->get();
    }

    /**
     * Create a new project.
     *
     * @param array $data Validated project data
     * @return Project
     */
    public function create(array $data): Project
    {
        return Project::create($data);
    }

    /**
     * Update an existing project.
     *
     * @param Project $project The project to update
     * @param array $data Validated update data
     * @return Project
     */
    public function update(Project $project, array $data): Project
    {
        $project->update($data);
        return $project->fresh();
    }

    /**
     * Delete a project.
     *
     * @param Project $project The project to delete
     * @return bool
     */
    public function delete(Project $project): bool
    {
        return $project->delete();
    }
}

Here is the complete app/Policies/ProjectPolicy.php:

<?php

namespace App\Policies;

use App\Models\Project;
use App\Models\User;

class ProjectPolicy
{
    // Check if user can view any projects
    public function viewAny(User $user): bool
    {
        return $user->can('view projects');
    }

    // Check if user can view a specific project
    public function view(User $user, Project $project): bool
    {
        return $user->can('view projects');
    }

    // Check if user can create projects
    public function create(User $user): bool
    {
        return $user->can('create projects');
    }

    // Check if user can update this project
    public function update(User $user, Project $project): bool
    {
        return $user->can('edit projects');
    }

    // Check if user can delete this project
    public function delete(User $user, Project $project): bool
    {
        return $user->can('delete projects');
    }
}

Here is the complete app/Http/Controllers/ProjectController.php:

<?php

namespace App\Http\Controllers;

use App\Http\Requests\Project\StoreProjectRequest;
use App\Http\Requests\Project\UpdateProjectRequest;
use App\Models\Project;
use App\Services\ProjectService;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;

class ProjectController extends Controller
{
    // Inject the service via constructor
    public function __construct(
        private ProjectService $projectService
    ) {}

    // List all projects
    public function index(): Response
    {
        $this->authorize('viewAny', Project::class);

        $projects = $this->projectService->list();

        return Inertia::render('projects/index', [
            'projects' => $projects,
        ]);
    }

    // Show the create form
    public function create(): Response
    {
        $this->authorize('create', Project::class);

        return Inertia::render('projects/create');
    }

    // Store a new project
    public function store(StoreProjectRequest $request): RedirectResponse
    {
        $this->authorize('create', Project::class);

        $this->projectService->create([
            ...$request->validated(),
            'user_id' => $request->user()->id,
        ]);

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

    // Show the edit form
    public function edit(Project $project): Response
    {
        $this->authorize('update', $project);

        return Inertia::render('projects/edit', [
            'project' => $project,
        ]);
    }

    // Update an existing project
    public function update(UpdateProjectRequest $request, Project $project): RedirectResponse
    {
        $this->authorize('update', $project);

        $this->projectService->update($project, $request->validated());

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

    // Delete a project
    public function destroy(Project $project): RedirectResponse
    {
        $this->authorize('delete', $project);

        $this->projectService->delete($project);

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

Your Projects module backend is complete. Next up, you will build the Tasks module following the same service layer and policy pattern.

Build the Tasks Module Backend

Your Projects module is fully wired up with a model, service layer, form requests, policy, and controller. Now you need a second module to demonstrate how this architecture scales.

Tasks live inside projects. Each task has a title, priority level, status, and an assignee. In this step, you will build the entire Tasks backend following the same service-layer pattern you used for Projects.

In this step, get ready to:
  • Create the Task model, migration, and resource controller.
  • Build a TaskService with filtering and auto-assignment logic.
  • Wire up nested routes with permission middleware.
Create the Task model, migration, and controller

Just like you did for Projects, Laravel's Artisan can scaffold the model, migration, and resource controller in a single command.

  • Generate the Task model, migration, and controller by running this command in your WSL2 terminal:
./vendor/bin/sail php artisan make:model Task -mcr

What does this command do?

The -mcr flags tell Artisan to generate a migration, controller (resource style), and the model itself. This is the same shortcut you used for the Project model.

  • Open the new migration file at database/migrations/. It will have a timestamp prefix and end with _create_tasks_table.php.
  • Replace the up() method contents with the following column definitions:
<?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();
            // Priority stored as string, cast to TaskPriority enum in the model
            $table->string('priority');
            // Status tracks workflow: pending, in_progress, completed
            $table->string('status');
            // Every task belongs to a project
            $table->foreignId('project_id')->constrained('projects');
            // Assignee is optional (auto-assigned in the service layer)
            $table->foreignId('assigned_to')->nullable()->constrained('users');
            $table->timestamps();
        });
    }

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

What does this migration do?

This creates a tasks table with two foreign keys. The project_id links each task to a project. The assigned_to column is nullable because the service layer will auto-assign the current user if no assignee is specified.

  • Open app/Models/Task.php and replace its contents with the following model definition:
<?php

namespace App\Models;

use App\Enums\TaskPriority;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Task extends Model
{
    // Fields that can be mass-assigned
    protected $fillable = [
        'title',
        'description',
        'priority',
        'status',
        'project_id',
        'assigned_to',
    ];

    // Cast priority to the TaskPriority enum for type safety
    protected $casts = [
        'priority' => TaskPriority::class,
    ];

    // Each task belongs to a project
    public function project(): BelongsTo
    {
        return $this->belongsTo(Project::class);
    }

    // Each task can be assigned to a user
    public function assignee(): BelongsTo
    {
        return $this->belongsTo(User::class, 'assigned_to');
    }
}

What does this model do?

The $fillable array lists every column that can be mass-assigned. The $casts array tells Laravel to convert the raw priority string into a TaskPriority enum instance automatically.

The two BelongsTo relationships let you access $task->project and $task->assignee as Eloquent relationships. The second relationship uses a custom foreign key (assigned_to) since it does not follow Laravel's naming convention.

Now add the inverse relationship to the Project model so a project knows about its tasks.

  • Open app/Models/Project.php and find the existing user() relationship method.
  • Add this import at the top of the file alongside the existing imports:
use Illuminate\Database\Eloquent\Relations\HasMany;
  • Add the following method below the user() relationship in the Project model:
// A project has many tasks
public function tasks(): HasMany
{
    return $this->hasMany(Task::class);
}

Why add the inverse relationship?

This lets you call $project->tasks to get all tasks belonging to a project. It is the other side of the BelongsTo you just defined in the Task model.

  • Save both model files.
  • Run the migration to create the tasks table:
./vendor/bin/sail php artisan migrate
  • You should see output confirming that the create_tasks_table migration ran successfully.

Migration failed?

If you see an error about a missing projects table, make sure your Projects migration ran in a previous step. Run ./vendor/bin/sail php artisan migrate:status to check.

If you see a syntax error, double-check that foreignId('assigned_to')->nullable() has nullable() before constrained(). help me fix my tasks migration error

Create the TaskService and form request classes

Form requests keep validation logic out of your controller. The service layer keeps business logic out of your controller. Together they produce thin, readable controllers.

  • Generate the form request classes by running these two commands:
./vendor/bin/sail php artisan make:request Task/StoreTaskRequest
./vendor/bin/sail php artisan make:request Task/UpdateTaskRequest
  • Open app/Http/Requests/Task/StoreTaskRequest.php and replace its contents with:
<?php

namespace App\Http\Requests\Task;

use App\Enums\TaskPriority;
use Illuminate\Foundation\Http\FormRequest;

class StoreTaskRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    /**
     * Validation rules for creating a task.
     */
    public function rules(): array
    {
        return [
            'title' => 'required|string|max:255',
            'description' => 'nullable|string',
            // Priority must match one of the TaskPriority enum values
            'priority' => 'required|in:' . implode(',', array_column(TaskPriority::cases(), 'value')),
            // Status tracks workflow state
            'status' => 'required|in:pending,in_progress,completed',
            // Must reference an existing project
            'project_id' => 'required|exists:projects,id',
            // Assignee is optional, but must be a valid user if provided
            'assigned_to' => 'nullable|exists:users,id',
        ];
    }
}

What do these validation rules do?

The in: rule for priority dynamically builds a comma-separated list from your TaskPriority enum cases (Low, Medium, High, Critical). This means if you add a new priority level to the enum later, the validation automatically accepts it.

The exists:projects,id rule ensures the task references a real project. The exists:users,id rule on assigned_to only fires when a value is provided, thanks to the nullable rule preceding it.

  • Open app/Http/Requests/Task/UpdateTaskRequest.php and replace its contents with the same structure and rules as StoreTaskRequest. Change only the class name to UpdateTaskRequest.
<?php

namespace App\Http\Requests\Task;

use App\Enums\TaskPriority;
use Illuminate\Foundation\Http\FormRequest;

class UpdateTaskRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    /**
     * Validation rules for updating a task.
     */
    public function rules(): array
    {
        return [
            'title' => 'required|string|max:255',
            'description' => 'nullable|string',
            'priority' => 'required|in:' . implode(',', array_column(TaskPriority::cases(), 'value')),
            'status' => 'required|in:pending,in_progress,completed',
            'project_id' => 'required|exists:projects,id',
            'assigned_to' => 'nullable|exists:users,id',
        ];
    }
}
  • Save both request files.
  • Now create the service class. Create a new file at app/Services/TaskService.php and add the following:
<?php

namespace App\Services;

use App\Models\Task;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Auth;

class TaskService
{
    /**
     * List tasks with optional filters by project, priority, and assignee.
     *
     * @param array $filters Associative array of filter criteria
     * @return Collection
     */
    public function list(array $filters): Collection
    {
        $query = Task::query();

        // Filter by parent project
        if (isset($filters['project_id'])) {
            $query->where('project_id', $filters['project_id']);
        }

        // Filter by priority level
        if (isset($filters['priority'])) {
            $query->where('priority', $filters['priority']);
        }

        // Filter by assigned user
        if (isset($filters['assigned_to'])) {
            $query->where('assigned_to', $filters['assigned_to']);
        }

        // Eager-load relationships to avoid N+1 queries
        return $query->with(['project', 'assignee'])->get();
    }

    /**
     * Create a new task. Auto-assigns to current user if no assignee specified.
     *
     * @param array $data Validated task data
     * @return Task
     */
    public function create(array $data): Task
    {
        // Auto-assign to the logged-in user when no assignee is provided
        if (empty($data['assigned_to'])) {
            $data['assigned_to'] = Auth::id();
        }

        return Task::create($data);
    }

    /**
     * Update an existing task.
     *
     * @param Task $task The task to update
     * @param array $data Validated update data
     * @return Task
     */
    public function update(Task $task, array $data): Task
    {
        $task->update($data);
        return $task->fresh();
    }

    /**
     * Delete a task.
     *
     * @param Task $task The task to delete
     * @return bool
     */
    public function delete(Task $task): bool
    {
        return $task->delete();
    }
}

What is the auto-assign logic?

The create() method checks whether assigned_to is empty. If no assignee was specified in the request, it fills in the currently authenticated user's ID via Auth::id(). This way developers creating tasks for themselves don't need to manually pick their own name.

The list() method accepts an array of optional filters. Each filter is applied only if its key exists, giving the controller flexibility to pass project, priority, or assignee filters from query parameters.

  • Save app/Services/TaskService.php.

Seeing a class not found error later?

Laravel uses PSR-4 autoloading. Any class inside the app/ directory is auto-discovered as long as the namespace matches the folder path. Verify that namespace App\Services; appears at the top of your file and the file is saved at app/Services/TaskService.php. help me fix a class not found error in my Laravel service

Implement the TaskController and define nested routes

The controller ties everything together. It receives HTTP requests, delegates to the service, and uses policies for authorization. The routes use nested resource syntax to express the parent-child relationship between projects and tasks.

  • Create a new file at app/Policies/TaskPolicy.php with the following contents:
<?php

namespace App\Policies;

use App\Models\Task;
use App\Models\User;

class TaskPolicy
{
    // Check if user can view any tasks
    public function viewAny(User $user): bool
    {
        return $user->can('view tasks');
    }

    // Check if user can view a specific task
    public function view(User $user, Task $task): bool
    {
        return $user->can('view tasks');
    }

    // Check if user can create tasks
    public function create(User $user): bool
    {
        return $user->can('create tasks');
    }

    // Check if user can update a specific task
    public function update(User $user, Task $task): bool
    {
        return $user->can('edit tasks');
    }

    // Check if user can delete a specific task
    public function delete(User $user, Task $task): bool
    {
        return $user->can('delete tasks');
    }
}

How does the policy connect to Spatie permissions?

Each policy method calls $user->can('permission name') which checks against the permissions you seeded with Spatie laravel-permission. The controller calls $this->authorize() which invokes the matching policy method. This two-layer approach keeps permission names in one place (the policy) and authorization calls clean in the controller.

  • Open app/Http/Controllers/TaskController.php and replace its contents with:
<?php

namespace App\Http\Controllers;

use App\Http\Requests\Task\StoreTaskRequest;
use App\Http\Requests\Task\UpdateTaskRequest;
use App\Models\Project;
use App\Models\Task;
use App\Services\TaskService;
use Inertia\Inertia;

class TaskController extends Controller
{
    // Inject TaskService for business logic delegation
    public function __construct(
        private TaskService $taskService
    ) {}

    // Display tasks for a given project
    public function index(Project $project)
    {
        $this->authorize('viewAny', Task::class);

        $tasks = $this->taskService->list(['project_id' => $project->id]);

        return Inertia::render('tasks/index', [
            'project' => $project,
            'tasks' => $tasks,
        ]);
    }

    // Show the create task form
    public function create(Project $project)
    {
        $this->authorize('create', Task::class);

        return Inertia::render('tasks/create', [
            'project' => $project,
        ]);
    }

    // Store a new task via the service layer
    public function store(StoreTaskRequest $request, Project $project)
    {
        $this->authorize('create', Task::class);

        $data = $request->validated();
        $data['project_id'] = $project->id;

        $this->taskService->create($data);

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

    // Show the edit task form
    public function edit(Project $project, Task $task)
    {
        $this->authorize('update', $task);

        return Inertia::render('tasks/edit', [
            'project' => $project,
            'task' => $task,
        ]);
    }

    // Update an existing task via the service layer
    public function update(UpdateTaskRequest $request, Project $project, Task $task)
    {
        $this->authorize('update', $task);

        $this->taskService->update($task, $request->validated());

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

    // Delete a task
    public function destroy(Project $project, Task $task)
    {
        $this->authorize('delete', $task);

        $this->taskService->delete($task);

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

Why does every method receive a Project parameter?

Because tasks are nested under projects in the URL (/projects/{project}/tasks), Laravel automatically resolves the parent Project model from the route. This gives you the parent context in every controller method without an extra query.

The store() method sets project_id from the route parameter rather than trusting the request body. This prevents a user from submitting a task to a project they do not have access to via the URL.

  • Save app/Http/Controllers/TaskController.php.
  • Open routes/web.php and add the nested task resource route inside the existing auth middleware group, below the projects route:
use App\Http\Controllers\TaskController;

Route::middleware(['auth'])->group(function () {
    Route::resource('projects', ProjectController::class);
    // Nested resource: /projects/{project}/tasks
    Route::resource('projects.tasks', TaskController::class)
        ->middlewareFor(['store', 'update'], 'permission:create tasks|edit tasks')
        ->middlewareFor('destroy', 'permission:delete tasks');
});

What does the nested resource route do?

The Route::resource('projects.tasks', ...) syntax generates URLs like /projects/{project}/tasks and /projects/{project}/tasks/{task}. Laravel infers the parent-child relationship from the dot notation.

The middlewareFor method applies Spatie's permission middleware to specific actions. The store and update actions require the create tasks or edit tasks permission. The destroy action requires delete tasks. This provides a route-level guard on top of the policy checks in the controller.

Register the permission middleware alias

If you have not already registered Spatie's middleware aliases, open bootstrap/app.php and add the permission alias inside the withMiddleware callback:

->withMiddleware(function (Middleware $middleware) {
    $middleware->alias([
        'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,
        'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class,
        'role_or_permission' => \Spatie\Permission\Middleware\RoleOrPermissionMiddleware::class,
    ]);
})
  • Save routes/web.php and bootstrap/app.php.
  • Verify the routes registered correctly by running:
./vendor/bin/sail php artisan route:list --path=tasks
  • You should see routes for projects.tasks.index, projects.tasks.create, projects.tasks.store, projects.tasks.edit, projects.tasks.update, and projects.tasks.destroy listed in the output.

Routes not showing up?

Make sure the use App\Http\Controllers\TaskController; import statement is at the top of routes/web.php. If you see a middleware error, confirm the permission alias is registered in bootstrap/app.php. help me debug my Laravel nested resource routes

✔️ Awesome, I've got everything!

Great. Double check you have saved all your files.

ⓧ I'd like to double check the full code

Here is the full TaskController.php for comparison:

<?php

namespace App\Http\Controllers;

use App\Http\Requests\Task\StoreTaskRequest;
use App\Http\Requests\Task\UpdateTaskRequest;
use App\Models\Project;
use App\Models\Task;
use App\Services\TaskService;
use Inertia\Inertia;

class TaskController extends Controller
{
    // Inject TaskService for business logic delegation
    public function __construct(
        private TaskService $taskService
    ) {}

    // Display tasks for a given project
    public function index(Project $project)
    {
        $this->authorize('viewAny', Task::class);

        $tasks = $this->taskService->list(['project_id' => $project->id]);

        return Inertia::render('tasks/index', [
            'project' => $project,
            'tasks' => $tasks,
        ]);
    }

    // Show the create task form
    public function create(Project $project)
    {
        $this->authorize('create', Task::class);

        return Inertia::render('tasks/create', [
            'project' => $project,
        ]);
    }

    // Store a new task via the service layer
    public function store(StoreTaskRequest $request, Project $project)
    {
        $this->authorize('create', Task::class);

        $data = $request->validated();
        $data['project_id'] = $project->id;

        $this->taskService->create($data);

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

    // Show the edit task form
    public function edit(Project $project, Task $task)
    {
        $this->authorize('update', $task);

        return Inertia::render('tasks/edit', [
            'project' => $project,
            'task' => $task,
        ]);
    }

    // Update an existing task via the service layer
    public function update(UpdateTaskRequest $request, Project $project, Task $task)
    {
        $this->authorize('update', $task);

        $this->taskService->update($task, $request->validated());

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

    // Delete a task
    public function destroy(Project $project, Task $task)
    {
        $this->authorize('delete', $task);

        $this->taskService->delete($task);

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

And the full TaskService.php:

<?php

namespace App\Services;

use App\Models\Task;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Auth;

class TaskService
{
    /**
     * List tasks with optional filters by project, priority, and assignee.
     *
     * @param array $filters Associative array of filter criteria
     * @return Collection
     */
    public function list(array $filters): Collection
    {
        $query = Task::query();

        // Filter by parent project
        if (isset($filters['project_id'])) {
            $query->where('project_id', $filters['project_id']);
        }

        // Filter by priority level
        if (isset($filters['priority'])) {
            $query->where('priority', $filters['priority']);
        }

        // Filter by assigned user
        if (isset($filters['assigned_to'])) {
            $query->where('assigned_to', $filters['assigned_to']);
        }

        // Eager-load relationships to avoid N+1 queries
        return $query->with(['project', 'assignee'])->get();
    }

    /**
     * Create a new task. Auto-assigns to current user if no assignee specified.
     *
     * @param array $data Validated task data
     * @return Task
     */
    public function create(array $data): Task
    {
        // Auto-assign to the logged-in user when no assignee is provided
        if (empty($data['assigned_to'])) {
            $data['assigned_to'] = Auth::id();
        }

        return Task::create($data);
    }

    /**
     * Update an existing task.
     *
     * @param Task $task The task to update
     * @param array $data Validated update data
     * @return Task
     */
    public function update(Task $task, array $data): Task
    {
        $task->update($data);
        return $task->fresh();
    }

    /**
     * Delete a task.
     *
     * @param Task $task The task to delete
     * @return bool
     */
    public function delete(Task $task): bool
    {
        return $task->delete();
    }
}

And the routes/web.php route group:

use App\Http\Controllers\ProjectController;
use App\Http\Controllers\TaskController;

Route::middleware(['auth'])->group(function () {
    Route::resource('projects', ProjectController::class);
    // Nested resource: /projects/{project}/tasks
    Route::resource('projects.tasks', TaskController::class)
        ->middlewareFor(['store', 'update'], 'permission:create tasks|edit tasks')
        ->middlewareFor('destroy', 'permission:delete tasks');
});

Your Tasks backend is complete. You now have two full CRUD modules following the same service-layer architecture. Next up, you will build the React frontend that consumes these controllers.

Build the React Frontend for Projects

Your backend now has two complete modules with services, validation, and policies. The missing piece is the user-facing interface that lets people actually interact with their projects.

In this step, you will build the React frontend for the Projects module. You will define shared TypeScript interfaces, create a responsive table page, and build forms that submit data back to Laravel through Inertia.

In this step, get ready to:
  • Define shared TypeScript interfaces for Projects, Tasks, and Users.
  • Create the Projects index page with role-conditional action buttons.
  • Build project creation and editing forms with validation error display.
Create the Projects index page

Before building any pages, you need to define the data shapes your frontend expects. The resources/js/types/ directory already exists from the starter kit.

  • Create the shared TypeScript interfaces by opening resources/js/types/index.ts and replacing its contents with the following:
/** Available project statuses */
export type ProjectStatus = 'Active' | 'Completed' | 'OnHold';

/** Available task priority levels */
export type TaskPriority = 'Low' | 'Medium' | 'High' | 'Critical';

/** User data returned from the backend */
export interface User {
    id: number;
    name: string;
    email: string;
    roles: string[];
}

/** Project data returned from the backend */
export interface Project {
    id: number;
    name: string;
    description: string | null;
    status: ProjectStatus;
    user_id: number;
    task_count: number;
    created_at: string;
    updated_at: string;
}

/** Task data returned from the backend */
export interface Task {
    id: number;
    title: string;
    description: string | null;
    priority: TaskPriority;
    status: string;
    project_id: number;
    assigned_to: number | null;
    assignee?: User;
    project?: Project;
    created_at: string;
    updated_at: string;
}

/** Shared page props with authenticated user and permissions */
export interface PageProps {
    auth: {
        user: User;
        permissions: string[];
    };
}

What does this code do?

These TypeScript interfaces define the exact data shapes your React pages expect from the Laravel backend. Each interface maps to an Eloquent model on the server side.

The PageProps interface is especially important. It types the shared authentication data (the logged-in user and their permissions array) that every page receives through Inertia.

  • Save resources/js/types/index.ts.

Now you need the shadcn/ui Table component for the index page.

  • Install the Table component by running:
./vendor/bin/sail npx shadcn@latest add table

What does this command do?

The npx shadcn@latest add table command downloads the Table component source code into resources/js/components/ui/. Unlike a traditional library, shadcn/ui gives you the actual component files to customize.

  • Update the ProjectController to pass user permissions to the frontend. Open app/Http/Controllers/ProjectController.php and find the index method.
  • Update the return statement so it passes permissions alongside the projects data:
/**
 * Display a listing of projects.
 */
public function index(Request $request)
{
    $projects = $this->projectService->list([]);

    // Pass projects and user permissions to the Inertia view
    return Inertia::render('projects/index', [
        'projects' => $projects,
        'permissions' => $request->user()->getAllPermissions()->pluck('name')->toArray(),
    ]);
}

Why pass permissions from the controller?

The frontend needs to know what the current user is allowed to do. By passing the permissions array from the controller, each React page can conditionally render Create, Edit, or Delete buttons without making extra API calls.

  • Apply the same pattern to the create and edit methods so they also pass permissions to their respective Inertia views.
  • Save app/Http/Controllers/ProjectController.php.

Now create the projects listing page. This page displays all projects in a table with conditional action buttons based on user permissions.

  • Create the file resources/js/pages/projects/index.tsx and add the following code:
import { Head, Link } from '@inertiajs/react';
import {
    Table,
    TableBody,
    TableCell,
    TableHead,
    TableHeader,
    TableRow,
} from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { type Project, type PageProps } from '@/types';

/** Props passed from ProjectController@index */
interface Props extends PageProps {
    projects: Project[];
    permissions: string[];
}

/**
 * Projects index page.
 * Displays all projects in a responsive table with role-based action buttons.
 */
export default function ProjectsIndex({ projects, permissions }: Props) {
    // Check if user has specific permissions
    const canCreate = permissions.includes('create projects');
    const canEdit = permissions.includes('edit projects');
    const canDelete = permissions.includes('delete projects');

    return (
        <>
            <Head title="Projects" />
            <div className="p-6">
                <div className="flex items-center justify-between mb-6">
                    <h1 className="text-2xl font-bold">Projects</h1>
                    {canCreate && (
                        <Link href="/projects/create">
                            <Button>Create Project</Button>
                        </Link>
                    )}
                </div>

                <Table>
                    <TableHeader>
                        <TableRow>
                            <TableHead>Name</TableHead>
                            <TableHead>Status</TableHead>
                            <TableHead>Owner</TableHead>
                            <TableHead>Tasks</TableHead>
                            <TableHead>Actions</TableHead>
                        </TableRow>
                    </TableHeader>
                    <TableBody>
                        {projects.map((project) => (
                            <TableRow key={project.id}>
                                <TableCell className="font-medium">
                                    {project.name}
                                </TableCell>
                                <TableCell>
                                    {/* Use custom badge utility classes from Tailwind theme */}
                                    <span className={
                                        project.status === 'Active' ? 'badge-active' :
                                        project.status === 'Completed' ? 'badge-completed' :
                                        'badge-on-hold'
                                    }>
                                        {project.status}
                                    </span>
                                </TableCell>
                                <TableCell>{project.user_id}</TableCell>
                                <TableCell>{project.task_count}</TableCell>
                                <TableCell className="flex gap-2">
                                    {canEdit && (
                                        <Link href={`/projects/${project.id}/edit`}>
                                            <Button variant="outline" size="sm">
                                                Edit
                                            </Button>
                                        </Link>
                                    )}
                                    {canDelete && (
                                        <Link
                                            href={`/projects/${project.id}`}
                                            method="delete"
                                            as="button"
                                        >
                                            <Button variant="destructive" size="sm">
                                                Delete
                                            </Button>
                                        </Link>
                                    )}
                                </TableCell>
                            </TableRow>
                        ))}
                    </TableBody>
                </Table>
            </div>
        </>
    );
}

What does this code do?

  • The component receives projects and permissions from the controller via Inertia props.
  • Permission checks (canCreate, canEdit, canDelete) hide or show action buttons based on the user's role.
  • Status badges use the custom badge-active, badge-completed, and badge-on-hold utility classes from your Tailwind theme.
  • The Delete button uses Inertia's method="delete" to send a DELETE request without leaving the page.
  • Save resources/js/pages/projects/index.tsx.

Seeing TypeScript errors?

Make sure your resources/js/types/index.ts file is saved and that the imports at the top of index.tsx match the exact interface names (Project, PageProps). TypeScript imports are case-sensitive.

help me fix TypeScript import errors in my projects index page

Create the project creation and edit forms

Users with the right permissions need forms to create and edit projects. Both forms use Inertia's useForm hook for state management and server-side validation error handling.

  • Install the form-related shadcn/ui components by running:
./vendor/bin/sail npx shadcn@latest add input textarea select button

Components you may already have

If the Button component was already installed with the starter kit, the CLI will skip it. You can safely run the command regardless.

  • Create resources/js/pages/projects/create.tsx with the following code:
import { Head } from '@inertiajs/react';
import { useForm } from '@inertiajs/react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import {
    Select,
    SelectContent,
    SelectItem,
    SelectTrigger,
    SelectValue,
} from '@/components/ui/select';
import { type PageProps } from '@/types';

/** Props passed from ProjectController@create */
interface Props extends PageProps {
    permissions: string[];
}

/**
 * Project creation form.
 * Uses Inertia useForm hook for submission and validation error display.
 */
export default function ProjectCreate({ permissions }: Props) {
    // Initialize form state with useForm hook
    const { data, setData, post, processing, errors } = useForm({
        name: '',
        description: '',
        status: 'Active',
    });

    // Submit the form to the store route
    function handleSubmit(e: React.FormEvent) {
        e.preventDefault();
        post('/projects');
    }

    return (
        <>
            <Head title="Create Project" />
            <div className="p-6 max-w-2xl">
                <h1 className="text-2xl font-bold mb-6">Create Project</h1>

                <form onSubmit={handleSubmit} className="space-y-4">
                    {/* Project name field */}
                    <div>
                        <label className="block text-sm font-medium mb-1">
                            Name
                        </label>
                        <Input
                            value={data.name}
                            onChange={(e) => setData('name', e.target.value)}
                            placeholder="Enter project name"
                        />
                        {errors.name && (
                            <p className="text-sm text-red-500 mt-1">{errors.name}</p>
                        )}
                    </div>

                    {/* Project description field */}
                    <div>
                        <label className="block text-sm font-medium mb-1">
                            Description
                        </label>
                        <Textarea
                            value={data.description}
                            onChange={(e) => setData('description', e.target.value)}
                            placeholder="Optional project description"
                        />
                        {errors.description && (
                            <p className="text-sm text-red-500 mt-1">{errors.description}</p>
                        )}
                    </div>

                    {/* Project status selector */}
                    <div>
                        <label className="block text-sm font-medium mb-1">
                            Status
                        </label>
                        <Select
                            value={data.status}
                            onValueChange={(value) => setData('status', value)}
                        >
                            <SelectTrigger>
                                <SelectValue placeholder="Select status" />
                            </SelectTrigger>
                            <SelectContent>
                                <SelectItem value="Active">
                                    <span className="badge-active">Active</span>
                                </SelectItem>
                                <SelectItem value="Completed">
                                    <span className="badge-completed">Completed</span>
                                </SelectItem>
                                <SelectItem value="OnHold">
                                    <span className="badge-on-hold">On Hold</span>
                                </SelectItem>
                            </SelectContent>
                        </Select>
                        {errors.status && (
                            <p className="text-sm text-red-500 mt-1">{errors.status}</p>
                        )}
                    </div>

                    {/* Submit button */}
                    <Button type="submit" disabled={processing}>
                        Create Project
                    </Button>
                </form>
            </div>
        </>
    );
}

What does this code do?

  • The useForm hook from @inertiajs/react manages form data, submission state, and server-side validation errors automatically.
  • When you call post('/projects'), Inertia sends the form data to your Laravel store route. If validation fails, Laravel returns errors that Inertia populates into the errors object.
  • The processing boolean disables the submit button while the request is in flight, preventing double-submissions.
  • Save resources/js/pages/projects/create.tsx.

The edit form is almost identical, but it receives the existing project data as a prop and uses put instead of post.

  • Create resources/js/pages/projects/edit.tsx with the following code:
import { Head } from '@inertiajs/react';
import { useForm } from '@inertiajs/react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import {
    Select,
    SelectContent,
    SelectItem,
    SelectTrigger,
    SelectValue,
} from '@/components/ui/select';
import { type Project, type PageProps } from '@/types';

/** Props passed from ProjectController@edit */
interface Props extends PageProps {
    project: Project;
    permissions: string[];
}

/**
 * Project edit form.
 * Pre-populated with existing project data. Uses Inertia useForm for submission.
 */
export default function ProjectEdit({ project, permissions }: Props) {
    // Initialize form state with existing project values
    const { data, setData, put, processing, errors } = useForm({
        name: project.name,
        description: project.description || '',
        status: project.status,
    });

    // Submit the form to the update route
    function handleSubmit(e: React.FormEvent) {
        e.preventDefault();
        put(`/projects/${project.id}`);
    }

    return (
        <>
            <Head title="Edit Project" />
            <div className="p-6 max-w-2xl">
                <h1 className="text-2xl font-bold mb-6">Edit Project</h1>

                <form onSubmit={handleSubmit} className="space-y-4">
                    {/* Project name field */}
                    <div>
                        <label className="block text-sm font-medium mb-1">
                            Name
                        </label>
                        <Input
                            value={data.name}
                            onChange={(e) => setData('name', e.target.value)}
                            placeholder="Enter project name"
                        />
                        {errors.name && (
                            <p className="text-sm text-red-500 mt-1">{errors.name}</p>
                        )}
                    </div>

                    {/* Project description field */}
                    <div>
                        <label className="block text-sm font-medium mb-1">
                            Description
                        </label>
                        <Textarea
                            value={data.description}
                            onChange={(e) => setData('description', e.target.value)}
                            placeholder="Optional project description"
                        />
                        {errors.description && (
                            <p className="text-sm text-red-500 mt-1">{errors.description}</p>
                        )}
                    </div>

                    {/* Project status selector */}
                    <div>
                        <label className="block text-sm font-medium mb-1">
                            Status
                        </label>
                        <Select
                            value={data.status}
                            onValueChange={(value) => setData('status', value)}
                        >
                            <SelectTrigger>
                                <SelectValue placeholder="Select status" />
                            </SelectTrigger>
                            <SelectContent>
                                <SelectItem value="Active">
                                    <span className="badge-active">Active</span>
                                </SelectItem>
                                <SelectItem value="Completed">
                                    <span className="badge-completed">Completed</span>
                                </SelectItem>
                                <SelectItem value="OnHold">
                                    <span className="badge-on-hold">On Hold</span>
                                </SelectItem>
                            </SelectContent>
                        </Select>
                        {errors.status && (
                            <p className="text-sm text-red-500 mt-1">{errors.status}</p>
                        )}
                    </div>

                    {/* Submit button */}
                    <Button type="submit" disabled={processing}>
                        Update Project
                    </Button>
                </form>
            </div>
        </>
    );
}

How does the edit form differ from create?

  • The useForm hook is initialized with the existing project values from props, so fields appear pre-populated.
  • The submit handler calls put(`/projects/${project.id}`) instead of post('/projects'), sending a PUT request to the update route.
  • Save resources/js/pages/projects/edit.tsx.

Form not submitting?

Make sure you have e.preventDefault() at the top of your handleSubmit function. Without it, the browser performs a full page navigation instead of letting Inertia handle the request.

help me debug why my Inertia form is not submitting correctly

Add JSDoc comments and start the dev server

All your React components already include JSDoc comments on the component functions and interfaces. The last step is to start the Vite development server so you can see the pages in action.

  • Start the Vite dev server by running:
./vendor/bin/sail npm run dev
  • Visit http://localhost in your browser and log in with one of your seeded accounts (for example, admin@example.com with password password).
  • Navigate to /projects in your browser's address bar.

You should see the Projects table page. If you are logged in as admin, you will see the Create Project button and Edit/Delete buttons on each row.

Seeing a blank page at /projects?

Check your terminal for Vite errors. The most common cause is a TypeScript import path that does not resolve. Confirm that @/types resolves to resources/js/types/index.ts and that @/components/ui/table exists after running the shadcn add command.

help me debug a blank page when navigating to my Inertia route

✔️ Awesome, I've got everything!

Great. Double check you have saved all four files: resources/js/types/index.ts, resources/js/pages/projects/index.tsx, resources/js/pages/projects/create.tsx, and resources/js/pages/projects/edit.tsx.

ⓧ I'd like to double check the full code

Here is the complete code for each file created in this step:

/** Available project statuses */
export type ProjectStatus = 'Active' | 'Completed' | 'OnHold';

/** Available task priority levels */
export type TaskPriority = 'Low' | 'Medium' | 'High' | 'Critical';

/** User data returned from the backend */
export interface User {
    id: number;
    name: string;
    email: string;
    roles: string[];
}

/** Project data returned from the backend */
export interface Project {
    id: number;
    name: string;
    description: string | null;
    status: ProjectStatus;
    user_id: number;
    task_count: number;
    created_at: string;
    updated_at: string;
}

/** Task data returned from the backend */
export interface Task {
    id: number;
    title: string;
    description: string | null;
    priority: TaskPriority;
    status: string;
    project_id: number;
    assigned_to: number | null;
    assignee?: User;
    project?: Project;
    created_at: string;
    updated_at: string;
}

/** Shared page props with authenticated user and permissions */
export interface PageProps {
    auth: {
        user: User;
        permissions: string[];
    };
}
import { Head, Link } from '@inertiajs/react';
import {
    Table,
    TableBody,
    TableCell,
    TableHead,
    TableHeader,
    TableRow,
} from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { type Project, type PageProps } from '@/types';

/** Props passed from ProjectController@index */
interface Props extends PageProps {
    projects: Project[];
    permissions: string[];
}

/**
 * Projects index page.
 * Displays all projects in a responsive table with role-based action buttons.
 */
export default function ProjectsIndex({ projects, permissions }: Props) {
    // Check if user has specific permissions
    const canCreate = permissions.includes('create projects');
    const canEdit = permissions.includes('edit projects');
    const canDelete = permissions.includes('delete projects');

    return (
        <>
            <Head title="Projects" />
            <div className="p-6">
                <div className="flex items-center justify-between mb-6">
                    <h1 className="text-2xl font-bold">Projects</h1>
                    {canCreate && (
                        <Link href="/projects/create">
                            <Button>Create Project</Button>
                        </Link>
                    )}
                </div>

                <Table>
                    <TableHeader>
                        <TableRow>
                            <TableHead>Name</TableHead>
                            <TableHead>Status</TableHead>
                            <TableHead>Owner</TableHead>
                            <TableHead>Tasks</TableHead>
                            <TableHead>Actions</TableHead>
                        </TableRow>
                    </TableHeader>
                    <TableBody>
                        {projects.map((project) => (
                            <TableRow key={project.id}>
                                <TableCell className="font-medium">
                                    {project.name}
                                </TableCell>
                                <TableCell>
                                    {/* Use custom badge utility classes from Tailwind theme */}
                                    <span className={
                                        project.status === 'Active' ? 'badge-active' :
                                        project.status === 'Completed' ? 'badge-completed' :
                                        'badge-on-hold'
                                    }>
                                        {project.status}
                                    </span>
                                </TableCell>
                                <TableCell>{project.user_id}</TableCell>
                                <TableCell>{project.task_count}</TableCell>
                                <TableCell className="flex gap-2">
                                    {canEdit && (
                                        <Link href={`/projects/${project.id}/edit`}>
                                            <Button variant="outline" size="sm">
                                                Edit
                                            </Button>
                                        </Link>
                                    )}
                                    {canDelete && (
                                        <Link
                                            href={`/projects/${project.id}`}
                                            method="delete"
                                            as="button"
                                        >
                                            <Button variant="destructive" size="sm">
                                                Delete
                                            </Button>
                                        </Link>
                                    )}
                                </TableCell>
                            </TableRow>
                        ))}
                    </TableBody>
                </Table>
            </div>
        </>
    );
}
import { Head } from '@inertiajs/react';
import { useForm } from '@inertiajs/react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import {
    Select,
    SelectContent,
    SelectItem,
    SelectTrigger,
    SelectValue,
} from '@/components/ui/select';
import { type PageProps } from '@/types';

/** Props passed from ProjectController@create */
interface Props extends PageProps {
    permissions: string[];
}

/**
 * Project creation form.
 * Uses Inertia useForm hook for submission and validation error display.
 */
export default function ProjectCreate({ permissions }: Props) {
    // Initialize form state with useForm hook
    const { data, setData, post, processing, errors } = useForm({
        name: '',
        description: '',
        status: 'Active',
    });

    // Submit the form to the store route
    function handleSubmit(e: React.FormEvent) {
        e.preventDefault();
        post('/projects');
    }

    return (
        <>
            <Head title="Create Project" />
            <div className="p-6 max-w-2xl">
                <h1 className="text-2xl font-bold mb-6">Create Project</h1>

                <form onSubmit={handleSubmit} className="space-y-4">
                    {/* Project name field */}
                    <div>
                        <label className="block text-sm font-medium mb-1">
                            Name
                        </label>
                        <Input
                            value={data.name}
                            onChange={(e) => setData('name', e.target.value)}
                            placeholder="Enter project name"
                        />
                        {errors.name && (
                            <p className="text-sm text-red-500 mt-1">{errors.name}</p>
                        )}
                    </div>

                    {/* Project description field */}
                    <div>
                        <label className="block text-sm font-medium mb-1">
                            Description
                        </label>
                        <Textarea
                            value={data.description}
                            onChange={(e) => setData('description', e.target.value)}
                            placeholder="Optional project description"
                        />
                        {errors.description && (
                            <p className="text-sm text-red-500 mt-1">{errors.description}</p>
                        )}
                    </div>

                    {/* Project status selector */}
                    <div>
                        <label className="block text-sm font-medium mb-1">
                            Status
                        </label>
                        <Select
                            value={data.status}
                            onValueChange={(value) => setData('status', value)}
                        >
                            <SelectTrigger>
                                <SelectValue placeholder="Select status" />
                            </SelectTrigger>
                            <SelectContent>
                                <SelectItem value="Active">
                                    <span className="badge-active">Active</span>
                                </SelectItem>
                                <SelectItem value="Completed">
                                    <span className="badge-completed">Completed</span>
                                </SelectItem>
                                <SelectItem value="OnHold">
                                    <span className="badge-on-hold">On Hold</span>
                                </SelectItem>
                            </SelectContent>
                        </Select>
                        {errors.status && (
                            <p className="text-sm text-red-500 mt-1">{errors.status}</p>
                        )}
                    </div>

                    {/* Submit button */}
                    <Button type="submit" disabled={processing}>
                        Create Project
                    </Button>
                </form>
            </div>
        </>
    );
}
import { Head } from '@inertiajs/react';
import { useForm } from '@inertiajs/react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import {
    Select,
    SelectContent,
    SelectItem,
    SelectTrigger,
    SelectValue,
} from '@/components/ui/select';
import { type Project, type PageProps } from '@/types';

/** Props passed from ProjectController@edit */
interface Props extends PageProps {
    project: Project;
    permissions: string[];
}

/**
 * Project edit form.
 * Pre-populated with existing project data. Uses Inertia useForm for submission.
 */
export default function ProjectEdit({ project, permissions }: Props) {
    // Initialize form state with existing project values
    const { data, setData, put, processing, errors } = useForm({
        name: project.name,
        description: project.description || '',
        status: project.status,
    });

    // Submit the form to the update route
    function handleSubmit(e: React.FormEvent) {
        e.preventDefault();
        put(`/projects/${project.id}`);
    }

    return (
        <>
            <Head title="Edit Project" />
            <div className="p-6 max-w-2xl">
                <h1 className="text-2xl font-bold mb-6">Edit Project</h1>

                <form onSubmit={handleSubmit} className="space-y-4">
                    {/* Project name field */}
                    <div>
                        <label className="block text-sm font-medium mb-1">
                            Name
                        </label>
                        <Input
                            value={data.name}
                            onChange={(e) => setData('name', e.target.value)}
                            placeholder="Enter project name"
                        />
                        {errors.name && (
                            <p className="text-sm text-red-500 mt-1">{errors.name}</p>
                        )}
                    </div>

                    {/* Project description field */}
                    <div>
                        <label className="block text-sm font-medium mb-1">
                            Description
                        </label>
                        <Textarea
                            value={data.description}
                            onChange={(e) => setData('description', e.target.value)}
                            placeholder="Optional project description"
                        />
                        {errors.description && (
                            <p className="text-sm text-red-500 mt-1">{errors.description}</p>
                        )}
                    </div>

                    {/* Project status selector */}
                    <div>
                        <label className="block text-sm font-medium mb-1">
                            Status
                        </label>
                        <Select
                            value={data.status}
                            onValueChange={(value) => setData('status', value)}
                        >
                            <SelectTrigger>
                                <SelectValue placeholder="Select status" />
                            </SelectTrigger>
                            <SelectContent>
                                <SelectItem value="Active">
                                    <span className="badge-active">Active</span>
                                </SelectItem>
                                <SelectItem value="Completed">
                                    <span className="badge-completed">Completed</span>
                                </SelectItem>
                                <SelectItem value="OnHold">
                                    <span className="badge-on-hold">On Hold</span>
                                </SelectItem>
                            </SelectContent>
                        </Select>
                        {errors.status && (
                            <p className="text-sm text-red-500 mt-1">{errors.status}</p>
                        )}
                    </div>

                    {/* Submit button */}
                    <Button type="submit" disabled={processing}>
                        Update Project
                    </Button>
                </form>
            </div>
        </>
    );
}

Your Projects module now has a complete frontend. Next up, you will build the Tasks module interface with a card-based layout and role-specific controls.

Build the React Frontend for Tasks

In the previous step, you built the Projects module frontend with a table view, creation form, and edit form. Your users can now browse, create, and modify projects through a polished interface.

The Tasks module needs a different visual approach. Tasks are smaller, more actionable items that benefit from a card-based layout where you can scan priority and status at a glance. You will also wire the sidebar to show each user's role and provide navigation links across both modules.

In this step, get ready to:
  • Build a card-based Tasks index page with grid and list view toggles.
  • Create task forms with role-aware fields and real-time validation.
  • Add a role indicator badge and module navigation to the sidebar.
Create the tasks index page

The Projects module used a table layout. Tasks are different. They are smaller units of work that benefit from a scannable card layout where priority and status pop visually.

  • Create a new file at resources/js/pages/tasks/index.tsx by copying the following code into it:
import { Head, Link, router } from '@inertiajs/react';
import { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Task, PageProps } from '@/types';

/** Props passed from the TaskController index method */
interface TasksIndexProps extends PageProps {
  tasks: Task[];
}

/**
 * Tasks index page - displays tasks in a card-based layout
 * with grid/list view toggle and role-based action buttons.
 */
export default function TasksIndex({ tasks, auth }: TasksIndexProps) {
  // Toggle between grid and list display modes
  const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');

  // Map priority values to custom Tailwind utility classes
  const priorityClass = (priority: string) => {
    const classes: Record<string, string> = {
      low: 'priority-low',
      medium: 'priority-medium',
      high: 'priority-high',
      critical: 'priority-critical',
    };
    return classes[priority] || '';
  };

  return (
    <>
      <Head title="Tasks" />

      <div className="flex items-center justify-between mb-6">
        <h1 className="text-2xl font-bold">Tasks</h1>

        <div className="flex items-center gap-4">
          {/* View mode toggle */}
          <div className="flex gap-1">
            <Button
              variant={viewMode === 'grid' ? 'default' : 'outline'}
              size="sm"
              onClick={() => setViewMode('grid')}
            >
              Grid
            </Button>
            <Button
              variant={viewMode === 'list' ? 'default' : 'outline'}
              size="sm"
              onClick={() => setViewMode('list')}
            >
              List
            </Button>
          </div>

          {/* Show Create button only if user has permission */}
          {auth.permissions.includes('create tasks') && (
            <Link href="/projects/1/tasks/create">
              <Button>Create Task</Button>
            </Link>
          )}
        </div>
      </div>

      {/* Card-based task display */}
      <div
        className={viewMode === 'grid'
          ? 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4'
          : 'flex flex-col gap-3'
        }
      >
        {tasks.map((task) => (
          <Card key={task.id} className={viewMode === 'list' ? 'flex-row items-center' : ''}>
            <CardHeader>
              <div className="flex items-center justify-between">
                <CardTitle className="text-lg">{task.title}</CardTitle>
                <Badge className={priorityClass(task.priority)}>
                  {task.priority}
                </Badge>
              </div>
            </CardHeader>
            <CardContent>
              <div className="flex flex-col gap-2 text-sm text-muted-foreground">
                <p>Status: <Badge variant="outline">{task.status}</Badge></p>
                <p>Project: {task.project?.name}</p>
                {task.assignee && <p>Assigned to: {task.assignee.name}</p>}
              </div>

              {/* Conditional action buttons */}
              <div className="flex gap-2 mt-4">
                {auth.permissions.includes('edit tasks') && (
                  <Link href={`/projects/${task.project_id}/tasks/${task.id}/edit`}>
                    <Button variant="outline" size="sm">Edit</Button>
                  </Link>
                )}
                {auth.permissions.includes('delete tasks') && (
                  <Button
                    variant="destructive"
                    size="sm"
                    onClick={() => router.delete(`/projects/${task.project_id}/tasks/${task.id}`)}
                  >
                    Delete
                  </Button>
                )}
              </div>
            </CardContent>
          </Card>
        ))}
      </div>
    </>
  );
}

What does this code do?

This component receives a list of tasks from the backend via Inertia props. It renders each task as a Card with priority badge, status, parent project name, and assigned user.

The viewMode state toggles between a multi-column grid and a single-column list. The priorityClass helper maps each priority level to the custom @utility classes you defined in your Tailwind theme.

Action buttons (Edit, Delete) only appear when the user's permissions array includes the relevant permission string. This keeps the UI consistent with the backend authorization.

  • Save resources/js/pages/tasks/index.tsx.
  • You won't see a change yet because the Vite dev server needs the task creation form and backend route to render properly. The next chunk completes the forms.

Seeing TypeScript errors?

Make sure your resources/js/types/index.ts file exports the Task interface with the project and assignee optional relationship fields. If the imports from @/components/ui/card fail, confirm you installed the Card component with shadcn/ui.

help me fix TypeScript errors in my tasks index page

Create the task forms

The task creation form is more complex than the project form. It includes a project selector (which project does this task belong to), a priority picker with colored indicators, and an assignee dropdown that only appears for admin and developer roles.

  • Create resources/js/pages/tasks/create.tsx with the following code:
import { Head, Link } from '@inertiajs/react';
import { useForm } from '@inertiajs/react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Project, User, PageProps } from '@/types';

/** Props passed from the TaskController create method */
interface TaskCreateProps extends PageProps {
  projects: Project[];
  users: User[];
  project_id?: number;
}

/**
 * Task creation form with project selector, priority picker,
 * and role-aware assignee field.
 */
export default function TaskCreate({ projects, users, auth, project_id }: TaskCreateProps) {
  // Initialize form with Inertia useForm hook for reactive state
  const { data, setData, post, processing, errors } = useForm({
    title: '',
    description: '',
    priority: 'medium',
    status: 'pending',
    project_id: project_id || '',
    assigned_to: '',
  });

  // Determine if user can assign tasks to others
  const canAssign = auth.permissions.includes('create tasks') &&
    (auth.user.roles?.includes('admin') || auth.user.roles?.includes('developer'));

  function submit(e: React.FormEvent) {
    e.preventDefault();
    post(`/projects/${data.project_id}/tasks`);
  }

  return (
    <>
      <Head title="Create Task" />

      <h1 className="text-2xl font-bold mb-6">Create Task</h1>

      <form onSubmit={submit} className="max-w-lg space-y-4">
        {/* Task title */}
        <div>
          <label htmlFor="title" className="block text-sm font-medium mb-1">Title</label>
          <Input
            id="title"
            value={data.title}
            onChange={(e) => setData('title', e.target.value)}
          />
          {errors.title && <p className="text-sm text-destructive mt-1">{errors.title}</p>}
        </div>

        {/* Description field */}
        <div>
          <label htmlFor="description" className="block text-sm font-medium mb-1">Description</label>
          <Textarea
            id="description"
            value={data.description}
            onChange={(e) => setData('description', e.target.value)}
          />
          {errors.description && <p className="text-sm text-destructive mt-1">{errors.description}</p>}
        </div>

        {/* Project selector dropdown */}
        <div>
          <label className="block text-sm font-medium mb-1">Project</label>
          <Select value={String(data.project_id)} onValueChange={(val) => setData('project_id', val)}>
            <SelectTrigger>
              <SelectValue placeholder="Select a project" />
            </SelectTrigger>
            <SelectContent>
              {projects.map((project) => (
                <SelectItem key={project.id} value={String(project.id)}>
                  {project.name}
                </SelectItem>
              ))}
            </SelectContent>
          </Select>
          {errors.project_id && <p className="text-sm text-destructive mt-1">{errors.project_id}</p>}
        </div>

        {/* Priority selector with colored indicators */}
        <div>
          <label className="block text-sm font-medium mb-1">Priority</label>
          <Select value={data.priority} onValueChange={(val) => setData('priority', val)}>
            <SelectTrigger>
              <SelectValue placeholder="Select priority" />
            </SelectTrigger>
            <SelectContent>
              <SelectItem value="low">
                <span className="priority-low">Low</span>
              </SelectItem>
              <SelectItem value="medium">
                <span className="priority-medium">Medium</span>
              </SelectItem>
              <SelectItem value="high">
                <span className="priority-high">High</span>
              </SelectItem>
              <SelectItem value="critical">
                <span className="priority-critical">Critical</span>
              </SelectItem>
            </SelectContent>
          </Select>
          {errors.priority && <p className="text-sm text-destructive mt-1">{errors.priority}</p>}
        </div>

        {/* Assignee picker - only visible to admin/developer */}
        {canAssign && (
          <div>
            <label className="block text-sm font-medium mb-1">Assign To</label>
            <Select value={String(data.assigned_to)} onValueChange={(val) => setData('assigned_to', val)}>
              <SelectTrigger>
                <SelectValue placeholder="Select assignee" />
              </SelectTrigger>
              <SelectContent>
                {users.map((user) => (
                  <SelectItem key={user.id} value={String(user.id)}>
                    {user.name}
                  </SelectItem>
                ))}
              </SelectContent>
            </Select>
            {errors.assigned_to && <p className="text-sm text-destructive mt-1">{errors.assigned_to}</p>}
          </div>
        )}

        {/* Submit button */}
        <div className="flex gap-2">
          <Button type="submit" disabled={processing}>Create Task</Button>
          <Link href="/projects">
            <Button variant="outline" type="button">Cancel</Button>
          </Link>
        </div>
      </form>
    </>
  );
}

What does this code do?

The useForm hook from @inertiajs/react manages form state, submission, and error handling. When the form submits, Inertia sends a POST request to the nested route and automatically displays any validation errors returned from your StoreTaskRequest Form Request.

The canAssign check reads the user's roles from Inertia shared data. Only admins and developers see the assignee picker. Regular users skip it, and the backend auto-assigns the task to them.

  • Now create the edit form at resources/js/pages/tasks/edit.tsx with the following code:
import { Head, Link } from '@inertiajs/react';
import { useForm } from '@inertiajs/react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Task, Project, User, PageProps } from '@/types';

/** Props passed from the TaskController edit method */
interface TaskEditProps extends PageProps {
  task: Task;
  projects: Project[];
  users: User[];
}

/**
 * Task edit form - pre-populated with existing task data.
 * Same fields as create, but uses PUT method for updates.
 */
export default function TaskEdit({ task, projects, users, auth }: TaskEditProps) {
  // Pre-populate form with existing task data
  const { data, setData, put, processing, errors } = useForm({
    title: task.title,
    description: task.description || '',
    priority: task.priority,
    status: task.status,
    project_id: String(task.project_id),
    assigned_to: task.assigned_to ? String(task.assigned_to) : '',
  });

  const canAssign = auth.permissions.includes('edit tasks') &&
    (auth.user.roles?.includes('admin') || auth.user.roles?.includes('developer'));

  function submit(e: React.FormEvent) {
    e.preventDefault();
    put(`/projects/${data.project_id}/tasks/${task.id}`);
  }

  return (
    <>
      <Head title="Edit Task" />

      <h1 className="text-2xl font-bold mb-6">Edit Task</h1>

      <form onSubmit={submit} className="max-w-lg space-y-4">
        {/* Task title */}
        <div>
          <label htmlFor="title" className="block text-sm font-medium mb-1">Title</label>
          <Input
            id="title"
            value={data.title}
            onChange={(e) => setData('title', e.target.value)}
          />
          {errors.title && <p className="text-sm text-destructive mt-1">{errors.title}</p>}
        </div>

        {/* Description */}
        <div>
          <label htmlFor="description" className="block text-sm font-medium mb-1">Description</label>
          <Textarea
            id="description"
            value={data.description}
            onChange={(e) => setData('description', e.target.value)}
          />
          {errors.description && <p className="text-sm text-destructive mt-1">{errors.description}</p>}
        </div>

        {/* Project selector */}
        <div>
          <label className="block text-sm font-medium mb-1">Project</label>
          <Select value={data.project_id} onValueChange={(val) => setData('project_id', val)}>
            <SelectTrigger>
              <SelectValue placeholder="Select a project" />
            </SelectTrigger>
            <SelectContent>
              {projects.map((project) => (
                <SelectItem key={project.id} value={String(project.id)}>
                  {project.name}
                </SelectItem>
              ))}
            </SelectContent>
          </Select>
          {errors.project_id && <p className="text-sm text-destructive mt-1">{errors.project_id}</p>}
        </div>

        {/* Priority selector with colored indicators */}
        <div>
          <label className="block text-sm font-medium mb-1">Priority</label>
          <Select value={data.priority} onValueChange={(val) => setData('priority', val)}>
            <SelectTrigger>
              <SelectValue placeholder="Select priority" />
            </SelectTrigger>
            <SelectContent>
              <SelectItem value="low"><span className="priority-low">Low</span></SelectItem>
              <SelectItem value="medium"><span className="priority-medium">Medium</span></SelectItem>
              <SelectItem value="high"><span className="priority-high">High</span></SelectItem>
              <SelectItem value="critical"><span className="priority-critical">Critical</span></SelectItem>
            </SelectContent>
          </Select>
          {errors.priority && <p className="text-sm text-destructive mt-1">{errors.priority}</p>}
        </div>

        {/* Status selector */}
        <div>
          <label className="block text-sm font-medium mb-1">Status</label>
          <Select value={data.status} onValueChange={(val) => setData('status', val)}>
            <SelectTrigger>
              <SelectValue placeholder="Select status" />
            </SelectTrigger>
            <SelectContent>
              <SelectItem value="pending">Pending</SelectItem>
              <SelectItem value="in_progress">In Progress</SelectItem>
              <SelectItem value="completed">Completed</SelectItem>
            </SelectContent>
          </Select>
          {errors.status && <p className="text-sm text-destructive mt-1">{errors.status}</p>}
        </div>

        {/* Assignee picker - admin/developer only */}
        {canAssign && (
          <div>
            <label className="block text-sm font-medium mb-1">Assign To</label>
            <Select value={data.assigned_to} onValueChange={(val) => setData('assigned_to', val)}>
              <SelectTrigger>
                <SelectValue placeholder="Select assignee" />
              </SelectTrigger>
              <SelectContent>
                {users.map((user) => (
                  <SelectItem key={user.id} value={String(user.id)}>
                    {user.name}
                  </SelectItem>
                ))}
              </SelectContent>
            </Select>
            {errors.assigned_to && <p className="text-sm text-destructive mt-1">{errors.assigned_to}</p>}
          </div>
        )}

        {/* Submit */}
        <div className="flex gap-2">
          <Button type="submit" disabled={processing}>Update Task</Button>
          <Link href="/projects">
            <Button variant="outline" type="button">Cancel</Button>
          </Link>
        </div>
      </form>
    </>
  );
}

What does this code do?

The edit form mirrors the create form but pre-populates useForm with the existing task's data. It uses put instead of post for the HTTP method, matching the UpdateTaskRequest validation on the backend.

The edit form also includes a Status selector so existing tasks can transition between pending, in_progress, and completed states.

  • Save both resources/js/pages/tasks/create.tsx and resources/js/pages/tasks/edit.tsx.
  • You won't see the forms in the browser until the backend passes the required props (projects list, users list). The sidebar update in the next substep connects the navigation so you can reach these pages.

Form not submitting?

Confirm that the project_id value passed to the form matches a real project ID in your database. The nested route /projects/{project}/tasks requires a valid project. Also check that your Vite dev server is still running in the WSL2 terminal.

help me debug my task form submission

Add the role badge and navigation to the sidebar

Right now, users have no way to navigate between the Projects and Tasks modules from the sidebar. They also cannot see which role they are logged in as. You will add both features and wire the user's role through Inertia shared data.

  • Open app/Http/Middleware/HandleInertiaRequests.php in VS Code.
  • Find the share() method and update it to include the user's roles in the shared auth data:
/**
 * Define the props that are shared by default.
 *
 * @return array<string, mixed>
 */
public function share(Request $request): array
{
    return [
        ...parent::share($request),
        'auth' => [
            'user' => $request->user() ? array_merge(
                $request->user()->toArray(),
                ['roles' => $request->user()->getRoleNames()->toArray()]
            ) : null,
            'permissions' => $request->user()
                ? $request->user()->getAllPermissions()->pluck('name')->toArray()
                : [],
        ],
    ];
}

What does this code do?

The HandleInertiaRequests middleware's share() method defines data that every Inertia page receives automatically. By adding roles to the auth user object, every React component can read the current user's role without each controller passing it individually.

The getRoleNames() method comes from the Spatie HasRoles trait you added to the User model in an earlier step.

  • Save app/Http/Middleware/HandleInertiaRequests.php.
  • Now update the sidebar component. Open your sidebar layout file (the starter kit places it in resources/js/layouts/ as part of the AppSidebar or app-sidebar component).
  • Add the role indicator badge and navigation links by including this code in your sidebar component:
import { Link, usePage } from '@inertiajs/react';
import { Badge } from '@/components/ui/badge';
import { PageProps } from '@/types';

/**
 * Role badge color mapping.
 * Admin = red (destructive), Developer = blue, User = green.
 */
function getRoleBadgeVariant(role: string): string {
  const variants: Record<string, string> = {
    admin: 'destructive',
    developer: 'default',
    user: 'secondary',
  };
  return variants[role] || 'outline';
}

function getRoleBadgeClass(role: string): string {
  const classes: Record<string, string> = {
    admin: 'bg-red-500 text-white',
    developer: 'bg-blue-500 text-white',
    user: 'bg-green-500 text-white',
  };
  return classes[role] || '';
}

// Inside your sidebar component's return JSX, add:
// Role indicator badge
const { auth } = usePage<PageProps>().props;
const userRole = auth.user.roles?.[0] || 'user';

// Navigation links for both modules
<nav className="space-y-1">
  <Link
    href="/projects"
    className="block px-3 py-2 rounded-md hover:bg-accent"
  >
    Projects
  </Link>
  <Link
    href="/projects"
    className="block px-3 py-2 rounded-md hover:bg-accent"
  >
    Tasks
  </Link>
</nav>

// Role badge display
<div className="px-3 py-2">
  <Badge className={getRoleBadgeClass(userRole)}>
    {userRole}
  </Badge>
</div>

What does this code do?

The getRoleBadgeClass function maps each role to a distinct color. Admin gets red, developer gets blue, and user gets green. This gives an instant visual indicator of who is logged in.

The usePage hook reads the shared Inertia data you just configured in the middleware. The role badge and navigation links sit in the sidebar so they appear on every authenticated page.

  • Save your sidebar layout file.
  • Refresh your browser at http://localhost. You should see the role badge (red, blue, or green depending on which user you are logged in as) and navigation links for Projects and Tasks in the sidebar.

Role badge not showing?

The most common cause is the HandleInertiaRequests.php change not being saved. Confirm the file is saved, then do a hard refresh in your browser. Also check that you seeded users with roles in Step 6. You can verify by running ./vendor/bin/sail php artisan tinker and checking \App\Models\User::first()->getRoleNames().

help me debug why user roles are not showing in my sidebar

✔️ Awesome, I've got everything!

Great. Double check that all three files are saved and your browser shows the role badge in the sidebar.

ⓧ I'd like to double check the full code

Here is the complete HandleInertiaRequests.php share method for reference:

public function share(Request $request): array
{
    return [
        ...parent::share($request),
        'auth' => [
            'user' => $request->user() ? array_merge(
                $request->user()->toArray(),
                ['roles' => $request->user()->getRoleNames()->toArray()]
            ) : null,
            'permissions' => $request->user()
                ? $request->user()->getAllPermissions()->pluck('name')->toArray()
                : [],
        ],
    ];
}

Confirm this matches what you have in your file. The key addition is the roles key inside the auth.user array.

Your Tasks module frontend is ready. Users can browse tasks as cards, create and edit them with role-aware forms, and navigate between modules from the sidebar. Next up, you will write comprehensive project documentation so anyone can understand and set up your codebase.

Write Project Documentation

Your application has two working CRUD modules, role-based access, and a custom design system. The final piece that separates a learning exercise from a portfolio-quality project is documentation.

In this step, you will create a comprehensive README, write architecture and API documentation, and add inline code documentation throughout the codebase. These files give anyone reviewing your project an immediate understanding of how to run it, how it is structured, and how the permission system works.

In this step, get ready to:
  • Create a README with setup instructions, tech stack, and available routes.
  • Write architecture and API reference documentation in a docs folder.
  • Add PHPDoc and JSDoc inline documentation and comment your Tailwind theme.
Create the project README

The README is the first file anyone sees when they open your project repository. It needs to answer three questions immediately: what does this app do, how do I run it, and what accounts can I log in with.

  • Create a file called README.md in your project root (the project-manager folder) by running:
touch README.md
  • Open README.md in VS Code and paste the following content:
# Project Manager

## Project Overview

A full-stack project management dashboard built with Laravel 13 and React 19. Features two CRUD modules (Projects and Tasks) with role-based access control, a custom Tailwind 4 design system with dark mode, and Docker containerization via Laravel Sail.

## Tech Stack

| Technology | Version | Purpose |
|---|---|---|
| Laravel | 13 | Backend framework |
| React | 19 | Frontend UI library |
| TypeScript | 5.x | Type-safe frontend development |
| Tailwind CSS | 4 | Utility-first CSS framework |
| Inertia.js | 3 | Server-side routing for SPA |
| shadcn/ui | latest | UI component library |
| Spatie laravel-permission | 7 | Role-based access control |
| MySQL | 8 | Relational database |
| Docker / Sail | latest | Containerized development environment |

## Architecture

```
app/
├── Enums/
│   ├── ProjectStatus.php
│   └── TaskPriority.php
├── Http/
│   ├── Controllers/
│   │   ├── ProjectController.php
│   │   └── TaskController.php
│   ├── Middleware/
│   │   └── HandleInertiaRequests.php
│   └── Requests/
│       ├── Project/
│       │   ├── StoreProjectRequest.php
│       │   └── UpdateProjectRequest.php
│       └── Task/
│           ├── StoreTaskRequest.php
│           └── UpdateTaskRequest.php
├── Models/
│   ├── Project.php
│   ├── Task.php
│   └── User.php
├── Policies/
│   ├── ProjectPolicy.php
│   └── TaskPolicy.php
└── Services/
    ├── ProjectService.php
    └── TaskService.php

resources/js/
├── components/ui/
├── layouts/
├── pages/
│   ├── projects/
│   │   ├── index.tsx
│   │   ├── create.tsx
│   │   └── edit.tsx
│   └── tasks/
│       ├── index.tsx
│       ├── create.tsx
│       └── edit.tsx
└── types/
    └── index.ts
```

## Getting Started

### Prerequisites

- Docker Desktop with WSL2 enabled
- VS Code with WSL extension

### Setup

1. Clone the repository and navigate into it.
2. Start the containers:
   ```bash
   ./vendor/bin/sail up -d
   ```
3. Run database migrations:
   ```bash
   ./vendor/bin/sail php artisan migrate
   ```
4. Seed the database with roles and demo users:
   ```bash
   ./vendor/bin/sail php artisan db:seed
   ```
5. Install frontend dependencies and start the dev server:
   ```bash
   ./vendor/bin/sail npm install
   ./vendor/bin/sail npm run dev
   ```
6. Visit http://localhost in your browser.

## Available Accounts

| Email | Password | Role | Permissions |
|---|---|---|---|
| admin@example.com | password | Admin | Full CRUD on all projects and tasks |
| developer@example.com | password | Developer | Create, edit, and view projects and tasks |
| user@example.com | password | User | View-only access to all projects and tasks |

## Available Routes

| Method | URL | Description | Permission |
|---|---|---|---|
| GET | /projects | List all projects | view projects |
| POST | /projects | Create a project | create projects |
| GET | /projects/{project} | View a project | view projects |
| PUT | /projects/{project} | Update a project | edit projects |
| DELETE | /projects/{project} | Delete a project | delete projects |
| GET | /projects/{project}/tasks | List tasks for a project | view tasks |
| POST | /projects/{project}/tasks | Create a task | create tasks |
| GET | /projects/{project}/tasks/{task} | View a task | view tasks |
| PUT | /projects/{project}/tasks/{task} | Update a task | edit tasks |
| DELETE | /projects/{project}/tasks/{task} | Delete a task | delete tasks |

What does this README cover?

The README has six sections that answer the most common questions a reviewer or new teammate asks.

  • Project Overview tells them what the app does in one paragraph.
  • Tech Stack lists every tool with its version so they know what to install.
  • Architecture shows the folder structure diagram so they understand the codebase layout.
  • Getting Started gives step-by-step Docker setup commands.
  • Available Accounts lists the three test users so they can log in immediately.
  • Available Routes documents every endpoint, method, and required permission.
  • Save README.md.
  • Confirm the file exists by running:
ls README.md

You should see README.md listed in the output.

File not showing up?

Make sure you created the file in the project-manager root directory, not inside a subdirectory like app/ or resources/. Run pwd to check your current directory.

help me find where my README.md ended up

Create architecture and API documentation

A README gives the overview, but reviewers and teammates also want to understand the design decisions behind your code. Architecture documentation explains why you chose specific patterns. API documentation lists every route so frontend developers know exactly what data to send and expect back.

  • Create the docs/ folder and both documentation files by running:
mkdir -p docs
touch docs/ARCHITECTURE.md docs/API.md
  • Open docs/ARCHITECTURE.md and paste the following content:
# Architecture

## Service Layer Pattern

This project follows the "thin controllers, fat services" pattern. Controllers are responsible only for:

1. Accepting the HTTP request
2. Delegating business logic to a Service class
3. Returning the Inertia response

All business logic lives in the `app/Services/` directory. Each module (Projects, Tasks) has its own service class that encapsulates create, update, delete, and list operations. This keeps controllers readable and makes business logic reusable across different entry points (web, API, CLI, queued jobs).

## Form Requests for Validation

Validation rules are extracted from controllers into dedicated Form Request classes in `app/Http/Requests/`. Each module has separate Store and Update request classes:

- `Project/StoreProjectRequest.php` - Validates project creation data
- `Project/UpdateProjectRequest.php` - Validates project update data
- `Task/StoreTaskRequest.php` - Validates task creation data
- `Task/UpdateTaskRequest.php` - Validates task update data

This separation means controllers never contain validation logic. Form Requests also handle authorization checks via their `authorize()` method.

## Permission System

Role-based access control is implemented using Spatie laravel-permission v7.

### Roles vs Direct Permissions

- **Roles** (admin, developer, user) are assigned to users and group related permissions together.
- **Permissions** (view projects, create projects, edit projects, delete projects, view tasks, create tasks, edit tasks, delete tasks) are the granular access checks.
- Roles are assigned permissions via the database seeder. Users inherit permissions through their assigned role.

### How Middleware Works

Route-level protection uses Laravel Policies (ProjectPolicy, TaskPolicy) that check Spatie permissions via `$user->can('permission-name')`. The `$this->authorize()` method in controllers triggers the Policy check automatically. UI elements are conditionally rendered based on permissions passed from the backend to Inertia views as props.

## Tailwind 4 Theme Structure

The custom design system is defined in `resources/css/app.css` using Tailwind 4's `@theme` directive:

- **Brand colors** (`--color-primary`, `--color-secondary`, `--color-accent`) generate utilities like `bg-primary`, `text-accent`
- **Status colors** used for project status badges (active, completed, on-hold)
- **Priority colors** used for task priority indicators (low, medium, high, critical)
- **Font families** define the typography scale

Custom reusable styles are created with the `@utility` directive for badges and indicators. These custom utilities work with Tailwind variants like `hover:`, `focus:`, and `dark:`.

What does this document explain?

The architecture doc answers four "why" questions that the README deliberately skips. Why use a service layer instead of putting logic in controllers. Why extract validation into Form Requests. How the role and permission system fits together. How the Tailwind theme is structured using the new @theme directive.

This is the kind of documentation that helps a hiring manager understand your decision-making process, not just your ability to follow tutorials.

  • Save docs/ARCHITECTURE.md.
  • Open docs/API.md and paste the following content:
# API Reference

All routes require authentication. Responses are Inertia page renders (HTML with embedded JSON props).

## Projects

### List Projects

- **Method**: GET
- **URL**: `/projects`
- **Permission**: view projects
- **Response**: Inertia page with `projects` array prop

### Create Project

- **Method**: POST
- **URL**: `/projects`
- **Permission**: create projects
- **Request Body**:
  ```json
  {
    "name": "New Project",
    "description": "Project description",
    "status": "Active"
  }
  ```
- **Validation**: name required|string|max:255, description nullable|string, status required|in:Active,Completed,OnHold
- **Response**: Redirect to /projects

### View Project

- **Method**: GET
- **URL**: `/projects/{project}`
- **Permission**: view projects
- **Response**: Inertia page with `project` object prop

### Update Project

- **Method**: PUT
- **URL**: `/projects/{project}`
- **Permission**: edit projects
- **Request Body**:
  ```json
  {
    "name": "Updated Name",
    "description": "Updated description",
    "status": "Completed"
  }
  ```
- **Validation**: Same as Create Project
- **Response**: Redirect to /projects

### Delete Project

- **Method**: DELETE
- **URL**: `/projects/{project}`
- **Permission**: delete projects
- **Response**: Redirect to /projects

## Tasks

### List Tasks

- **Method**: GET
- **URL**: `/projects/{project}/tasks`
- **Permission**: view tasks
- **Response**: Inertia page with `tasks` array prop and `project` object prop

### Create Task

- **Method**: POST
- **URL**: `/projects/{project}/tasks`
- **Permission**: create tasks
- **Request Body**:
  ```json
  {
    "title": "New Task",
    "description": "Task description",
    "priority": "High",
    "status": "pending",
    "project_id": 1,
    "assigned_to": 2
  }
  ```
- **Validation**: title required|string|max:255, description nullable|string, priority required|in:Low,Medium,High,Critical, status required|in:pending,in_progress,completed, project_id required|exists:projects,id, assigned_to nullable|exists:users,id
- **Response**: Redirect to /projects/{project}/tasks

### View Task

- **Method**: GET
- **URL**: `/projects/{project}/tasks/{task}`
- **Permission**: view tasks
- **Response**: Inertia page with `task` object prop

### Update Task

- **Method**: PUT
- **URL**: `/projects/{project}/tasks/{task}`
- **Permission**: edit tasks
- **Request Body**:
  ```json
  {
    "title": "Updated Title",
    "description": "Updated description",
    "priority": "Critical",
    "status": "in_progress",
    "project_id": 1,
    "assigned_to": 3
  }
  ```
- **Validation**: Same as Create Task
- **Response**: Redirect to /projects/{project}/tasks

### Delete Task

- **Method**: DELETE
- **URL**: `/projects/{project}/tasks/{task}`
- **Permission**: delete tasks
- **Response**: Redirect to /projects/{project}/tasks

What does the API reference include?

Each route entry includes the HTTP method, URL pattern, required permission, request body shape (with example JSON), validation rules, and the response type. This is the minimum a frontend developer needs to build against your backend without reading controller source code.

  • Save docs/API.md.
  • Confirm both documentation files exist by running:
ls docs/

You should see API.md and ARCHITECTURE.md listed in the output.

Folder or files missing?

Make sure you ran the mkdir -p docs command before creating the files. If the folder exists but files are missing, run touch docs/ARCHITECTURE.md docs/API.md again.

help me troubleshoot my docs folder structure

Add inline documentation throughout the codebase

External documentation files describe the system from the outside. Inline documentation describes code from the inside. PHPDoc blocks on PHP methods and JSDoc comments on TypeScript interfaces let IDEs show helpful tooltips and make the codebase navigable without reading every line.

  • Open app/Http/Controllers/ProjectController.php and add PHPDoc blocks above each public method. Use this as your reference:
<?php

namespace App\Http\Controllers;

use App\Http\Requests\Project\StoreProjectRequest;
use App\Http\Requests\Project\UpdateProjectRequest;
use App\Models\Project;
use App\Services\ProjectService;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;

class ProjectController extends Controller
{
    /**
     * Create a new controller instance.
     *
     * @param ProjectService $projectService Service handling project business logic
     */
    public function __construct(private ProjectService $projectService)
    {
    }

    /**
     * Display a listing of all projects.
     *
     * @return Response Inertia page with projects array and user permissions
     */
    public function index(): Response
    {
        $this->authorize('viewAny', Project::class);

        $projects = $this->projectService->list([]);

        return Inertia::render('projects/index', [
            'projects' => $projects,
            'permissions' => [
                'create' => auth()->user()->can('create projects'),
                'edit' => auth()->user()->can('edit projects'),
                'delete' => auth()->user()->can('delete projects'),
            ],
        ]);
    }

    /**
     * Show the form for creating a new project.
     *
     * @return Response Inertia page with the project creation form
     */
    public function create(): Response
    {
        $this->authorize('create', Project::class);

        return Inertia::render('projects/create');
    }

    /**
     * Store a newly created project in the database.
     *
     * @param StoreProjectRequest $request Validated project creation data
     * @return RedirectResponse Redirects to projects index on success
     */
    public function store(StoreProjectRequest $request): RedirectResponse
    {
        $this->authorize('create', Project::class);

        $this->projectService->create($request->validated());

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

    /**
     * Show the form for editing the specified project.
     *
     * @param Project $project The project to edit
     * @return Response Inertia page with the project edit form
     */
    public function edit(Project $project): Response
    {
        $this->authorize('update', $project);

        return Inertia::render('projects/edit', [
            'project' => $project,
        ]);
    }

    /**
     * Update the specified project in the database.
     *
     * @param UpdateProjectRequest $request Validated project update data
     * @param Project $project The project to update
     * @return RedirectResponse Redirects to projects index on success
     */
    public function update(UpdateProjectRequest $request, Project $project): RedirectResponse
    {
        $this->authorize('update', $project);

        $this->projectService->update($project, $request->validated());

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

    /**
     * Remove the specified project from the database.
     *
     * @param Project $project The project to delete
     * @return RedirectResponse Redirects to projects index on success
     */
    public function destroy(Project $project): RedirectResponse
    {
        $this->authorize('delete', $project);

        $this->projectService->delete($project);

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

What do PHPDoc blocks include?

Each PHPDoc block starts with a one-line description of what the method does. The @param tags document each parameter's type and purpose. The @return tag documents the return type and what it contains.

IDEs like VS Code use these annotations to show tooltips when you hover over method calls. They also help teammates understand code without reading the implementation.

  • Save app/Http/Controllers/ProjectController.php.
  • Open app/Http/Controllers/TaskController.php and add the same PHPDoc pattern to all its public methods (index, create, store, edit, update, destroy). Follow the same format: a one-line description, @param for each argument, and @return with the response type.
  • Save app/Http/Controllers/TaskController.php.

Now add comments to your Tailwind theme to explain each design token.

  • Open resources/css/app.css and find the @theme block. Add a comment above each group of design tokens explaining its purpose:
@import "tailwindcss";

@theme {
    /* Primary brand color - used for buttons, links, and active states */
    --color-primary: #6366f1;
    /* Secondary brand color - used for hover states and secondary actions */
    --color-secondary: #4f46e5;
    /* Accent color - used for highlights and attention-grabbing elements */
    --color-accent: #8b5cf6;

    /* Status colors for project badges */
    --color-status-active: #22c55e;
    --color-status-completed: #3b82f6;
    --color-status-on-hold: #f59e0b;

    /* Priority colors for task indicators */
    --color-priority-low: #6b7280;
    --color-priority-medium: #f59e0b;
    --color-priority-high: #ef4444;
    --color-priority-critical: #dc2626;

    /* Typography - heading and body font families */
    --font-sans: 'Inter', sans-serif;
    --font-heading: 'Inter', sans-serif;
}

Why comment design tokens?

Design tokens are the bridge between design intent and code. A comment like "used for buttons, links, and active states" tells future developers where --color-primary should appear in the UI. Without these, someone might use bg-primary on a warning banner because the color looks close enough.

  • Save resources/css/app.css.
  • Verify your TypeScript interfaces in resources/js/types/index.ts already have JSDoc descriptions on all interfaces and enums. If any are missing, add a /** ... */ block above each interface describing what it represents.
  • Verify that both app/Services/ProjectService.php and app/Services/TaskService.php have PHPDoc blocks on their list, create, update, and delete methods. These were added in an earlier step. Confirm they include @param and @return annotations.

Not sure if your docs are complete?

Run a quick scan of your Services directory to check for PHPDoc blocks. Open any file in VS Code, hover over a public method name, and confirm a tooltip appears showing the description and parameter types. If no tooltip shows, the PHPDoc block is missing or has a syntax error.

help me check if all my PHP files have proper PHPDoc documentation

✔️ Awesome, I've got everything!

Great. Double check you have saved all modified files.

ⓧ I'd like to double check the full code

Here is the full resources/css/app.css with theme comments:

@import "tailwindcss";

@theme {
    /* Primary brand color - used for buttons, links, and active states */
    --color-primary: #6366f1;
    /* Secondary brand color - used for hover states and secondary actions */
    --color-secondary: #4f46e5;
    /* Accent color - used for highlights and attention-grabbing elements */
    --color-accent: #8b5cf6;

    /* Status colors for project badges */
    --color-status-active: #22c55e;
    --color-status-completed: #3b82f6;
    --color-status-on-hold: #f59e0b;

    /* Priority colors for task indicators */
    --color-priority-low: #6b7280;
    --color-priority-medium: #f59e0b;
    --color-priority-high: #ef4444;
    --color-priority-critical: #dc2626;

    /* Typography - heading and body font families */
    --font-sans: 'Inter', sans-serif;
    --font-heading: 'Inter', sans-serif;
}

Here is the documented ProjectController.php:

<?php

namespace App\Http\Controllers;

use App\Http\Requests\Project\StoreProjectRequest;
use App\Http\Requests\Project\UpdateProjectRequest;
use App\Models\Project;
use App\Services\ProjectService;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;

class ProjectController extends Controller
{
    /**
     * Create a new controller instance.
     *
     * @param ProjectService $projectService Service handling project business logic
     */
    public function __construct(private ProjectService $projectService)
    {
    }

    /**
     * Display a listing of all projects.
     *
     * @return Response Inertia page with projects array and user permissions
     */
    public function index(): Response
    {
        $this->authorize('viewAny', Project::class);

        $projects = $this->projectService->list([]);

        return Inertia::render('projects/index', [
            'projects' => $projects,
            'permissions' => [
                'create' => auth()->user()->can('create projects'),
                'edit' => auth()->user()->can('edit projects'),
                'delete' => auth()->user()->can('delete projects'),
            ],
        ]);
    }

    /**
     * Show the form for creating a new project.
     *
     * @return Response Inertia page with the project creation form
     */
    public function create(): Response
    {
        $this->authorize('create', Project::class);

        return Inertia::render('projects/create');
    }

    /**
     * Store a newly created project in the database.
     *
     * @param StoreProjectRequest $request Validated project creation data
     * @return RedirectResponse Redirects to projects index on success
     */
    public function store(StoreProjectRequest $request): RedirectResponse
    {
        $this->authorize('create', Project::class);

        $this->projectService->create($request->validated());

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

    /**
     * Show the form for editing the specified project.
     *
     * @param Project $project The project to edit
     * @return Response Inertia page with the project edit form
     */
    public function edit(Project $project): Response
    {
        $this->authorize('update', $project);

        return Inertia::render('projects/edit', [
            'project' => $project,
        ]);
    }

    /**
     * Update the specified project in the database.
     *
     * @param UpdateProjectRequest $request Validated project update data
     * @param Project $project The project to update
     * @return RedirectResponse Redirects to projects index on success
     */
    public function update(UpdateProjectRequest $request, Project $project): RedirectResponse
    {
        $this->authorize('update', $project);

        $this->projectService->update($project, $request->validated());

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

    /**
     * Remove the specified project from the database.
     *
     * @param Project $project The project to delete
     * @return RedirectResponse Redirects to projects index on success
     */
    public function destroy(Project $project): RedirectResponse
    {
        $this->authorize('delete', $project);

        $this->projectService->delete($project);

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

Your project is now fully documented from the outside in. The README gives quick setup instructions, the architecture docs explain your design decisions, the API reference maps every endpoint, and inline documentation makes the code self-describing. Next up, you will test the complete application across all three user roles.

Test the Complete Application

Your entire codebase is built, documented, and containerized. Now it is time to verify that every piece works together as expected.

In this step, you will log in as each of the three seeded users and confirm their role-based access matches the permissions you configured. You will also verify dark mode, your custom Tailwind theme, and responsive layouts.

In this step, get ready to:
  • Start the Vite dev server and test full CRUD as the admin user.
  • Verify restricted access as the developer user.
  • Confirm view-only access as the regular user and check responsive design.
Start the dev server and test as admin

The Vite dev server compiles your React frontend and enables hot module reloading. Without it running, your browser would only see the raw Laravel backend.

  • Start the Vite dev server by running this command in your WSL2 terminal:
./vendor/bin/sail npm run dev

What does this command do?

This starts the Vite development server inside your Sail container. It watches for file changes in resources/js/ and resources/css/ and instantly reloads the browser when you save.

  • Open your Windows browser and visit http://localhost.
  • You should see the Laravel application login page.

Seeing a blank page or connection refused?

Make sure your Sail containers are still running. Open a new WSL2 terminal tab and run ./vendor/bin/sail up -d to restart them.

If the page loads but looks unstyled, confirm the Vite dev server is running in your other terminal tab without errors.

Help me troubleshoot why my Laravel app isn't loading in the browser.

Now test the full admin experience. The admin account has unrestricted access to both modules.

  • Log in with email admin@example.com and password password.
  • Navigate to the Projects section in the sidebar.
  • Click Create and fill in a project name, description, and status.
  • Submit the form and confirm the new project appears in the list with the correct status badge.
  • Click on the project to open it, then click Edit to change the status.
  • Save the edit and confirm the updated status displays correctly.
  • Navigate to the Tasks section.
  • Create a new task within your project. Set a priority level and assignee.
  • Confirm the task appears with the correct priority badge color from your custom theme.

Next, verify your custom Tailwind theme and dark mode toggle.

  • Look at the status badges on your projects. They should use your custom badge utility colors (active, completed, on hold).
  • Check the priority indicators on tasks. Each priority level should display a distinct color from your @theme design tokens.
  • Toggle dark mode using the theme switcher in the UI.
  • Confirm that all custom colors, badges, and card styles adapt correctly to the dark variant.

Finally, test the delete capability that only admin has.

  • Delete the task you created. Confirm it disappears from the list.
  • Delete the project you created. Confirm it is removed.

Why test delete last?

Deleting resources first would leave you with nothing to view in the next substeps. Always test destructive actions after you have verified read and update operations.

Test restricted access as developer

The developer role can create and edit projects and tasks, but cannot delete anything. This substep confirms the permission boundaries work correctly.

  • Log out of the admin account by clicking your profile name and selecting Log Out.
  • Log in with email developer@example.com and password password.
  • Navigate to Projects and create a new project.
  • Confirm you can edit the project you just created.
  • Look at the action buttons on each project row. The Delete button should not be visible.
  • Navigate to Tasks and create a task within your project.
  • Confirm you can edit the task. The Delete button should not appear.

Now verify that the backend also blocks unauthorized actions, not just the UI.

  • Try visiting a delete URL directly in your browser's address bar. Type http://localhost/projects/1 and observe that the page loads normally (GET request).
  • Open your browser's developer tools by pressing F12.
  • In the Console tab, run a fetch request to simulate a DELETE action:
// Attempt a DELETE request as the developer user
fetch('/projects/1', {
  method: 'DELETE',
  headers: {
    'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content,
    'Accept': 'application/json'
  }
}).then(r => console.log(r.status))
  • You should see 403 logged in the console, confirming the backend rejects the unauthorized delete.

Why test both UI and backend?

Hiding a button in the UI is a convenience, not a security measure. A determined user could bypass the frontend entirely. The backend authorization (Policy + middleware) is the real security boundary.

Verifying a 403 response proves your Spatie permission checks work at the route level.

Not seeing a 403 response?

If you see a 405 (Method Not Allowed), the route may not be registered for DELETE. Check routes/web.php to confirm your resource routes are defined correctly.

If you see a 419 (CSRF token mismatch), make sure the meta tag selector in the fetch snippet matches your HTML layout.

Help me debug why my permission check isn't returning 403.

Test view-only access and responsive design

The regular user role has the most restrictive permissions. This account should only be able to browse and view resources.

  • Log out of the developer account.
  • Log in with email user@example.com and password password.
  • Navigate to Projects. Confirm you can see the project list and click into project details.
  • Verify that no Create, Edit, or Delete buttons appear anywhere in the interface.
  • Navigate to Tasks and confirm the same view-only behavior.
  • Check the role indicator badge in the sidebar. It should display your current role.

Now verify that the responsive layout adapts to narrow viewports.

  • Open your browser's developer tools by pressing F12.
  • Click the device toggle icon (or press Ctrl+Shift+M) to enter responsive mode.
  • Set the viewport width to around 375px to simulate a mobile device.
  • Verify that the Projects table stacks properly and is readable without horizontal scrolling.
  • Navigate to Tasks and confirm the card layout adjusts to a single column.

Finally, confirm your documentation is accurate.

  • Open README.md in VS Code.
  • Verify the Available Accounts section lists the three emails and roles you just tested.
  • Open docs/API.md and confirm the listed routes match the pages you navigated during testing.
  • Open docs/ARCHITECTURE.md and confirm the described permission system matches the behavior you just observed.

Docs drift is real

In professional projects, documentation that does not match the actual implementation is worse than no documentation at all. Always verify docs after a round of changes.

User account can still see action buttons?

If Create or Edit buttons appear for the user role, check that your React components are reading the permissions prop correctly. The backend shares permissions via Inertia shared data.

Also verify that the UserSeeder assigned only the user role (not developer or admin) to user@example.com.

Help me figure out why my user role still shows edit buttons in the React frontend.

Your full-stack application is tested and working. Every role behaves as designed, your theme looks sharp in both light and dark mode, and your documentation is verified against the real implementation. Next up, take on the Secret Mission to add a statistics dashboard.

Secret mission

Build a Dashboard with Statistics

Your project management app handles individual CRUD operations well. But what if you could see the big picture at a glance? In this secret mission, you'll build a role-aware dashboard with statistics cards, completion metrics, and a recent activity feed.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on your local machine using Docker containers. 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 (Laravel app, MySQL database, Redis cache).
  • Docker volumes (persisted database data).
  • Project folder (project-manager in your WSL2 home directory).

Keep everything running

No action needed. Choose this if you are still actively building or want to continue testing the application.

  • Your containers stay running in the background via ./vendor/bin/sail up -d.
  • Your database data persists in Docker volumes across restarts.
  • You can access the app any time at http://localhost.

Pause - I'll come back to this later

Stop all containers to free up system memory without losing your data. Your database and project files stay intact.

  • Open your WSL2 terminal and navigate to your project folder.
  • Stop all containers by running:
cd ~/project-manager
./vendor/bin/sail stop
  • When you are ready to resume, start everything again by running:
./vendor/bin/sail up -d

Your database data and project files will be exactly as you left them.

Delete - I don't want to use this again

Remove all containers, volumes, and project files. This permanently deletes your database data.

  • Open your WSL2 terminal and navigate to your project folder.
  • Stop all containers and remove Docker volumes by running:
cd ~/project-manager
./vendor/bin/sail down -v

What does this command do?

The down command stops and removes all containers. The -v flag also removes Docker volumes, which is where your MySQL database data is stored.

  • Remove the project folder entirely by running:
cd ~
rm -rf ~/project-manager
  • Confirm the folder is gone by running:
ls ~/project-manager

You should see a "No such file or directory" message confirming the folder was deleted.

That's a Wrap!

That's a Wrap!

Nice work! You've just built a full-stack, containerized project management application with Laravel and React. Two CRUD modules, three user roles, a custom design system, and comprehensive documentation. All running inside Docker containers.

You've learned how to:

  • Build a Dockerized development environment using Laravel Sail with PHP, MySQL, and Redis, following industry-standard architecture patterns (Service layer, Form Requests, Enums, and Policies).
  • Implement role-based access control with three user roles (admin, developer, user) using Spatie laravel-permission, with granular permission checks on both backend routes and frontend UI elements.
  • Create a custom Tailwind 4 design system using the @theme directive with custom colors, dark mode, and reusable @utility classes, paired with a React 19 frontend using TypeScript and shadcn/ui components.
  • Build a role-aware dashboard with aggregate statistics cards, task completion metrics, and a recent activity feed.

Ready to quiz yourself?