Corporate Project Management System

Build a full-stack corporate project management app with Laravel and React.

Introduction

30 Second Summary

Every company needs a system to track who works where, what projects are happening, and who has permission to see what. Building one from scratch teaches you how real business applications separate authentication from business data and handle multiple interconnected modules.

In this project, you will build a corporate project management system using Laravel, React, and Inertia.js. You will create employee records, departments, projects, and tasks, all protected by dynamic role-based access control that admins can configure from the UI.

What You'll Build

A full-stack Laravel 12 application with a React 19 frontend that manages employees, departments, projects, and tasks with dynamic role-based access control and shared hosting deployment.

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

  • A working corporate system with separated user accounts and employee records, departments, projects, and task tracking, all protected by role-based access control.
  • A dynamic admin panel where admins can create custom roles, assign permissions, manage user accounts independently from employee records, and organize departments.
  • The application deployed and running on SiteGround shared hosting.
  • Secret Mission: Add a project activity feed showing recent task updates across the company.

Are there any prerequisites?

You will need a basic understanding of PHP and JavaScript. You will also need a SiteGround shared hosting account with SSH access for the deployment step.

Set Up Your Development Environment

Before you can build your corporate project management system, you need a working development environment with PHP, Composer, Node.js, and the Laravel framework.

In this step, you will install all the required tools and create a fresh Laravel project with the React starter kit. By the end, you will have a running application with login and registration pages.

In this step, get ready to:
  • Install PHP, Composer, and Node.js on your machine.
  • Install the Laravel installer and create your project with the React + Inertia starter kit.
  • Run the application locally and confirm the login and registration pages work.
Install PHP, Composer, and Node.js

Laravel 12 requires PHP 8.2+ at minimum. You also need Composer (PHP's package manager) and Node.js to compile the frontend assets.

The Laravel team provides a one-command installer at php.new that installs PHP, Composer, and the Laravel installer together.

  • Open your terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
  • Install PHP, Composer, and the Laravel installer by running the command for your operating system:

macOS

  • Run this command in your terminal:
/bin/bash -c "$(curl -fsSL https://php.new/install/mac/8.4)"
  • After it finishes, close and reopen your terminal to refresh your session.

Windows

  • Right-click PowerShell and select Run as administrator.
  • Run this command:
Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; iex ((New-Object System.Net.WebClient).DownloadString('https://php.new/install/windows/8.4'))
  • After it finishes, close and reopen PowerShell to refresh your session.

What does this command do?

The php.new script downloads and installs PHP 8.4 (which satisfies the 8.2+ requirement), Composer, and the Laravel installer as globally accessible binaries on your machine.

It does not interfere with any existing development setup. It simply adds the binaries you need.

Now verify PHP is installed correctly.

  • Check your PHP version by running:
php -v

✔️ I see version 8.2 or higher

PHP is ready. You should see output like PHP 8.4.x (or any version 8.2+).

ⓧ I see an older version

You have an older PHP version installed that does not meet the 8.2+ requirement.

  • Re-run the php.new install command above for your operating system.
  • Close and reopen your terminal, then run php -v again.

ⓧ Command not found

PHP is not installed or not in your PATH.

  • Make sure you ran the correct php.new command for your operating system.
  • Make sure you closed and reopened your terminal after running the installer.
  • If the issue persists on Windows, ensure you ran PowerShell as administrator.
  • Check Composer is installed by running:
composer --version

✔️ I see a version number

Composer is ready. You should see output like Composer version 2.x.x.

ⓧ Command not found

Composer was not installed by the php.new script, or your PATH was not refreshed.

  • Close and reopen your terminal completely.
  • Re-run the php.new command for your operating system.
  • If it still fails, download Composer separately from getcomposer.org.

Next, install Node.js for compiling the frontend assets.

  • Check if Node.js is already installed by running:
node --version

✔️ I see a version number

Node.js is ready. Any recent LTS version will work for this project.

ⓧ Command not found

Node.js is not installed on your machine.

  • Visit nodejs.org and download the LTS version for your operating system.
  • Run the installer and follow the prompts (accept all defaults).
  • Close and reopen your terminal after installation.
  • Run node --version again to confirm it is installed.

Still seeing errors?

If any of the version commands are not working after following the instructions above, try restarting your computer. Some PATH changes only take effect after a full restart.

Help me troubleshoot my PHP, Composer, or Node.js installation

Install the Laravel installer and create the project

The php.new script already installed the Laravel CLI. If you installed PHP and Composer separately (without php.new), you need to install the Laravel installer manually.

  • If you did NOT use the php.new script, install the Laravel installer globally by running:
composer global require laravel/installer

What is the Laravel installer?

The Laravel installer is a CLI tool that scaffolds new Laravel projects. It prompts you to choose your testing framework, database, and starter kit during project creation.

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

The installer will ask you a series of questions. Follow these prompts carefully.

  • When asked which starter kit to install, select React (with Inertia).
  • When asked which testing framework to use, select your preference (Pest or PHPUnit). Either works for this project.
  • When asked which database to use, select SQLite (the default).
  • When asked to run npm install, select yes.
  • Wait for the installer to finish downloading dependencies and setting up the project.

Why SQLite for now?

SQLite requires zero configuration for local development. Laravel creates a database/database.sqlite file automatically and runs migrations during setup.

You will switch to MySQL when deploying to SiteGround in a later step.

  • Once the installer finishes, move into your project folder by running:
cd corporate-pm
  • Confirm the project was created by running:
ls

You should see files like artisan, composer.json, package.json, and folders like app/, resources/, and routes/.

Laravel command not found?

Make sure Composer's global vendor bin directory is in your PATH.

  • On macOS, the path is typically $HOME/.composer/vendor/bin.
  • On Windows, the path is typically %USERPROFILE%\AppData\Roaming\Composer\vendor\bin.
  • If you used the php.new script, simply close and reopen your terminal.

Help me fix my Laravel command not found error

Run the application locally

Your Laravel project is created, but you need to build the frontend assets and start the development server to see it in action.

  • Install npm dependencies and build the frontend assets by running:
npm install && npm run build

What does this command do?

The npm install command downloads all the JavaScript packages listed in package.json (React, Inertia.js, TypeScript, Tailwind CSS, and Vite).

The npm run build command uses Vite to compile and bundle those assets into production-ready files in the public/build/ directory.

  • Start the Laravel development server by running:
composer run dev

What does composer run dev do?

This command starts Laravel's local development server, the queue worker, and the Vite development server simultaneously. It gives you hot-reloading for frontend changes as you develop.

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

You should see the Laravel welcome page with Log in and Register links in the top right corner.

  • Click Register to confirm the registration form loads.
  • Click Log in to confirm the login form loads.

Page not loading at localhost:8000?

  • Make sure the composer run dev command is still running in your terminal. Do not close the terminal window.
  • Check that no other application is using port 8000. If you see an address-already-in-use error, stop the other process or use a different port.
  • If you see a blank page, make sure you ran npm install && npm run build before starting the server.

Help me debug my Laravel development server

Your development environment is ready and your Laravel project is running locally with React and Inertia. Next up, you will install the Spatie Permission package and create the database schema for employees, departments, projects, and tasks.

Install Spatie Permission and Create the Database Schema

Your Laravel app can handle login and registration, but it has no concept of who can do what. Every logged-in user has the same power.

In a corporate system, different people need different access levels. Admins manage everything, managers oversee their departments, and employees see only their own tasks. In this step, you will install the Spatie Laravel Permission package to handle role-based access control, then create the database tables that store your company's employees, departments, projects, and tasks.

In this step, get ready to:
  • Install the Spatie Permission package and configure middleware.
  • Create database migrations for employees, departments, projects, and tasks.
  • Set up Eloquent models with relationships.
Install the Spatie package

Spatie Laravel Permission is the community-standard package for managing roles and permissions in Laravel applications. It gives you middleware for route protection, a clean API for assigning roles, and database-backed permission storage.

  • Make sure your terminal is in the corporate-pm folder, then install the package by running:
composer require spatie/laravel-permission

What does this command do?

Composer downloads the spatie/laravel-permission package and adds it to your composer.json dependencies. It also installs any sub-dependencies the package needs.

  • Wait for Composer to finish. You should see a success message confirming the package was installed.
  • Publish the Spatie config file and migration by running:
php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider"

What does this command do?

This publishes two things into your project: a config/permission.php configuration file and a migration file in database/migrations/ that creates the roles, permissions, and pivot tables Spatie needs.

  • Confirm you see config/permission.php in your project and a new migration file in database/migrations/.

Command not found?

Make sure you are inside the corporate-pm directory. Artisan commands only work from the project root. If Composer failed, confirm your PHP version is 8.2+ by running php -v.

Still stuck? Help me troubleshoot the Spatie Permission install

Now you need to tell your User model to use Spatie's role system.

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

namespace App\Models;

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, Notifiable, HasRoles;

    /**
     * The attributes that are mass assignable.
     */
    protected $fillable = [
        'name',
        'email',
        'password',
    ];

    /**
     * The attributes that should be hidden for serialization.
     */
    protected $hidden = [
        'password',
        'remember_token',
    ];

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

What does this code do?

The key addition is use HasRoles; inside the class. This trait from Spatie adds methods like assignRole(), hasRole(), and can() to every User instance. It also connects your User model to the roles and permissions tables that Spatie's migration creates.

  • Save app/Models/User.php.

Next, register Spatie's middleware aliases so you can protect routes by role or permission.

  • Open bootstrap/app.php.
  • Replace the entire file contents with the following code:
<?php

use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        commands: __DIR__.'/../routes/console.php',
        health: '/up',
    )
    ->withMiddleware(function (Middleware $middleware) {
        // Register Spatie permission middleware aliases
        $middleware->alias([
            'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,
            'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class,
            'role_or_permission' => \Spatie\Permission\Middleware\RoleOrPermissionMiddleware::class,
        ]);
    })
    ->withExceptions(function (Exceptions $exceptions) {
        //
    })->create();

What does this code do?

The $middleware->alias() call registers three shorthand names: role, permission, and role_or_permission. You can now apply these as route middleware (e.g. middleware('permission:manage employees')) to restrict access.

  • Save bootstrap/app.php.

Seeing a class not found error?

Double-check that the Composer install succeeded earlier. The Spatie middleware classes only exist after the package is installed. Run composer dump-autoload to refresh the autoloader if needed.

Need more help? Help me fix the Spatie middleware alias setup

Create migrations for employees, departments, projects, and tasks

Your database needs tables to store company data. You will create four migrations in a specific order so that foreign key references resolve correctly.

  • Generate all four migration files by running these commands one at a time:
php artisan make:migration create_departments_table
php artisan make:migration create_employees_table
php artisan make:migration create_projects_table
php artisan make:migration create_tasks_table

Why this order?

Departments must exist before employees (employees reference a department). Projects reference departments. Tasks reference both projects and employees. Running migrations in timestamp order ensures each table exists before another references it.

  • Confirm you see four new files in database/migrations/ with today's date in their filenames.
  • Open the departments migration file (the one ending in _create_departments_table.php) and replace its contents with:
<?php

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

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('departments', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->text('description')->nullable();
            $table->foreignId('head_employee_id')->nullable()->constrained('employees')->nullOnDelete();
            $table->timestamps();
        });
    }

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

What does this migration do?

This creates a departments table with a name, an optional description, and a nullable foreign key pointing to an employee who heads the department. The nullOnDelete() means if the head employee is deleted, the column becomes null rather than deleting the department.

  • Save the departments migration file.
  • Open the employees migration file (ending in _create_employees_table.php) and replace its contents with:
<?php

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

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('employees', function (Blueprint $table) {
            $table->id();
            $table->string('employee_id')->unique();
            $table->string('first_name');
            $table->string('last_name');
            $table->string('email')->unique();
            $table->string('phone')->nullable();
            $table->string('position');
            $table->foreignId('department_id')->nullable()->constrained('departments')->nullOnDelete();
            $table->foreignId('user_id')->nullable()->unique()->constrained('users')->nullOnDelete();
            $table->date('hire_date');
            $table->decimal('salary', 10, 2)->nullable();
            $table->enum('status', ['active', 'inactive', 'on_leave'])->default('active');
            $table->timestamps();
        });
    }

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

What does this migration do?

The employees table stores HR data separately from login credentials. The user_id column is nullable and unique. An employee can exist without a login account, and a user account can exist without an employee record. This is the separated architecture at the heart of your system.

  • Save the employees migration file.
  • Open the projects migration file (ending in _create_projects_table.php) and replace its contents with:
<?php

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

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('projects', function (Blueprint $table) {
            $table->id();
            $table->string('title');
            $table->text('description')->nullable();
            $table->foreignId('department_id')->nullable()->constrained('departments')->nullOnDelete();
            $table->enum('status', ['planning', 'active', 'on_hold', 'completed'])->default('planning');
            $table->date('deadline')->nullable();
            $table->timestamps();
        });
    }

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

What does this migration do?

The projects table tracks company projects with a title, optional description, a link to the owning department, a status enum for workflow tracking, and an optional deadline.

  • Save the projects migration file.
  • Open the tasks migration file (ending in _create_tasks_table.php) and replace its contents with:
<?php

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

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('tasks', function (Blueprint $table) {
            $table->id();
            $table->string('title');
            $table->text('description')->nullable();
            $table->foreignId('project_id')->constrained('projects')->cascadeOnDelete();
            $table->foreignId('assigned_to_employee_id')->nullable()->constrained('employees')->nullOnDelete();
            $table->enum('status', ['todo', 'in_progress', 'review', 'done'])->default('todo');
            $table->enum('priority', ['low', 'medium', 'high', 'urgent'])->default('medium');
            $table->date('due_date')->nullable();
            $table->timestamps();
        });
    }

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

What does this migration do?

Tasks belong to a project (required, cascading delete) and are optionally assigned to an employee. The status and priority enums give you structured workflow tracking without extra lookup tables.

  • Save the tasks migration file.
  • Run all migrations to create the tables in your database:
php artisan migrate
  • You should see output listing each migration that ran, including the Spatie permission tables and your four new tables.

Migration failed with a foreign key error?

If you see an error about a foreign key referencing a table that does not exist, check that your migration files have timestamps in the correct order. The departments file must have an earlier timestamp than employees. You can rename the date prefix in the filename to reorder them, then run php artisan migrate:fresh to start clean.

Need help? Help me fix my migration order

Circular reference between departments and employees

The departments table has a head_employee_id that references employees, and employees has a department_id that references departments. Both columns are nullable, which breaks the circular dependency. The database creates both tables without issue because neither foreign key is required.

Set up Eloquent models and relationships

With your tables in place, you need Eloquent models to interact with them in PHP. Each model maps to a table and defines how records relate to each other.

  • Generate all four models by running these commands:
php artisan make:model Employee
php artisan make:model Department
php artisan make:model Project
php artisan make:model Task
  • Confirm you see four new files in app/Models/: Employee.php, Department.php, Project.php, and Task.php.
  • Open app/Models/Department.php and replace its contents with:
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Department extends Model
{
    use HasFactory;

    protected $fillable = [
        'name',
        'description',
        'head_employee_id',
    ];

    /**
     * Get the employee who heads this department.
     */
    public function headEmployee(): BelongsTo
    {
        return $this->belongsTo(Employee::class, 'head_employee_id');
    }

    /**
     * Get all employees in this department.
     */
    public function employees(): HasMany
    {
        return $this->hasMany(Employee::class);
    }

    /**
     * Get all projects belonging to this department.
     */
    public function projects(): HasMany
    {
        return $this->hasMany(Project::class);
    }
}

What does this model do?

The Department model defines three relationships. It belongsTo an Employee as its head (using the custom foreign key head_employee_id). It hasMany employees and hasMany projects. These relationships let you write expressive queries like $department->employees to get all staff.

  • Save app/Models/Department.php.
  • Open app/Models/Employee.php and replace its contents with:
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Employee extends Model
{
    use HasFactory;

    protected $fillable = [
        'employee_id',
        'first_name',
        'last_name',
        'email',
        'phone',
        'position',
        'department_id',
        'user_id',
        'hire_date',
        'salary',
        'status',
    ];

    /**
     * Get the user account linked to this employee (optional).
     */
    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }

    /**
     * Get the department this employee belongs to.
     */
    public function department(): BelongsTo
    {
        return $this->belongsTo(Department::class);
    }

    /**
     * Get all tasks assigned to this employee.
     */
    public function tasks(): HasMany
    {
        return $this->hasMany(Task::class, 'assigned_to_employee_id');
    }
}

What does this model do?

The Employee model connects to both the auth system and the business data. The user() relationship is nullable, allowing employees to exist without a login account. The custom foreign key on tasks() tells Eloquent to look at assigned_to_employee_id instead of the default employee_id column.

  • Save app/Models/Employee.php.
  • Open app/Models/Project.php and replace its contents with:
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Project extends Model
{
    use HasFactory;

    protected $fillable = [
        'title',
        'description',
        'department_id',
        'status',
        'deadline',
    ];

    /**
     * Get the department this project belongs to.
     */
    public function department(): BelongsTo
    {
        return $this->belongsTo(Department::class);
    }

    /**
     * Get all tasks for this project.
     */
    public function tasks(): HasMany
    {
        return $this->hasMany(Task::class);
    }

    /**
     * Get the employees assigned to this project.
     */
    public function employees(): BelongsToMany
    {
        return $this->belongsToMany(Employee::class, 'project_employee');
    }
}

What does this model do?

The Project model links to a department, has many tasks, and uses a many-to-many relationship with employees through a project_employee pivot table. You will create that pivot table in a later step when you build the project member assignment feature.

  • Save app/Models/Project.php.
  • Open app/Models/Task.php and replace its contents with:
<?php

namespace App\Models;

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

class Task extends Model
{
    use HasFactory;

    protected $fillable = [
        'title',
        'description',
        'project_id',
        'assigned_to_employee_id',
        'status',
        'priority',
        'due_date',
    ];

    /**
     * Get the project this task belongs to.
     */
    public function project(): BelongsTo
    {
        return $this->belongsTo(Project::class);
    }

    /**
     * Get the employee assigned to this task.
     */
    public function assignee(): BelongsTo
    {
        return $this->belongsTo(Employee::class, 'assigned_to_employee_id');
    }
}

What does this model do?

The Task model belongs to a project (required) and optionally belongs to an employee as its assignee. The custom foreign key assigned_to_employee_id keeps the column name explicit and descriptive in the database.

  • Save app/Models/Task.php.

✔️ Awesome, I've got everything!

Great. Double check you have saved all four model files and bootstrap/app.php.

ⓧ I'd like to double check the full code

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

<?php

namespace App\Models;

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, Notifiable, HasRoles;

    /**
     * The attributes that are mass assignable.
     */
    protected $fillable = [
        'name',
        'email',
        'password',
    ];

    /**
     * The attributes that should be hidden for serialization.
     */
    protected $hidden = [
        'password',
        'remember_token',
    ];

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

Here is the complete bootstrap/app.php:

<?php

use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        commands: __DIR__.'/../routes/console.php',
        health: '/up',
    )
    ->withMiddleware(function (Middleware $middleware) {
        // Register Spatie permission middleware aliases
        $middleware->alias([
            'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,
            'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class,
            'role_or_permission' => \Spatie\Permission\Middleware\RoleOrPermissionMiddleware::class,
        ]);
    })
    ->withExceptions(function (Exceptions $exceptions) {
        //
    })->create();

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

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Department extends Model
{
    use HasFactory;

    protected $fillable = [
        'name',
        'description',
        'head_employee_id',
    ];

    /**
     * Get the employee who heads this department.
     */
    public function headEmployee(): BelongsTo
    {
        return $this->belongsTo(Employee::class, 'head_employee_id');
    }

    /**
     * Get all employees in this department.
     */
    public function employees(): HasMany
    {
        return $this->hasMany(Employee::class);
    }

    /**
     * Get all projects belonging to this department.
     */
    public function projects(): HasMany
    {
        return $this->hasMany(Project::class);
    }
}

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

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Employee extends Model
{
    use HasFactory;

    protected $fillable = [
        'employee_id',
        'first_name',
        'last_name',
        'email',
        'phone',
        'position',
        'department_id',
        'user_id',
        'hire_date',
        'salary',
        'status',
    ];

    /**
     * Get the user account linked to this employee (optional).
     */
    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }

    /**
     * Get the department this employee belongs to.
     */
    public function department(): BelongsTo
    {
        return $this->belongsTo(Department::class);
    }

    /**
     * Get all tasks assigned to this employee.
     */
    public function tasks(): HasMany
    {
        return $this->hasMany(Task::class, 'assigned_to_employee_id');
    }
}

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

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Project extends Model
{
    use HasFactory;

    protected $fillable = [
        'title',
        'description',
        'department_id',
        'status',
        'deadline',
    ];

    /**
     * Get the department this project belongs to.
     */
    public function department(): BelongsTo
    {
        return $this->belongsTo(Department::class);
    }

    /**
     * Get all tasks for this project.
     */
    public function tasks(): HasMany
    {
        return $this->hasMany(Task::class);
    }

    /**
     * Get the employees assigned to this project.
     */
    public function employees(): BelongsToMany
    {
        return $this->belongsToMany(Employee::class, 'project_employee');
    }
}

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

<?php

namespace App\Models;

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

class Task extends Model
{
    use HasFactory;

    protected $fillable = [
        'title',
        'description',
        'project_id',
        'assigned_to_employee_id',
        'status',
        'priority',
        'due_date',
    ];

    /**
     * Get the project this task belongs to.
     */
    public function project(): BelongsTo
    {
        return $this->belongsTo(Project::class);
    }

    /**
     * Get the employee assigned to this task.
     */
    public function assignee(): BelongsTo
    {
        return $this->belongsTo(Employee::class, 'assigned_to_employee_id');
    }
}

Models not found when running artisan commands later?

Ensure each model file starts with the correct namespace App\Models and is saved in the app/Models/ directory. Laravel uses PSR-4 autoloading, so the file path must match the namespace exactly.

Still have issues? Help me fix my Eloquent model not being found

Your database schema is ready and your models know how to talk to each other. Next up, you will seed the database with roles, permissions, and sample data so you can test everything end to end.

Seed Roles, Permissions, and Sample Data

Your database tables are ready, but they're empty. Before building CRUD pages, you need test data to work with and a permission system that controls who can do what.

In this step, you'll seed the database with roles, permissions, and sample records. This includes an admin user with no employee record, a manager linked to an employee, and an employee record with no login account. These scenarios prove that your user-employee separation actually works.

In this step, get ready to:
  • Create a permission seeder that defines roles and permissions.
  • Seed sample departments, users, and employees with different linking scenarios.
  • Run the seeders and verify the data structure by logging in.
Create the permission seeder

Role-based access control needs two things: permissions (specific actions like "view employees") and roles (bundles of permissions like "admin" or "manager"). You'll create a seeder that defines all 12 permissions your system needs and groups them into three roles.

  • Generate the seeder file by running this command in your terminal:
php artisan make:seeder PermissionSeeder
  • Confirm the file was created by checking the database/seeders directory. You should see PermissionSeeder.php listed.
  • Open database/seeders/PermissionSeeder.php in VS Code and replace its entire contents with this code:
<?php

namespace Database\Seeders;

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

class PermissionSeeder extends Seeder
{
    public function run(): void
    {
        // Clear the permission cache so new entries are recognized
        app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions();

        // Define all 12 permissions for the system
        $permissions = [
            'view employees',
            'manage employees',
            'view departments',
            'manage departments',
            'view projects',
            'manage projects',
            'view tasks',
            'create tasks',
            'edit tasks',
            'delete tasks',
            'manage users',
            'manage roles',
        ];

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

        // Admin role gets every permission
        $adminRole = Role::create(['name' => 'admin']);
        $adminRole->givePermissionTo(Permission::all());

        // Manager role: can view employees, manage projects and tasks
        $managerRole = Role::create(['name' => 'manager']);
        $managerRole->givePermissionTo([
            'view employees',
            'view departments',
            'view projects',
            'manage projects',
            'view tasks',
            'create tasks',
            'edit tasks',
            'delete tasks',
        ]);

        // Employee role: basic task permissions only
        $employeeRole = Role::create(['name' => 'employee']);
        $employeeRole->givePermissionTo([
            'view tasks',
            'create tasks',
            'edit tasks',
        ]);
    }
}

What does this code do?

This seeder uses Spatie's Permission and Role models to populate the database with your access control structure.

  • forgetCachedPermissions() clears the permission cache so newly created entries are recognized immediately.
  • 12 granular permissions are created, covering employees, departments, projects, tasks, users, and roles.
  • The admin role gets all permissions. The manager role can view employees and manage projects/tasks. The employee role can only view, create, and edit tasks.
  • Save PermissionSeeder.php.

You won't see a change yet. The next substep creates sample data, and then you'll run both seeders together.

Seeing a class not found error?

  • Make sure the namespace at the top of the file is exactly Database\Seeders.
  • Check that both use statements import from Spatie\Permission\Models (not a different namespace).
  • Confirm that you ran composer require spatie/laravel-permission in the previous step.

Why is my PermissionSeeder not finding the Spatie classes?

Create sample data seeder

Permissions alone don't prove anything. You need real data that demonstrates the user-employee separation in action. This seeder creates scenarios that would exist in a real company: an external admin with no employee record, employees linked to login accounts, and a new hire who hasn't received a login yet.

  • Generate the sample data seeder by running this command:
php artisan make:seeder SampleDataSeeder
  • Confirm SampleDataSeeder.php appears in your database/seeders directory.
  • Open database/seeders/SampleDataSeeder.php and replace its entire contents with this code:
<?php

namespace Database\Seeders;

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

class SampleDataSeeder extends Seeder
{
    public function run(): void
    {
        // Create three departments
        $engineering = Department::create([
            'name' => 'Engineering',
            'description' => 'Software development and technical operations',
        ]);

        $marketing = Department::create([
            'name' => 'Marketing',
            'description' => 'Brand management and growth initiatives',
        ]);

        $hr = Department::create([
            'name' => 'HR',
            'description' => 'Human resources and talent management',
        ]);

        // Admin user with NO employee record (demonstrates user-without-employee)
        $adminUser = User::create([
            'name' => 'Admin User',
            'email' => 'admin@example.com',
            'password' => Hash::make('password'),
        ]);
        $adminUser->assignRole('admin');

        // Manager user linked to an employee record in Engineering
        $managerUser = User::create([
            'name' => 'Manager User',
            'email' => 'manager@example.com',
            'password' => Hash::make('password'),
        ]);
        $managerUser->assignRole('manager');

        $managerEmployee = Employee::create([
            'employee_id' => 'EMP-001',
            'first_name' => 'Manager',
            'last_name' => 'User',
            'email' => 'manager@example.com',
            'position' => 'Engineering Lead',
            'department_id' => $engineering->id,
            'user_id' => $managerUser->id,
            'hire_date' => '2023-01-15',
            'salary' => 95000,
            'status' => 'active',
        ]);

        // Employee user linked to an employee record
        $employeeUser = User::create([
            'name' => 'Employee User',
            'email' => 'employee@example.com',
            'password' => Hash::make('password'),
        ]);
        $employeeUser->assignRole('employee');

        Employee::create([
            'employee_id' => 'EMP-002',
            'first_name' => 'Employee',
            'last_name' => 'User',
            'email' => 'employee@example.com',
            'position' => 'Software Developer',
            'department_id' => $engineering->id,
            'user_id' => $employeeUser->id,
            'hire_date' => '2023-06-01',
            'salary' => 75000,
            'status' => 'active',
        ]);

        // Employee record with NO user account (demonstrates employee-without-login)
        Employee::create([
            'employee_id' => 'EMP-003',
            'first_name' => 'New',
            'last_name' => 'Hire',
            'email' => 'newhire@example.com',
            'position' => 'Marketing Specialist',
            'department_id' => $marketing->id,
            'user_id' => null,
            'hire_date' => '2024-01-10',
            'salary' => 65000,
            'status' => 'active',
        ]);

        // Set the department head for Engineering
        $engineering->update(['head_employee_id' => $managerEmployee->id]);
    }
}

What does this code do?

This seeder creates the four key data scenarios that prove your separated architecture works.

  • admin@example.com is a user with the admin role but no employee record. This represents an external consultant or system administrator.
  • manager@example.com is a user linked to an employee record in the Engineering department. This is the typical case for most staff.
  • employee@example.com is a regular user linked to an employee record with basic task permissions.
  • EMP-003 (New Hire) is an employee record with no user account. This represents someone added to the HR system before they get a login.
  • Save SampleDataSeeder.php.

You won't see a change yet. The next substep wires up the DatabaseSeeder and runs everything.

Getting a model not found error?

  • Verify the use statements at the top import App\Models\Department, App\Models\Employee, and App\Models\User.
  • Make sure you created the Employee and Department models in the previous step.

Why can't my SampleDataSeeder find the Employee or Department model?

Run seeders and verify

Laravel's DatabaseSeeder is the entry point that orchestrates all other seeders. You need to tell it to call your two new seeders in the correct order (permissions first, then sample data that depends on those permissions).

  • Open database/seeders/DatabaseSeeder.php and replace its entire contents with this code:
<?php

namespace Database\Seeders;

use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
    public function run(): void
    {
        // Run permission seeder first, then sample data that uses those roles
        $this->call([
            PermissionSeeder::class,
            SampleDataSeeder::class,
        ]);
    }
}

Why does order matter?

PermissionSeeder must run before SampleDataSeeder because the sample data seeder calls assignRole('admin') on user records. If the roles don't exist yet, that call will fail.

  • Save DatabaseSeeder.php.
  • Reset your database and run all seeders by running this command:
php artisan migrate:fresh --seed

What does this command do?

migrate:fresh drops all tables and re-runs every migration from scratch. The --seed flag then executes DatabaseSeeder, which calls your two seeders in order. This gives you a completely clean database with known test data every time.

You should see output listing each migration running, followed by seeder confirmations for PermissionSeeder and SampleDataSeeder.

  • Start your development server by running:
composer run dev
  • Open your browser and navigate to the login page.
  • Log in with the email admin@example.com and password password.
  • Confirm you can log in successfully. The admin user has no employee record linked to it, which is correct.

How do I know it worked?

If you can log in as admin@example.com, your seeders ran correctly. The admin has the admin role with all 12 permissions, but no linked employee record. In later steps, you'll build pages that display this separation clearly.

Seeder failing with an error?

  • If you see a "table not found" error, make sure your migrations ran successfully in the previous step. Try running php artisan migrate:fresh --seed again.
  • If you see a "role not found" error, check that PermissionSeeder is listed BEFORE SampleDataSeeder in the DatabaseSeeder.php call array.
  • If you see a duplicate entry error, the migrate:fresh command should prevent this since it resets the database. Run the command again from a clean state.

My seeders are failing and I can't figure out why. Help me debug.

✔️ Awesome, I've got everything!

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

ⓧ I'd like to double check the full code

Here are the complete files for all three seeders.

<?php

namespace Database\Seeders;

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

class PermissionSeeder extends Seeder
{
    public function run(): void
    {
        // Clear the permission cache so new entries are recognized
        app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions();

        // Define all 12 permissions for the system
        $permissions = [
            'view employees',
            'manage employees',
            'view departments',
            'manage departments',
            'view projects',
            'manage projects',
            'view tasks',
            'create tasks',
            'edit tasks',
            'delete tasks',
            'manage users',
            'manage roles',
        ];

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

        // Admin role gets every permission
        $adminRole = Role::create(['name' => 'admin']);
        $adminRole->givePermissionTo(Permission::all());

        // Manager role: can view employees, manage projects and tasks
        $managerRole = Role::create(['name' => 'manager']);
        $managerRole->givePermissionTo([
            'view employees',
            'view departments',
            'view projects',
            'manage projects',
            'view tasks',
            'create tasks',
            'edit tasks',
            'delete tasks',
        ]);

        // Employee role: basic task permissions only
        $employeeRole = Role::create(['name' => 'employee']);
        $employeeRole->givePermissionTo([
            'view tasks',
            'create tasks',
            'edit tasks',
        ]);
    }
}
<?php

namespace Database\Seeders;

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

class SampleDataSeeder extends Seeder
{
    public function run(): void
    {
        // Create three departments
        $engineering = Department::create([
            'name' => 'Engineering',
            'description' => 'Software development and technical operations',
        ]);

        $marketing = Department::create([
            'name' => 'Marketing',
            'description' => 'Brand management and growth initiatives',
        ]);

        $hr = Department::create([
            'name' => 'HR',
            'description' => 'Human resources and talent management',
        ]);

        // Admin user with NO employee record (demonstrates user-without-employee)
        $adminUser = User::create([
            'name' => 'Admin User',
            'email' => 'admin@example.com',
            'password' => Hash::make('password'),
        ]);
        $adminUser->assignRole('admin');

        // Manager user linked to an employee record in Engineering
        $managerUser = User::create([
            'name' => 'Manager User',
            'email' => 'manager@example.com',
            'password' => Hash::make('password'),
        ]);
        $managerUser->assignRole('manager');

        $managerEmployee = Employee::create([
            'employee_id' => 'EMP-001',
            'first_name' => 'Manager',
            'last_name' => 'User',
            'email' => 'manager@example.com',
            'position' => 'Engineering Lead',
            'department_id' => $engineering->id,
            'user_id' => $managerUser->id,
            'hire_date' => '2023-01-15',
            'salary' => 95000,
            'status' => 'active',
        ]);

        // Employee user linked to an employee record
        $employeeUser = User::create([
            'name' => 'Employee User',
            'email' => 'employee@example.com',
            'password' => Hash::make('password'),
        ]);
        $employeeUser->assignRole('employee');

        Employee::create([
            'employee_id' => 'EMP-002',
            'first_name' => 'Employee',
            'last_name' => 'User',
            'email' => 'employee@example.com',
            'position' => 'Software Developer',
            'department_id' => $engineering->id,
            'user_id' => $employeeUser->id,
            'hire_date' => '2023-06-01',
            'salary' => 75000,
            'status' => 'active',
        ]);

        // Employee record with NO user account (demonstrates employee-without-login)
        Employee::create([
            'employee_id' => 'EMP-003',
            'first_name' => 'New',
            'last_name' => 'Hire',
            'email' => 'newhire@example.com',
            'position' => 'Marketing Specialist',
            'department_id' => $marketing->id,
            'user_id' => null,
            'hire_date' => '2024-01-10',
            'salary' => 65000,
            'status' => 'active',
        ]);

        // Set the department head for Engineering
        $engineering->update(['head_employee_id' => $managerEmployee->id]);
    }
}
<?php

namespace Database\Seeders;

use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
    public function run(): void
    {
        // Run permission seeder first, then sample data that uses those roles
        $this->call([
            PermissionSeeder::class,
            SampleDataSeeder::class,
        ]);
    }
}

Your database is now populated with roles, permissions, and realistic test data. Next up, you'll build the Employee CRUD so you can view and manage these records through the browser.

Build the Employee CRUD

With your database schema and sample data in place, it's time to build the first real business module. The Employee CRUD lets admins create, view, edit, and delete employee records.

This module also introduces the core architectural pattern of the app: separating user accounts (login credentials) from employee records (HR data). The employee form will include an optional link to a user account, proving that the two can exist independently.

In this step, get ready to:
  • Build an EmployeeController with full CRUD operations and permission-based protection.
  • Create React pages for listing, viewing, and managing employees.
  • Implement user-employee linking and unlinking in the employee form.
Create the Employee controller

The EmployeeController handles all CRUD operations for employee records. It returns Inertia responses that render React page components with the data they need.

  • Generate the controller by running this command in your terminal:
php artisan make:controller EmployeeController --resource
  • Open app/Http/Controllers/EmployeeController.php and replace its contents with the following:
<?php

namespace App\Http\Controllers;

use App\Models\Employee;
use App\Models\Department;
use App\Models\User;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;

class EmployeeController extends Controller
{
    // Display a paginated list of all employees
    public function index(): Response
    {
        $employees = Employee::with(['department', 'user'])
            ->paginate(15);

        return Inertia::render('Employees/Index', [
            'employees' => $employees,
        ]);
    }

    // Show the form for creating a new employee
    public function create(): Response
    {
        // Get departments for the dropdown
        $departments = Department::all();

        // Get users that are not yet linked to any employee
        $availableUsers = User::whereDoesntHave('employee')->get();

        return Inertia::render('Employees/Create', [
            'departments' => $departments,
            'availableUsers' => $availableUsers,
        ]);
    }

    // Store a newly created employee in the database
    public function store(Request $request)
    {
        $validated = $request->validate([
            'employee_id' => 'required|string|unique:employees',
            'first_name' => 'required|string|max:255',
            'last_name' => 'required|string|max:255',
            'email' => 'required|email|max:255',
            'phone' => 'nullable|string|max:20',
            'position' => 'required|string|max:255',
            'department_id' => 'required|exists:departments,id',
            'hire_date' => 'required|date',
            'salary' => 'required|numeric|min:0',
            'status' => 'required|in:active,inactive,terminated',
            'user_id' => 'nullable|exists:users,id',
        ]);

        Employee::create($validated);

        return redirect()->route('employees.index')
            ->with('success', 'Employee created successfully.');
    }

    // Display a single employee's details
    public function show(Employee $employee): Response
    {
        $employee->load(['department', 'user']);

        return Inertia::render('Employees/Show', [
            'employee' => $employee,
            // Pass whether current user can see salary
            'canViewSalary' => auth()->user()->hasRole('admin'),
        ]);
    }

    // Show the form for editing an existing employee
    public function edit(Employee $employee): Response
    {
        $departments = Department::all();

        // Get users not linked to any employee, plus the currently linked user
        $availableUsers = User::where(function ($query) use ($employee) {
            $query->whereDoesntHave('employee')
                ->orWhere('id', $employee->user_id);
        })->get();

        return Inertia::render('Employees/Edit', [
            'employee' => $employee,
            'departments' => $departments,
            'availableUsers' => $availableUsers,
        ]);
    }

    // Update an existing employee record
    public function update(Request $request, Employee $employee)
    {
        $validated = $request->validate([
            'employee_id' => 'required|string|unique:employees,employee_id,' . $employee->id,
            'first_name' => 'required|string|max:255',
            'last_name' => 'required|string|max:255',
            'email' => 'required|email|max:255',
            'phone' => 'nullable|string|max:20',
            'position' => 'required|string|max:255',
            'department_id' => 'required|exists:departments,id',
            'hire_date' => 'required|date',
            'salary' => 'required|numeric|min:0',
            'status' => 'required|in:active,inactive,terminated',
            'user_id' => 'nullable|exists:users,id',
        ]);

        $employee->update($validated);

        return redirect()->route('employees.index')
            ->with('success', 'Employee updated successfully.');
    }

    // Delete an employee record (does not delete linked user)
    public function destroy(Employee $employee)
    {
        $employee->delete();

        return redirect()->route('employees.index')
            ->with('success', 'Employee deleted successfully.');
    }
}

What does this code do?

Each method in the controller handles one CRUD action and returns an Inertia response that renders a React page component.

  • index() loads employees with their department and user relationships, then paginates the results.
  • create() and edit() pass departments and available (unlinked) users as dropdown options.
  • store() and update() validate all fields including the optional user_id for linking.
  • destroy() deletes only the employee record. The linked user account stays intact.

Now register the routes so Laravel knows where to send requests for employee pages.

  • Open routes/web.php and add the employee resource routes inside the existing auth middleware group:
use App\Http\Controllers\EmployeeController;

// Employee CRUD routes - view open to all auth users, manage restricted
Route::resource('employees', EmployeeController::class)
    ->middleware('auth')
    ->middlewareFor(['create', 'store', 'edit', 'update', 'destroy'], 'permission:manage employees');

What does this route registration do?

Route::resource generates all seven CRUD routes (index, create, store, show, edit, update, destroy) from a single line.

The middlewareFor method applies the permission:manage employees middleware only to the create, store, edit, update, and destroy actions. Any authenticated user can view the employee list and details, but only users with the 'manage employees' permission can make changes.

  • Save both EmployeeController.php and web.php.
  • Verify the routes registered correctly by running:
php artisan route:list --path=employees

You should see seven routes listed for the employees resource, including GET, POST, PUT, and DELETE methods.

Not seeing the employee routes?

  • Check that your use statement for EmployeeController is at the top of web.php.
  • Make sure the route definition is not nested inside another middleware group that conflicts.
  • Run php artisan route:clear to clear the route cache, then try again.

help me debug my employee routes not showing up

Create Employee React pages

Each controller method renders a specific React page component. You need four page files: Index (table listing), Show (detail view), Create (new employee form), and Edit (update form).

  • Create the Employees directory for your pages by running:
mkdir -p resources/js/Pages/Employees
  • Create resources/js/Pages/Employees/Index.tsx and add the following code:
import { Head, Link, router } from '@inertiajs/react';

// TypeScript interfaces for the employee data
interface Department {
    id: number;
    name: string;
}

interface User {
    id: number;
    email: string;
}

interface Employee {
    id: number;
    employee_id: string;
    first_name: string;
    last_name: string;
    email: string;
    position: string;
    status: string;
    department: Department | null;
    user: User | null;
}

interface PaginatedEmployees {
    data: Employee[];
    links: { url: string | null; label: string; active: boolean }[];
}

interface Props {
    employees: PaginatedEmployees;
}

export default function Index({ employees }: Props) {
    // Handle employee deletion with confirmation
    function handleDelete(id: number) {
        if (confirm('Are you sure you want to delete this employee?')) {
            router.delete(`/employees/${id}`);
        }
    }

    return (
        <>
            <Head title="Employees" />
            <div className="p-6">
                <div className="flex justify-between items-center mb-6">
                    <h1 className="text-2xl font-bold">Employees</h1>
                    <Link
                        href="/employees/create"
                        className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
                    >
                        Add Employee
                    </Link>
                </div>

                {/* Employee table */}
                <table className="w-full border-collapse border border-gray-200">
                    <thead>
                        <tr className="bg-gray-100">
                            <th className="border p-2 text-left">Employee ID</th>
                            <th className="border p-2 text-left">Name</th>
                            <th className="border p-2 text-left">Department</th>
                            <th className="border p-2 text-left">Position</th>
                            <th className="border p-2 text-left">Status</th>
                            <th className="border p-2 text-left">Has Account</th>
                            <th className="border p-2 text-left">Actions</th>
                        </tr>
                    </thead>
                    <tbody>
                        {employees.data.map((employee) => (
                            <tr key={employee.id}>
                                <td className="border p-2">{employee.employee_id}</td>
                                <td className="border p-2">
                                    {employee.first_name} {employee.last_name}
                                </td>
                                <td className="border p-2">
                                    {employee.department?.name ?? 'N/A'}
                                </td>
                                <td className="border p-2">{employee.position}</td>
                                <td className="border p-2">
                                    <span className={`px-2 py-1 rounded text-sm ${
                                        employee.status === 'active'
                                            ? 'bg-green-100 text-green-800'
                                            : 'bg-red-100 text-red-800'
                                    }`}>
                                        {employee.status}
                                    </span>
                                </td>
                                <td className="border p-2">
                                    {/* Boolean badge showing linked account */}
                                    {employee.user ? (
                                        <span className="bg-blue-100 text-blue-800 px-2 py-1 rounded text-sm">Yes</span>
                                    ) : (
                                        <span className="bg-gray-100 text-gray-600 px-2 py-1 rounded text-sm">No</span>
                                    )}
                                </td>
                                <td className="border p-2 space-x-2">
                                    <Link href={`/employees/${employee.id}`} className="text-blue-600 hover:underline">View</Link>
                                    <Link href={`/employees/${employee.id}/edit`} className="text-yellow-600 hover:underline">Edit</Link>
                                    <button onClick={() => handleDelete(employee.id)} className="text-red-600 hover:underline">Delete</button>
                                </td>
                            </tr>
                        ))}
                    </tbody>
                </table>

                {/* Pagination links */}
                <div className="mt-4 flex gap-2">
                    {employees.links.map((link, index) => (
                        <Link
                            key={index}
                            href={link.url ?? '#'}
                            className={`px-3 py-1 border rounded ${
                                link.active ? 'bg-blue-600 text-white' : 'hover:bg-gray-100'
                            }`}
                            dangerouslySetInnerHTML={{ __html: link.label }}
                        />
                    ))}
                </div>
            </div>
        </>
    );
}

What does this page do?

  • The Props interface defines the shape of data the controller passes to this page.
  • The table renders each employee's ID, full name, department, position, status badge, and whether they have a linked user account.
  • handleDelete uses router.delete() from Inertia to send a DELETE request without a full page reload.
  • Pagination links render at the bottom using Laravel's built-in paginator data.
  • Next, create resources/js/Pages/Employees/Show.tsx with the following code:
import { Head, Link } from '@inertiajs/react';

interface Department {
    id: number;
    name: string;
}

interface User {
    id: number;
    email: string;
}

interface Employee {
    id: number;
    employee_id: string;
    first_name: string;
    last_name: string;
    email: string;
    phone: string | null;
    position: string;
    status: string;
    hire_date: string;
    salary: number;
    department: Department | null;
    user: User | null;
}

interface Props {
    employee: Employee;
    canViewSalary: boolean;
}

export default function Show({ employee, canViewSalary }: Props) {
    return (
        <>
            <Head title={`${employee.first_name} ${employee.last_name}`} />
            <div className="p-6 max-w-2xl">
                <div className="flex justify-between items-center mb-6">
                    <h1 className="text-2xl font-bold">
                        {employee.first_name} {employee.last_name}
                    </h1>
                    <Link
                        href={`/employees/${employee.id}/edit`}
                        className="bg-yellow-500 text-white px-4 py-2 rounded hover:bg-yellow-600"
                    >
                        Edit
                    </Link>
                </div>

                {/* Employee detail fields */}
                <dl className="grid grid-cols-2 gap-4">
                    <div>
                        <dt className="font-semibold text-gray-600">Employee ID</dt>
                        <dd>{employee.employee_id}</dd>
                    </div>
                    <div>
                        <dt className="font-semibold text-gray-600">Email</dt>
                        <dd>{employee.email}</dd>
                    </div>
                    <div>
                        <dt className="font-semibold text-gray-600">Phone</dt>
                        <dd>{employee.phone ?? 'Not provided'}</dd>
                    </div>
                    <div>
                        <dt className="font-semibold text-gray-600">Position</dt>
                        <dd>{employee.position}</dd>
                    </div>
                    <div>
                        <dt className="font-semibold text-gray-600">Department</dt>
                        <dd>{employee.department?.name ?? 'Unassigned'}</dd>
                    </div>
                    <div>
                        <dt className="font-semibold text-gray-600">Hire Date</dt>
                        <dd>{employee.hire_date}</dd>
                    </div>
                    <div>
                        <dt className="font-semibold text-gray-600">Status</dt>
                        <dd>
                            <span className={`px-2 py-1 rounded text-sm ${
                                employee.status === 'active'
                                    ? 'bg-green-100 text-green-800'
                                    : 'bg-red-100 text-red-800'
                            }`}>
                                {employee.status}
                            </span>
                        </dd>
                    </div>

                    {/* Salary only visible to admins */}
                    {canViewSalary && (
                        <div>
                            <dt className="font-semibold text-gray-600">Salary</dt>
                            <dd>${employee.salary.toLocaleString()}</dd>
                        </div>
                    )}

                    <div>
                        <dt className="font-semibold text-gray-600">Linked Account</dt>
                        <dd>
                            {employee.user
                                ? employee.user.email
                                : 'No linked user account'}
                        </dd>
                    </div>
                </dl>

                <div className="mt-6">
                    <Link href="/employees" className="text-blue-600 hover:underline">
                        Back to Employees
                    </Link>
                </div>
            </div>
        </>
    );
}

Why is salary conditionally rendered?

The controller passes a canViewSalary boolean based on whether the current user has the admin role. The React component checks this prop before rendering the salary field.

This keeps sensitive compensation data hidden from non-admin users while still using the same page component for everyone.

  • Save both Index.tsx and Show.tsx.
  • Run npm run build to compile the new pages, then start the dev server with composer run dev.
  • Open your browser and navigate to /employees. You should see a table listing the sample employees seeded earlier, each showing their department and a Yes/No badge for account linking.

Seeing a blank page or component not found error?

  • Check that the file path matches exactly: resources/js/Pages/Employees/Index.tsx. Inertia resolves page components by path.
  • Make sure you ran npm run build after creating the new files.
  • Confirm your controller returns Inertia::render('Employees/Index', ...) with the exact path matching your file structure.

help me fix my Inertia page not rendering

Handle user-employee linking

The Create and Edit forms include a dropdown to optionally link an employee to a user account. This is the key feature that proves the separated architecture: employees can exist without login accounts, and user accounts can exist without employee records.

  • Create resources/js/Pages/Employees/Create.tsx with the following form code:
import { Head, Link, useForm } from '@inertiajs/react';
import { FormEvent } from 'react';

interface Department {
    id: number;
    name: string;
}

interface User {
    id: number;
    email: string;
}

interface Props {
    departments: Department[];
    availableUsers: User[];
}

export default function Create({ departments, availableUsers }: Props) {
    // Initialize form with all employee fields
    const { data, setData, post, processing, errors } = useForm({
        employee_id: '',
        first_name: '',
        last_name: '',
        email: '',
        phone: '',
        position: '',
        department_id: '',
        hire_date: '',
        salary: '',
        status: 'active',
        user_id: '',  // Optional link to user account
    });

    function handleSubmit(e: FormEvent) {
        e.preventDefault();
        post('/employees');
    }

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

                <form onSubmit={handleSubmit} className="space-y-4">
                    {/* Employee ID */}
                    <div>
                        <label className="block font-medium">Employee ID</label>
                        <input
                            type="text"
                            value={data.employee_id}
                            onChange={e => setData('employee_id', e.target.value)}
                            className="w-full border rounded p-2"
                        />
                        {errors.employee_id && <p className="text-red-600 text-sm">{errors.employee_id}</p>}
                    </div>

                    {/* First and Last Name */}
                    <div className="grid grid-cols-2 gap-4">
                        <div>
                            <label className="block font-medium">First Name</label>
                            <input
                                type="text"
                                value={data.first_name}
                                onChange={e => setData('first_name', e.target.value)}
                                className="w-full border rounded p-2"
                            />
                            {errors.first_name && <p className="text-red-600 text-sm">{errors.first_name}</p>}
                        </div>
                        <div>
                            <label className="block font-medium">Last Name</label>
                            <input
                                type="text"
                                value={data.last_name}
                                onChange={e => setData('last_name', e.target.value)}
                                className="w-full border rounded p-2"
                            />
                            {errors.last_name && <p className="text-red-600 text-sm">{errors.last_name}</p>}
                        </div>
                    </div>

                    {/* Email and Phone */}
                    <div className="grid grid-cols-2 gap-4">
                        <div>
                            <label className="block font-medium">Email</label>
                            <input
                                type="email"
                                value={data.email}
                                onChange={e => setData('email', e.target.value)}
                                className="w-full border rounded p-2"
                            />
                            {errors.email && <p className="text-red-600 text-sm">{errors.email}</p>}
                        </div>
                        <div>
                            <label className="block font-medium">Phone</label>
                            <input
                                type="text"
                                value={data.phone}
                                onChange={e => setData('phone', e.target.value)}
                                className="w-full border rounded p-2"
                            />
                        </div>
                    </div>

                    {/* Position */}
                    <div>
                        <label className="block font-medium">Position</label>
                        <input
                            type="text"
                            value={data.position}
                            onChange={e => setData('position', e.target.value)}
                            className="w-full border rounded p-2"
                        />
                        {errors.position && <p className="text-red-600 text-sm">{errors.position}</p>}
                    </div>

                    {/* Department dropdown */}
                    <div>
                        <label className="block font-medium">Department</label>
                        <select
                            value={data.department_id}
                            onChange={e => setData('department_id', e.target.value)}
                            className="w-full border rounded p-2"
                        >
                            <option value="">Select Department</option>
                            {departments.map(dept => (
                                <option key={dept.id} value={dept.id}>{dept.name}</option>
                            ))}
                        </select>
                        {errors.department_id && <p className="text-red-600 text-sm">{errors.department_id}</p>}
                    </div>

                    {/* Hire Date and Salary */}
                    <div className="grid grid-cols-2 gap-4">
                        <div>
                            <label className="block font-medium">Hire Date</label>
                            <input
                                type="date"
                                value={data.hire_date}
                                onChange={e => setData('hire_date', e.target.value)}
                                className="w-full border rounded p-2"
                            />
                            {errors.hire_date && <p className="text-red-600 text-sm">{errors.hire_date}</p>}
                        </div>
                        <div>
                            <label className="block font-medium">Salary</label>
                            <input
                                type="number"
                                value={data.salary}
                                onChange={e => setData('salary', e.target.value)}
                                className="w-full border rounded p-2"
                            />
                            {errors.salary && <p className="text-red-600 text-sm">{errors.salary}</p>}
                        </div>
                    </div>

                    {/* Status */}
                    <div>
                        <label className="block font-medium">Status</label>
                        <select
                            value={data.status}
                            onChange={e => setData('status', e.target.value)}
                            className="w-full border rounded p-2"
                        >
                            <option value="active">Active</option>
                            <option value="inactive">Inactive</option>
                            <option value="terminated">Terminated</option>
                        </select>
                    </div>

                    {/* Optional: Link to User Account */}
                    <div>
                        <label className="block font-medium">Link to User Account (Optional)</label>
                        <select
                            value={data.user_id}
                            onChange={e => setData('user_id', e.target.value)}
                            className="w-full border rounded p-2"
                        >
                            <option value="">No linked account</option>
                            {availableUsers.map(user => (
                                <option key={user.id} value={user.id}>{user.email}</option>
                            ))}
                        </select>
                        <p className="text-sm text-gray-500 mt-1">
                            Only users without an existing employee link are shown.
                        </p>
                    </div>

                    {/* Submit */}
                    <div className="flex gap-4">
                        <button
                            type="submit"
                            disabled={processing}
                            className="bg-blue-600 text-white px-6 py-2 rounded hover:bg-blue-700 disabled:opacity-50"
                        >
                            Create Employee
                        </button>
                        <Link href="/employees" className="px-6 py-2 border rounded hover:bg-gray-50">
                            Cancel
                        </Link>
                    </div>
                </form>
            </div>
        </>
    );
}

How does the linking dropdown work?

The controller passes availableUsers containing only users who are not already linked to an employee record. This prevents accidentally linking one user to two different employees.

When the form submits with a user_id selected, the controller saves that value on the employee record, creating the link. Leaving it blank creates an employee without a login account.

  • Now create resources/js/Pages/Employees/Edit.tsx with the pre-populated form and unlink option:
import { Head, Link, useForm } from '@inertiajs/react';
import { FormEvent } from 'react';

interface Department {
    id: number;
    name: string;
}

interface User {
    id: number;
    email: string;
}

interface Employee {
    id: number;
    employee_id: string;
    first_name: string;
    last_name: string;
    email: string;
    phone: string | null;
    position: string;
    department_id: number;
    hire_date: string;
    salary: number;
    status: string;
    user_id: number | null;
}

interface Props {
    employee: Employee;
    departments: Department[];
    availableUsers: User[];
}

export default function Edit({ employee, departments, availableUsers }: Props) {
    // Pre-populate form with existing employee data
    const { data, setData, put, processing, errors } = useForm({
        employee_id: employee.employee_id,
        first_name: employee.first_name,
        last_name: employee.last_name,
        email: employee.email,
        phone: employee.phone ?? '',
        position: employee.position,
        department_id: String(employee.department_id),
        hire_date: employee.hire_date,
        salary: String(employee.salary),
        status: employee.status,
        user_id: employee.user_id ? String(employee.user_id) : '',
    });

    function handleSubmit(e: FormEvent) {
        e.preventDefault();
        put(`/employees/${employee.id}`);
    }

    // Unlink user without deleting either record
    function handleUnlink() {
        setData('user_id', '');
    }

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

                <form onSubmit={handleSubmit} className="space-y-4">
                    {/* Employee ID */}
                    <div>
                        <label className="block font-medium">Employee ID</label>
                        <input
                            type="text"
                            value={data.employee_id}
                            onChange={e => setData('employee_id', e.target.value)}
                            className="w-full border rounded p-2"
                        />
                        {errors.employee_id && <p className="text-red-600 text-sm">{errors.employee_id}</p>}
                    </div>

                    {/* First and Last Name */}
                    <div className="grid grid-cols-2 gap-4">
                        <div>
                            <label className="block font-medium">First Name</label>
                            <input
                                type="text"
                                value={data.first_name}
                                onChange={e => setData('first_name', e.target.value)}
                                className="w-full border rounded p-2"
                            />
                            {errors.first_name && <p className="text-red-600 text-sm">{errors.first_name}</p>}
                        </div>
                        <div>
                            <label className="block font-medium">Last Name</label>
                            <input
                                type="text"
                                value={data.last_name}
                                onChange={e => setData('last_name', e.target.value)}
                                className="w-full border rounded p-2"
                            />
                            {errors.last_name && <p className="text-red-600 text-sm">{errors.last_name}</p>}
                        </div>
                    </div>

                    {/* Email and Phone */}
                    <div className="grid grid-cols-2 gap-4">
                        <div>
                            <label className="block font-medium">Email</label>
                            <input
                                type="email"
                                value={data.email}
                                onChange={e => setData('email', e.target.value)}
                                className="w-full border rounded p-2"
                            />
                            {errors.email && <p className="text-red-600 text-sm">{errors.email}</p>}
                        </div>
                        <div>
                            <label className="block font-medium">Phone</label>
                            <input
                                type="text"
                                value={data.phone}
                                onChange={e => setData('phone', e.target.value)}
                                className="w-full border rounded p-2"
                            />
                        </div>
                    </div>

                    {/* Position */}
                    <div>
                        <label className="block font-medium">Position</label>
                        <input
                            type="text"
                            value={data.position}
                            onChange={e => setData('position', e.target.value)}
                            className="w-full border rounded p-2"
                        />
                        {errors.position && <p className="text-red-600 text-sm">{errors.position}</p>}
                    </div>

                    {/* Department dropdown */}
                    <div>
                        <label className="block font-medium">Department</label>
                        <select
                            value={data.department_id}
                            onChange={e => setData('department_id', e.target.value)}
                            className="w-full border rounded p-2"
                        >
                            <option value="">Select Department</option>
                            {departments.map(dept => (
                                <option key={dept.id} value={dept.id}>{dept.name}</option>
                            ))}
                        </select>
                        {errors.department_id && <p className="text-red-600 text-sm">{errors.department_id}</p>}
                    </div>

                    {/* Hire Date and Salary */}
                    <div className="grid grid-cols-2 gap-4">
                        <div>
                            <label className="block font-medium">Hire Date</label>
                            <input
                                type="date"
                                value={data.hire_date}
                                onChange={e => setData('hire_date', e.target.value)}
                                className="w-full border rounded p-2"
                            />
                            {errors.hire_date && <p className="text-red-600 text-sm">{errors.hire_date}</p>}
                        </div>
                        <div>
                            <label className="block font-medium">Salary</label>
                            <input
                                type="number"
                                value={data.salary}
                                onChange={e => setData('salary', e.target.value)}
                                className="w-full border rounded p-2"
                            />
                            {errors.salary && <p className="text-red-600 text-sm">{errors.salary}</p>}
                        </div>
                    </div>

                    {/* Status */}
                    <div>
                        <label className="block font-medium">Status</label>
                        <select
                            value={data.status}
                            onChange={e => setData('status', e.target.value)}
                            className="w-full border rounded p-2"
                        >
                            <option value="active">Active</option>
                            <option value="inactive">Inactive</option>
                            <option value="terminated">Terminated</option>
                        </select>
                    </div>

                    {/* Link to User Account with unlink option */}
                    <div>
                        <label className="block font-medium">Link to User Account (Optional)</label>
                        <div className="flex gap-2">
                            <select
                                value={data.user_id}
                                onChange={e => setData('user_id', e.target.value)}
                                className="flex-1 border rounded p-2"
                            >
                                <option value="">No linked account</option>
                                {availableUsers.map(user => (
                                    <option key={user.id} value={user.id}>{user.email}</option>
                                ))}
                            </select>
                            {/* Unlink button appears only when a user is linked */}
                            {data.user_id && (
                                <button
                                    type="button"
                                    onClick={handleUnlink}
                                    className="px-3 py-2 bg-red-100 text-red-700 rounded hover:bg-red-200"
                                >
                                    Unlink
                                </button>
                            )}
                        </div>
                        <p className="text-sm text-gray-500 mt-1">
                            Unlinking removes the connection without deleting either record.
                        </p>
                    </div>

                    {/* Submit */}
                    <div className="flex gap-4">
                        <button
                            type="submit"
                            disabled={processing}
                            className="bg-blue-600 text-white px-6 py-2 rounded hover:bg-blue-700 disabled:opacity-50"
                        >
                            Update Employee
                        </button>
                        <Link href="/employees" className="px-6 py-2 border rounded hover:bg-gray-50">
                            Cancel
                        </Link>
                    </div>
                </form>
            </div>
        </>
    );
}

How does unlinking work?

The handleUnlink function sets user_id to an empty string in the form state. When the form submits, the controller saves null for that field, breaking the link between the employee record and the user account.

Neither the employee nor the user gets deleted. The employee stays in the HR system, and the user account keeps its login access. They simply stop being connected to each other.

  • Save both Create.tsx and Edit.tsx.
  • Run npm run build again to compile the new pages.
  • Navigate to /employees/create in your browser. You should see a form with all employee fields and a "Link to User Account" dropdown showing available users.
  • Try creating a new employee, then editing an existing one. Verify that the link/unlink dropdown works as expected.

Form submits but nothing happens?

  • Check the browser console for validation errors. Inertia returns 422 responses for failed validation, and the errors object should populate with messages.
  • Make sure your Employee model has user_id in its $fillable array. Without it, Laravel silently ignores the field on mass assignment.
  • Verify the useForm import comes from @inertiajs/react, not another package.

help me debug my employee form submission

✔️ Awesome, I've got everything!

Great. Double check you have saved all four page files and the controller.

ⓧ I'd like to double check the full code

Here is the complete EmployeeController.php:

<?php

namespace App\Http\Controllers;

use App\Models\Employee;
use App\Models\Department;
use App\Models\User;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;

class EmployeeController extends Controller
{
    public function index(): Response
    {
        $employees = Employee::with(['department', 'user'])
            ->paginate(15);

        return Inertia::render('Employees/Index', [
            'employees' => $employees,
        ]);
    }

    public function create(): Response
    {
        $departments = Department::all();
        $availableUsers = User::whereDoesntHave('employee')->get();

        return Inertia::render('Employees/Create', [
            'departments' => $departments,
            'availableUsers' => $availableUsers,
        ]);
    }

    public function store(Request $request)
    {
        $validated = $request->validate([
            'employee_id' => 'required|string|unique:employees',
            'first_name' => 'required|string|max:255',
            'last_name' => 'required|string|max:255',
            'email' => 'required|email|max:255',
            'phone' => 'nullable|string|max:20',
            'position' => 'required|string|max:255',
            'department_id' => 'required|exists:departments,id',
            'hire_date' => 'required|date',
            'salary' => 'required|numeric|min:0',
            'status' => 'required|in:active,inactive,terminated',
            'user_id' => 'nullable|exists:users,id',
        ]);

        Employee::create($validated);

        return redirect()->route('employees.index')
            ->with('success', 'Employee created successfully.');
    }

    public function show(Employee $employee): Response
    {
        $employee->load(['department', 'user']);

        return Inertia::render('Employees/Show', [
            'employee' => $employee,
            'canViewSalary' => auth()->user()->hasRole('admin'),
        ]);
    }

    public function edit(Employee $employee): Response
    {
        $departments = Department::all();

        $availableUsers = User::where(function ($query) use ($employee) {
            $query->whereDoesntHave('employee')
                ->orWhere('id', $employee->user_id);
        })->get();

        return Inertia::render('Employees/Edit', [
            'employee' => $employee,
            'departments' => $departments,
            'availableUsers' => $availableUsers,
        ]);
    }

    public function update(Request $request, Employee $employee)
    {
        $validated = $request->validate([
            'employee_id' => 'required|string|unique:employees,employee_id,' . $employee->id,
            'first_name' => 'required|string|max:255',
            'last_name' => 'required|string|max:255',
            'email' => 'required|email|max:255',
            'phone' => 'nullable|string|max:20',
            'position' => 'required|string|max:255',
            'department_id' => 'required|exists:departments,id',
            'hire_date' => 'required|date',
            'salary' => 'required|numeric|min:0',
            'status' => 'required|in:active,inactive,terminated',
            'user_id' => 'nullable|exists:users,id',
        ]);

        $employee->update($validated);

        return redirect()->route('employees.index')
            ->with('success', 'Employee updated successfully.');
    }

    public function destroy(Employee $employee)
    {
        $employee->delete();

        return redirect()->route('employees.index')
            ->with('success', 'Employee deleted successfully.');
    }
}

Here is the route registration in routes/web.php:

use App\Http\Controllers\EmployeeController;

Route::resource('employees', EmployeeController::class)
    ->middleware('auth')
    ->middlewareFor(['create', 'store', 'edit', 'update', 'destroy'], 'permission:manage employees');

See the full page files above for Index.tsx, Show.tsx, Create.tsx, and Edit.tsx.

Your employee module is fully operational. Next up, you will build the dynamic role management system so admins can create and configure roles directly from the browser.

Build the Dynamic Role Management CRUD

In the previous step, you built a complete Employee CRUD that lets admins manage employee records and link them to user accounts.

Now it's time to build the admin panel that controls who can do what. Right now, your system has roles (admin, manager, employee) seeded into the database, but there's no way for admins to create new roles or adjust permissions from the UI. In this step, you'll build a dynamic role management system so admins can create custom roles, assign permissions using checkboxes, and modify access levels without touching code.

In this step, get ready to:
  • Create a RoleController with full CRUD operations for managing roles and permissions.
  • Build React pages for listing, creating, and editing roles with grouped permission checkboxes.
  • Protect all role management routes with permission-based middleware.
Create the Role controller

The RoleController handles all CRUD operations for roles. It fetches roles with their permission and user counts, groups permissions by module for the form, and uses syncPermissions() to update a role's permissions in a single call.

  • Create the RoleController by running this command in your terminal:
php artisan make:controller RoleController
  • Open app/Http/Controllers/RoleController.php and replace its contents with the following code:
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Inertia\Inertia;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;

class RoleController extends Controller
{
    // Display all roles with permission and user counts
    public function index()
    {
        $roles = Role::withCount(['permissions', 'users'])->get();

        return Inertia::render('Roles/Index', [
            'roles' => $roles,
        ]);
    }

    // Show the create role form with grouped permissions
    public function create()
    {
        $groupedPermissions = $this->getGroupedPermissions();

        return Inertia::render('Roles/Create', [
            'groupedPermissions' => $groupedPermissions,
        ]);
    }

    // Validate and store a new role, then sync permissions
    public function store(Request $request)
    {
        $validated = $request->validate([
            'name' => 'required|string|unique:roles,name',
            'permissions' => 'array',
        ]);

        $role = Role::create(['name' => $validated['name']]);
        $role->syncPermissions($validated['permissions'] ?? []);

        return redirect()->route('roles.index')
            ->with('success', 'Role created successfully.');
    }

    // Show the edit form with existing role data and grouped permissions
    public function edit(Role $role)
    {
        $groupedPermissions = $this->getGroupedPermissions();

        return Inertia::render('Roles/Edit', [
            'role' => $role->load('permissions'),
            'groupedPermissions' => $groupedPermissions,
        ]);
    }

    // Validate and update the role name and sync permissions
    public function update(Request $request, Role $role)
    {
        $validated = $request->validate([
            'name' => 'required|string|unique:roles,name,' . $role->id,
            'permissions' => 'array',
        ]);

        $role->update(['name' => $validated['name']]);
        $role->syncPermissions($validated['permissions'] ?? []);

        return redirect()->route('roles.index')
            ->with('success', 'Role updated successfully.');
    }

    // Delete the role
    public function destroy(Role $role)
    {
        $role->delete();

        return redirect()->route('roles.index')
            ->with('success', 'Role deleted successfully.');
    }

    // Group permissions by module category for the form display
    private function getGroupedPermissions()
    {
        $permissions = Permission::all();

        $modules = [
            'Employee Management' => ['view employees', 'manage employees'],
            'Department Management' => ['view departments', 'manage departments'],
            'Project Management' => ['view projects', 'manage projects'],
            'Task Management' => ['view tasks', 'create tasks', 'edit tasks', 'delete tasks'],
            'System Administration' => ['manage users', 'manage roles'],
        ];

        $grouped = [];
        foreach ($modules as $module => $permissionNames) {
            $grouped[$module] = $permissions->filter(function ($permission) use ($permissionNames) {
                return in_array($permission->name, $permissionNames);
            })->values();
        }

        return $grouped;
    }
}

What does this controller do?

  • index() fetches every role along with counts of how many permissions and users belong to each one, using Eloquent's withCount() method.
  • store() and update() both validate the role name for uniqueness, then call syncPermissions() from the Spatie package to replace all existing permissions with the selected set.
  • getGroupedPermissions() is a private helper that organizes all permissions into five module categories. This structure powers the grouped checkbox UI on the create and edit forms.
  • Save RoleController.php.

Seeing a class not found error?

Make sure the use Spatie\Permission\Models\Role and use Spatie\Permission\Models\Permission import statements are at the top of the file. If you installed the Spatie package in a previous step, these classes should resolve automatically.

help me debug a class not found error in my RoleController

Create the Role management React pages

You need three React pages for the role management UI: an index page listing all roles, a create page with permission checkboxes, and an edit page that pre-populates the form.

  • Create the resources/js/Pages/Roles directory by running:
mkdir resources/js/Pages/Roles
  • Create the file resources/js/Pages/Roles/Index.tsx and add the following code:
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, Link, router } from '@inertiajs/react';

// Define the shape of a role with counts
interface Role {
    id: number;
    name: string;
    permissions_count: number;
    users_count: number;
}

interface Props {
    roles: Role[];
}

export default function Index({ roles }: Props) {
    // Handle role deletion with confirmation
    const deleteRole = (id: number) => {
        if (confirm('Are you sure you want to delete this role?')) {
            router.delete(route('roles.destroy', id));
        }
    };

    return (
        <AuthenticatedLayout>
            <Head title="Roles" />

            <div className="p-6">
                <div className="flex justify-between items-center mb-6">
                    <h1 className="text-2xl font-bold">Roles</h1>
                    <Link
                        href={route('roles.create')}
                        className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
                    >
                        Create Role
                    </Link>
                </div>

                {/* Roles table with name, permissions count, and user count */}
                <table className="w-full border-collapse border">
                    <thead>
                        <tr className="bg-gray-100">
                            <th className="border p-3 text-left">Role Name</th>
                            <th className="border p-3 text-left">Permissions</th>
                            <th className="border p-3 text-left">Users</th>
                            <th className="border p-3 text-left">Actions</th>
                        </tr>
                    </thead>
                    <tbody>
                        {roles.map((role) => (
                            <tr key={role.id}>
                                <td className="border p-3 font-medium">{role.name}</td>
                                <td className="border p-3">{role.permissions_count}</td>
                                <td className="border p-3">{role.users_count}</td>
                                <td className="border p-3 space-x-2">
                                    <Link
                                        href={route('roles.edit', role.id)}
                                        className="text-blue-500 hover:underline"
                                    >
                                        Edit
                                    </Link>
                                    <button
                                        onClick={() => deleteRole(role.id)}
                                        className="text-red-500 hover:underline"
                                    >
                                        Delete
                                    </button>
                                </td>
                            </tr>
                        ))}
                    </tbody>
                </table>
            </div>
        </AuthenticatedLayout>
    );
}

What does the Index page do?

The Index page renders a table showing each role's name, how many permissions it has, and how many users are assigned to it. The deleteRole function uses Inertia.js's router.delete() to send a DELETE request without a full page reload.

  • Save Index.tsx.
  • Create the file resources/js/Pages/Roles/Create.tsx and add the following code:
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, useForm, Link } from '@inertiajs/react';

// Define permission and props interfaces
interface Permission {
    id: number;
    name: string;
}

interface Props {
    groupedPermissions: Record<string, Permission[]>;
}

export default function Create({ groupedPermissions }: Props) {
    // Initialize form with empty name and permissions array
    const { data, setData, post, processing, errors } = useForm({
        name: '',
        permissions: [] as string[],
    });

    // Toggle a permission in the selected array
    const togglePermission = (permissionName: string) => {
        const updated = data.permissions.includes(permissionName)
            ? data.permissions.filter((p) => p !== permissionName)
            : [...data.permissions, permissionName];
        setData('permissions', updated);
    };

    const submit = (e: React.FormEvent) => {
        e.preventDefault();
        post(route('roles.store'));
    };

    return (
        <AuthenticatedLayout>
            <Head title="Create Role" />

            <div className="p-6 max-w-2xl">
                <h1 className="text-2xl font-bold mb-6">Create Role</h1>

                <form onSubmit={submit}>
                    {/* Role name input */}
                    <div className="mb-4">
                        <label className="block font-medium mb-1">Role Name</label>
                        <input
                            type="text"
                            value={data.name}
                            onChange={(e) => setData('name', e.target.value)}
                            className="w-full border rounded p-2"
                        />
                        {errors.name && (
                            <p className="text-red-500 text-sm mt-1">{errors.name}</p>
                        )}
                    </div>

                    {/* Permission checkboxes grouped by module */}
                    <div className="mb-6">
                        <h2 className="font-medium mb-3">Permissions</h2>
                        {Object.entries(groupedPermissions).map(([module, permissions]) => (
                            <div key={module} className="mb-4">
                                <h3 className="font-semibold text-gray-700 mb-2">
                                    {module}
                                </h3>
                                <div className="grid grid-cols-2 gap-2 ml-4">
                                    {permissions.map((permission) => (
                                        <label
                                            key={permission.id}
                                            className="flex items-center gap-2"
                                        >
                                            <input
                                                type="checkbox"
                                                checked={data.permissions.includes(
                                                    permission.name
                                                )}
                                                onChange={() =>
                                                    togglePermission(permission.name)
                                                }
                                            />
                                            {permission.name}
                                        </label>
                                    ))}
                                </div>
                            </div>
                        ))}
                    </div>

                    <div className="flex gap-4">
                        <button
                            type="submit"
                            disabled={processing}
                            className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
                        >
                            Create Role
                        </button>
                        <Link
                            href={route('roles.index')}
                            className="px-4 py-2 text-gray-600 hover:underline"
                        >
                            Cancel
                        </Link>
                    </div>
                </form>
            </div>
        </AuthenticatedLayout>
    );
}

What does the Create page do?

  • useForm() from @inertiajs/react manages form state, validation errors, and the submission lifecycle in one hook.
  • togglePermission() adds or removes a permission name from the selected array whenever a checkbox changes.
  • The groupedPermissions prop arrives pre-organized by module (Employee Management, Department Management, etc.), so the UI renders one section of checkboxes per module.
  • Save Create.tsx.
  • Create the file resources/js/Pages/Roles/Edit.tsx and add the following code:
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, useForm, Link } from '@inertiajs/react';

// Define permission, role, and props interfaces
interface Permission {
    id: number;
    name: string;
}

interface Role {
    id: number;
    name: string;
    permissions: Permission[];
}

interface Props {
    role: Role;
    groupedPermissions: Record<string, Permission[]>;
}

export default function Edit({ role, groupedPermissions }: Props) {
    // Pre-populate form with existing role data
    const { data, setData, put, processing, errors } = useForm({
        name: role.name,
        permissions: role.permissions.map((p) => p.name),
    });

    // Toggle a permission in the selected array
    const togglePermission = (permissionName: string) => {
        const updated = data.permissions.includes(permissionName)
            ? data.permissions.filter((p) => p !== permissionName)
            : [...data.permissions, permissionName];
        setData('permissions', updated);
    };

    const submit = (e: React.FormEvent) => {
        e.preventDefault();
        put(route('roles.update', role.id));
    };

    return (
        <AuthenticatedLayout>
            <Head title="Edit Role" />

            <div className="p-6 max-w-2xl">
                <h1 className="text-2xl font-bold mb-6">Edit Role</h1>

                <form onSubmit={submit}>
                    {/* Role name input pre-filled */}
                    <div className="mb-4">
                        <label className="block font-medium mb-1">Role Name</label>
                        <input
                            type="text"
                            value={data.name}
                            onChange={(e) => setData('name', e.target.value)}
                            className="w-full border rounded p-2"
                        />
                        {errors.name && (
                            <p className="text-red-500 text-sm mt-1">{errors.name}</p>
                        )}
                    </div>

                    {/* Permission checkboxes with existing permissions pre-checked */}
                    <div className="mb-6">
                        <h2 className="font-medium mb-3">Permissions</h2>
                        {Object.entries(groupedPermissions).map(([module, permissions]) => (
                            <div key={module} className="mb-4">
                                <h3 className="font-semibold text-gray-700 mb-2">
                                    {module}
                                </h3>
                                <div className="grid grid-cols-2 gap-2 ml-4">
                                    {permissions.map((permission) => (
                                        <label
                                            key={permission.id}
                                            className="flex items-center gap-2"
                                        >
                                            <input
                                                type="checkbox"
                                                checked={data.permissions.includes(
                                                    permission.name
                                                )}
                                                onChange={() =>
                                                    togglePermission(permission.name)
                                                }
                                            />
                                            {permission.name}
                                        </label>
                                    ))}
                                </div>
                            </div>
                        ))}
                    </div>

                    <div className="flex gap-4">
                        <button
                            type="submit"
                            disabled={processing}
                            className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
                        >
                            Update Role
                        </button>
                        <Link
                            href={route('roles.index')}
                            className="px-4 py-2 text-gray-600 hover:underline"
                        >
                            Cancel
                        </Link>
                    </div>
                </form>
            </div>
        </AuthenticatedLayout>
    );
}

How does the Edit page differ from Create?

The Edit page receives a role prop with the existing role's name and permissions already loaded. The useForm hook initializes data.name and data.permissions from this prop, so all current permissions appear pre-checked.

On submit, it calls put() instead of post() to send a PUT request to the update route.

  • Save Edit.tsx.

Seeing a blank page when you navigate to a Roles page?

Check that your file names match the Inertia render path exactly. The controller calls Inertia::render('Roles/Index'), so the file must be at resources/js/Pages/Roles/Index.tsx with matching capitalization.

help me debug why my Inertia page is showing a blank screen

Add permission-based route protection

The final piece is route registration with middleware. You'll wrap all role management routes with the permission:manage roles middleware so only users who have that specific permission can access the role CRUD.

  • Open routes/web.php.
  • Find the existing employee resource routes.
  • Add the following role routes below the employee routes:
// Role management routes - restricted to users with 'manage roles' permission
Route::middleware(['auth', 'permission:manage roles'])->group(function () {
    Route::resource('roles', RoleController::class)->except(['show']);
});

What does this route group do?

  • The auth middleware ensures only logged-in users can access these routes.
  • The permission:manage roles middleware (from the Spatie package) checks that the authenticated user has the "manage roles" permission before allowing access.
  • except(['show']) skips the show route because you don't need a separate detail page for roles. Editing serves that purpose.
  • Make sure to add the import for RoleController at the top of routes/web.php:
use App\Http\Controllers\RoleController;
  • Save routes/web.php.
  • In your terminal, rebuild the frontend and start the dev server by running:
npm run build && composer run dev
  • Log in as the admin user (the one seeded in the previous step).
  • Navigate to /roles in your browser. You should see a table listing the admin, manager, and employee roles with their permission counts.

Getting a 403 Forbidden error?

This means your logged-in user does not have the "manage roles" permission. Make sure you are logged in as the admin user that was seeded with all permissions. If you seeded the database correctly in Step 3, the admin role includes the "manage roles" permission.

help me fix a 403 error on my roles route

✔️ Awesome, I've got everything!

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

ⓧ I'd like to double check the full code

Here is the full RoleController.php:

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Inertia\Inertia;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;

class RoleController extends Controller
{
    public function index()
    {
        $roles = Role::withCount(['permissions', 'users'])->get();

        return Inertia::render('Roles/Index', [
            'roles' => $roles,
        ]);
    }

    public function create()
    {
        $groupedPermissions = $this->getGroupedPermissions();

        return Inertia::render('Roles/Create', [
            'groupedPermissions' => $groupedPermissions,
        ]);
    }

    public function store(Request $request)
    {
        $validated = $request->validate([
            'name' => 'required|string|unique:roles,name',
            'permissions' => 'array',
        ]);

        $role = Role::create(['name' => $validated['name']]);
        $role->syncPermissions($validated['permissions'] ?? []);

        return redirect()->route('roles.index')
            ->with('success', 'Role created successfully.');
    }

    public function edit(Role $role)
    {
        $groupedPermissions = $this->getGroupedPermissions();

        return Inertia::render('Roles/Edit', [
            'role' => $role->load('permissions'),
            'groupedPermissions' => $groupedPermissions,
        ]);
    }

    public function update(Request $request, Role $role)
    {
        $validated = $request->validate([
            'name' => 'required|string|unique:roles,name,' . $role->id,
            'permissions' => 'array',
        ]);

        $role->update(['name' => $validated['name']]);
        $role->syncPermissions($validated['permissions'] ?? []);

        return redirect()->route('roles.index')
            ->with('success', 'Role updated successfully.');
    }

    public function destroy(Role $role)
    {
        $role->delete();

        return redirect()->route('roles.index')
            ->with('success', 'Role deleted successfully.');
    }

    private function getGroupedPermissions()
    {
        $permissions = Permission::all();

        $modules = [
            'Employee Management' => ['view employees', 'manage employees'],
            'Department Management' => ['view departments', 'manage departments'],
            'Project Management' => ['view projects', 'manage projects'],
            'Task Management' => ['view tasks', 'create tasks', 'edit tasks', 'delete tasks'],
            'System Administration' => ['manage users', 'manage roles'],
        ];

        $grouped = [];
        foreach ($modules as $module => $permissionNames) {
            $grouped[$module] = $permissions->filter(function ($permission) use ($permissionNames) {
                return in_array($permission->name, $permissionNames);
            })->values();
        }

        return $grouped;
    }
}

Here is the full Roles/Index.tsx:

import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, Link, router } from '@inertiajs/react';

interface Role {
    id: number;
    name: string;
    permissions_count: number;
    users_count: number;
}

interface Props {
    roles: Role[];
}

export default function Index({ roles }: Props) {
    const deleteRole = (id: number) => {
        if (confirm('Are you sure you want to delete this role?')) {
            router.delete(route('roles.destroy', id));
        }
    };

    return (
        <AuthenticatedLayout>
            <Head title="Roles" />

            <div className="p-6">
                <div className="flex justify-between items-center mb-6">
                    <h1 className="text-2xl font-bold">Roles</h1>
                    <Link
                        href={route('roles.create')}
                        className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
                    >
                        Create Role
                    </Link>
                </div>

                <table className="w-full border-collapse border">
                    <thead>
                        <tr className="bg-gray-100">
                            <th className="border p-3 text-left">Role Name</th>
                            <th className="border p-3 text-left">Permissions</th>
                            <th className="border p-3 text-left">Users</th>
                            <th className="border p-3 text-left">Actions</th>
                        </tr>
                    </thead>
                    <tbody>
                        {roles.map((role) => (
                            <tr key={role.id}>
                                <td className="border p-3 font-medium">{role.name}</td>
                                <td className="border p-3">{role.permissions_count}</td>
                                <td className="border p-3">{role.users_count}</td>
                                <td className="border p-3 space-x-2">
                                    <Link
                                        href={route('roles.edit', role.id)}
                                        className="text-blue-500 hover:underline"
                                    >
                                        Edit
                                    </Link>
                                    <button
                                        onClick={() => deleteRole(role.id)}
                                        className="text-red-500 hover:underline"
                                    >
                                        Delete
                                    </button>
                                </td>
                            </tr>
                        ))}
                    </tbody>
                </table>
            </div>
        </AuthenticatedLayout>
    );
}

Here is the full Roles/Create.tsx:

import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, useForm, Link } from '@inertiajs/react';

interface Permission {
    id: number;
    name: string;
}

interface Props {
    groupedPermissions: Record<string, Permission[]>;
}

export default function Create({ groupedPermissions }: Props) {
    const { data, setData, post, processing, errors } = useForm({
        name: '',
        permissions: [] as string[],
    });

    const togglePermission = (permissionName: string) => {
        const updated = data.permissions.includes(permissionName)
            ? data.permissions.filter((p) => p !== permissionName)
            : [...data.permissions, permissionName];
        setData('permissions', updated);
    };

    const submit = (e: React.FormEvent) => {
        e.preventDefault();
        post(route('roles.store'));
    };

    return (
        <AuthenticatedLayout>
            <Head title="Create Role" />

            <div className="p-6 max-w-2xl">
                <h1 className="text-2xl font-bold mb-6">Create Role</h1>

                <form onSubmit={submit}>
                    <div className="mb-4">
                        <label className="block font-medium mb-1">Role Name</label>
                        <input
                            type="text"
                            value={data.name}
                            onChange={(e) => setData('name', e.target.value)}
                            className="w-full border rounded p-2"
                        />
                        {errors.name && (
                            <p className="text-red-500 text-sm mt-1">{errors.name}</p>
                        )}
                    </div>

                    <div className="mb-6">
                        <h2 className="font-medium mb-3">Permissions</h2>
                        {Object.entries(groupedPermissions).map(([module, permissions]) => (
                            <div key={module} className="mb-4">
                                <h3 className="font-semibold text-gray-700 mb-2">
                                    {module}
                                </h3>
                                <div className="grid grid-cols-2 gap-2 ml-4">
                                    {permissions.map((permission) => (
                                        <label
                                            key={permission.id}
                                            className="flex items-center gap-2"
                                        >
                                            <input
                                                type="checkbox"
                                                checked={data.permissions.includes(
                                                    permission.name
                                                )}
                                                onChange={() =>
                                                    togglePermission(permission.name)
                                                }
                                            />
                                            {permission.name}
                                        </label>
                                    ))}
                                </div>
                            </div>
                        ))}
                    </div>

                    <div className="flex gap-4">
                        <button
                            type="submit"
                            disabled={processing}
                            className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
                        >
                            Create Role
                        </button>
                        <Link
                            href={route('roles.index')}
                            className="px-4 py-2 text-gray-600 hover:underline"
                        >
                            Cancel
                        </Link>
                    </div>
                </form>
            </div>
        </AuthenticatedLayout>
    );
}

Here is the full Roles/Edit.tsx:

import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, useForm, Link } from '@inertiajs/react';

interface Permission {
    id: number;
    name: string;
}

interface Role {
    id: number;
    name: string;
    permissions: Permission[];
}

interface Props {
    role: Role;
    groupedPermissions: Record<string, Permission[]>;
}

export default function Edit({ role, groupedPermissions }: Props) {
    const { data, setData, put, processing, errors } = useForm({
        name: role.name,
        permissions: role.permissions.map((p) => p.name),
    });

    const togglePermission = (permissionName: string) => {
        const updated = data.permissions.includes(permissionName)
            ? data.permissions.filter((p) => p !== permissionName)
            : [...data.permissions, permissionName];
        setData('permissions', updated);
    };

    const submit = (e: React.FormEvent) => {
        e.preventDefault();
        put(route('roles.update', role.id));
    };

    return (
        <AuthenticatedLayout>
            <Head title="Edit Role" />

            <div className="p-6 max-w-2xl">
                <h1 className="text-2xl font-bold mb-6">Edit Role</h1>

                <form onSubmit={submit}>
                    <div className="mb-4">
                        <label className="block font-medium mb-1">Role Name</label>
                        <input
                            type="text"
                            value={data.name}
                            onChange={(e) => setData('name', e.target.value)}
                            className="w-full border rounded p-2"
                        />
                        {errors.name && (
                            <p className="text-red-500 text-sm mt-1">{errors.name}</p>
                        )}
                    </div>

                    <div className="mb-6">
                        <h2 className="font-medium mb-3">Permissions</h2>
                        {Object.entries(groupedPermissions).map(([module, permissions]) => (
                            <div key={module} className="mb-4">
                                <h3 className="font-semibold text-gray-700 mb-2">
                                    {module}
                                </h3>
                                <div className="grid grid-cols-2 gap-2 ml-4">
                                    {permissions.map((permission) => (
                                        <label
                                            key={permission.id}
                                            className="flex items-center gap-2"
                                        >
                                            <input
                                                type="checkbox"
                                                checked={data.permissions.includes(
                                                    permission.name
                                                )}
                                                onChange={() =>
                                                    togglePermission(permission.name)
                                                }
                                            />
                                            {permission.name}
                                        </label>
                                    ))}
                                </div>
                            </div>
                        ))}
                    </div>

                    <div className="flex gap-4">
                        <button
                            type="submit"
                            disabled={processing}
                            className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
                        >
                            Update Role
                        </button>
                        <Link
                            href={route('roles.index')}
                            className="px-4 py-2 text-gray-600 hover:underline"
                        >
                            Cancel
                        </Link>
                    </div>
                </form>
            </div>
        </AuthenticatedLayout>
    );
}

Here are the role routes added to routes/web.php:

use App\Http\Controllers\RoleController;

Route::middleware(['auth', 'permission:manage roles'])->group(function () {
    Route::resource('roles', RoleController::class)->except(['show']);
});

Your role management system is live. Admins can now create custom roles, assign granular permissions using checkboxes, and edit or delete roles on the fly. Next up, you'll build the Department and Project CRUDs that give these roles something meaningful to protect.

Build Department and Project CRUDs

Your employee records and role management system are in place. Now it's time to give those employees somewhere to belong and something to work on.

In this step, you'll build the department and project modules. Departments organize employees into teams, and projects track deliverables with deadlines and assigned members.

In this step, get ready to:
  • Create a department CRUD with admin-only editing and a department head selector.
  • Create a project CRUD with role-based visibility and status tracking.
  • Wire up a many-to-many relationship for assigning employees to projects.
Create the Department controller and pages

Departments are the organizational backbone of the company. Every employee belongs to a department, and every project lives under one. The department CRUD needs to be viewable by all authenticated users, but only admins should be able to create, edit, or delete departments.

  • Generate the DepartmentController by running this command in your terminal:
php artisan make:controller DepartmentController --resource
  • Open app/Http/Controllers/DepartmentController.php and replace its contents with the following:
<?php

namespace App\Http\Controllers;

use App\Models\Department;
use App\Models\Employee;
use Illuminate\Http\Request;
use Inertia\Inertia;

class DepartmentController extends Controller
{
    // List all departments with employee count and head employee name
    public function index()
    {
        $departments = Department::withCount('employees')
            ->with('headEmployee:id,first_name,last_name')
            ->get();

        return Inertia::render('Departments/Index', [
            'departments' => $departments,
        ]);
    }

    // Show the create form with employees list for head selection
    public function create()
    {
        $employees = Employee::select('id', 'first_name', 'last_name')->get();

        return Inertia::render('Departments/Create', [
            'employees' => $employees,
        ]);
    }

    // Store a new department
    public function store(Request $request)
    {
        $validated = $request->validate([
            'name' => 'required|string|max:255',
            'description' => 'nullable|string',
            'head_employee_id' => 'nullable|exists:employees,id',
        ]);

        Department::create($validated);

        return redirect()->route('departments.index')
            ->with('success', 'Department created successfully.');
    }

    // Show the edit form with current data and employees list
    public function edit(Department $department)
    {
        $employees = Employee::select('id', 'first_name', 'last_name')->get();

        return Inertia::render('Departments/Edit', [
            'department' => $department,
            'employees' => $employees,
        ]);
    }

    // Update an existing department
    public function update(Request $request, Department $department)
    {
        $validated = $request->validate([
            'name' => 'required|string|max:255',
            'description' => 'nullable|string',
            'head_employee_id' => 'nullable|exists:employees,id',
        ]);

        $department->update($validated);

        return redirect()->route('departments.index')
            ->with('success', 'Department updated successfully.');
    }

    // Delete a department
    public function destroy(Department $department)
    {
        $department->delete();

        return redirect()->route('departments.index')
            ->with('success', 'Department deleted successfully.');
    }
}

What does this controller do?

The index() method uses Eloquent eager loading to pull employee counts and the head employee's name in a single query. This avoids N+1 performance issues when rendering the table.

The create() and edit() methods both pass the full employees list to the frontend so the admin can pick a department head from a dropdown.

Now create the React pages for departments. You need three files: the index listing, the create form, and the edit form.

  • Create the resources/js/Pages/Departments folder inside your project.
  • Create Index.tsx inside that folder with the following code:
import { Head, Link, router } from '@inertiajs/react';

// Type definitions for department data
interface Department {
    id: number;
    name: string;
    description: string | null;
    employees_count: number;
    head_employee: { id: number; first_name: string; last_name: string } | null;
}

interface Props {
    departments: Department[];
}

export default function Index({ departments }: Props) {
    // Handle department deletion with confirmation
    function handleDelete(id: number) {
        if (confirm('Are you sure you want to delete this department?')) {
            router.delete(`/departments/${id}`);
        }
    }

    return (
        <>
            <Head title="Departments" />
            <div className="p-6">
                <div className="flex justify-between items-center mb-6">
                    <h1 className="text-2xl font-bold">Departments</h1>
                    <Link href="/departments/create" className="btn-primary">
                        Create Department
                    </Link>
                </div>

                {/* Department table with name, employee count, and head */}
                <table className="w-full border-collapse">
                    <thead>
                        <tr>
                            <th className="border p-2 text-left">Name</th>
                            <th className="border p-2 text-left">Employees</th>
                            <th className="border p-2 text-left">Department Head</th>
                            <th className="border p-2 text-left">Actions</th>
                        </tr>
                    </thead>
                    <tbody>
                        {departments.map((dept) => (
                            <tr key={dept.id}>
                                <td className="border p-2">{dept.name}</td>
                                <td className="border p-2">{dept.employees_count}</td>
                                <td className="border p-2">
                                    {dept.head_employee
                                        ? `${dept.head_employee.first_name} ${dept.head_employee.last_name}`
                                        : 'Not assigned'}
                                </td>
                                <td className="border p-2">
                                    <Link href={`/departments/${dept.id}/edit`} className="text-blue-600 mr-2">
                                        Edit
                                    </Link>
                                    <button onClick={() => handleDelete(dept.id)} className="text-red-600">
                                        Delete
                                    </button>
                                </td>
                            </tr>
                        ))}
                    </tbody>
                </table>
            </div>
        </>
    );
}

What does this page display?

The table shows each department's name, the number of employees (from withCount), and the department head's full name. The head_employee relationship is optional, so the code shows "Not assigned" when no head exists.

The handleDelete function sends a DELETE request through Inertia.js's router, which triggers a server-side redirect back to the index with fresh data.

  • Create Create.tsx in the same Departments folder:
import { Head } from '@inertiajs/react';
import { useForm } from '@inertiajs/react';

// Type for employee dropdown options
interface Employee {
    id: number;
    first_name: string;
    last_name: string;
}

interface Props {
    employees: Employee[];
}

export default function Create({ employees }: Props) {
    // Initialize form with empty department fields
    const { data, setData, post, processing, errors } = useForm({
        name: '',
        description: '',
        head_employee_id: '',
    });

    function submit(e: React.FormEvent) {
        e.preventDefault();
        post('/departments');
    }

    return (
        <>
            <Head title="Create Department" />
            <div className="p-6 max-w-lg">
                <h1 className="text-2xl font-bold mb-6">Create Department</h1>
                <form onSubmit={submit} className="space-y-4">
                    {/* Department name field */}
                    <div>
                        <label className="block font-medium">Name</label>
                        <input
                            type="text"
                            value={data.name}
                            onChange={(e) => setData('name', e.target.value)}
                            className="w-full border rounded p-2"
                        />
                        {errors.name && <div className="text-red-600 text-sm">{errors.name}</div>}
                    </div>

                    {/* Optional description field */}
                    <div>
                        <label className="block font-medium">Description</label>
                        <textarea
                            value={data.description}
                            onChange={(e) => setData('description', e.target.value)}
                            className="w-full border rounded p-2"
                        />
                        {errors.description && <div className="text-red-600 text-sm">{errors.description}</div>}
                    </div>

                    {/* Department head dropdown populated from employees list */}
                    <div>
                        <label className="block font-medium">Department Head</label>
                        <select
                            value={data.head_employee_id}
                            onChange={(e) => setData('head_employee_id', e.target.value)}
                            className="w-full border rounded p-2"
                        >
                            <option value="">Select a head (optional)</option>
                            {employees.map((emp) => (
                                <option key={emp.id} value={emp.id}>
                                    {emp.first_name} {emp.last_name}
                                </option>
                            ))}
                        </select>
                        {errors.head_employee_id && <div className="text-red-600 text-sm">{errors.head_employee_id}</div>}
                    </div>

                    <button type="submit" disabled={processing} className="btn-primary">
                        Create Department
                    </button>
                </form>
            </div>
        </>
    );
}

How does the useForm hook work here?

The useForm hook from @inertiajs/react manages form state, submission, error handling, and processing indicators all in one place. When you call post('/departments'), Inertia sends the form data to your Laravel controller and handles the redirect response automatically.

  • Create Edit.tsx in the same folder with the pre-populated form:
import { Head } from '@inertiajs/react';
import { useForm } from '@inertiajs/react';

interface Employee {
    id: number;
    first_name: string;
    last_name: string;
}

interface Department {
    id: number;
    name: string;
    description: string | null;
    head_employee_id: number | null;
}

interface Props {
    department: Department;
    employees: Employee[];
}

export default function Edit({ department, employees }: Props) {
    // Pre-populate form with existing department data
    const { data, setData, put, processing, errors } = useForm({
        name: department.name,
        description: department.description || '',
        head_employee_id: department.head_employee_id?.toString() || '',
    });

    function submit(e: React.FormEvent) {
        e.preventDefault();
        put(`/departments/${department.id}`);
    }

    return (
        <>
            <Head title="Edit Department" />
            <div className="p-6 max-w-lg">
                <h1 className="text-2xl font-bold mb-6">Edit Department</h1>
                <form onSubmit={submit} className="space-y-4">
                    <div>
                        <label className="block font-medium">Name</label>
                        <input
                            type="text"
                            value={data.name}
                            onChange={(e) => setData('name', e.target.value)}
                            className="w-full border rounded p-2"
                        />
                        {errors.name && <div className="text-red-600 text-sm">{errors.name}</div>}
                    </div>

                    <div>
                        <label className="block font-medium">Description</label>
                        <textarea
                            value={data.description}
                            onChange={(e) => setData('description', e.target.value)}
                            className="w-full border rounded p-2"
                        />
                    </div>

                    {/* Pre-selected department head from existing data */}
                    <div>
                        <label className="block font-medium">Department Head</label>
                        <select
                            value={data.head_employee_id}
                            onChange={(e) => setData('head_employee_id', e.target.value)}
                            className="w-full border rounded p-2"
                        >
                            <option value="">Select a head (optional)</option>
                            {employees.map((emp) => (
                                <option key={emp.id} value={emp.id}>
                                    {emp.first_name} {emp.last_name}
                                </option>
                            ))}
                        </select>
                    </div>

                    <button type="submit" disabled={processing} className="btn-primary">
                        Update Department
                    </button>
                </form>
            </div>
        </>
    );
}
  • Save all three department page files.
  • Open routes/web.php and add the department resource routes after your existing role routes:
// Department routes - viewable by all, but create/edit/delete restricted to admins
Route::resource('departments', DepartmentController::class)
    ->only(['index'])
    ->middleware(['auth']);

Route::resource('departments', DepartmentController::class)
    ->except(['index', 'show'])
    ->middleware(['auth', 'permission:manage departments']);

Why split the routes into two declarations?

The index route only requires authentication. Any logged-in user can browse departments. The create, edit, and delete routes additionally require the manage departments permission through Spatie's middleware.

  • Add the import for DepartmentController at the top of routes/web.php:
use App\Http\Controllers\DepartmentController;
  • Save routes/web.php.
  • In your browser, navigate to http://localhost:8000/departments while logged in as the admin user. You should see the departments table with any seeded departments listed.

Seeing a 403 Forbidden error?

Make sure your admin user has the manage departments permission assigned. If you seeded "all permissions" for admin but forgot to add manage departments to your permission seeder, re-run the seeder with php artisan db:seed.

Also check that you're visiting the /departments route (index), not /departments/create directly. The index only requires authentication, not the permission.

help me debug a 403 error on my department routes

Create the Project controller and pages

Projects track deliverables across the company. The key difference from departments is that project visibility is role-scoped. Admins see all projects, managers see their department's projects, and regular users only see projects their linked employee is assigned to.

  • Generate the ProjectController by running:
php artisan make:controller ProjectController --resource
  • Open app/Http/Controllers/ProjectController.php and replace its contents with:
<?php

namespace App\Http\Controllers;

use App\Models\Project;
use App\Models\Department;
use App\Models\Employee;
use Illuminate\Http\Request;
use Inertia\Inertia;

class ProjectController extends Controller
{
    // List projects scoped by user role
    public function index(Request $request)
    {
        $user = $request->user();

        // Admin sees all projects
        if ($user->hasRole('admin')) {
            $projects = Project::with('department:id,name')->get();
        }
        // Manager sees projects in their department
        elseif ($user->hasRole('manager')) {
            $departmentId = $user->employee?->department_id;
            $projects = Project::with('department:id,name')
                ->where('department_id', $departmentId)
                ->get();
        }
        // Regular user sees only assigned projects
        else {
            $employeeId = $user->employee?->id;
            $projects = Project::with('department:id,name')
                ->whereHas('employees', function ($query) use ($employeeId) {
                    $query->where('employee_id', $employeeId);
                })
                ->get();
        }

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

    // Show the create form with departments and employees for assignment
    public function create()
    {
        $departments = Department::select('id', 'name')->get();
        $employees = Employee::select('id', 'first_name', 'last_name')->get();

        return Inertia::render('Projects/Create', [
            'departments' => $departments,
            'employees' => $employees,
        ]);
    }

    // Store a new project and sync assigned employees
    public function store(Request $request)
    {
        $validated = $request->validate([
            'title' => 'required|string|max:255',
            'description' => 'nullable|string',
            'department_id' => 'required|exists:departments,id',
            'status' => 'required|in:planning,active,completed,on_hold',
            'deadline' => 'nullable|date',
            'employee_ids' => 'array',
            'employee_ids.*' => 'exists:employees,id',
        ]);

        $project = Project::create($validated);

        // Sync the many-to-many employee assignment
        if (isset($validated['employee_ids'])) {
            $project->employees()->sync($validated['employee_ids']);
        }

        return redirect()->route('projects.index')
            ->with('success', 'Project created successfully.');
    }

    // Show project detail with tasks and assigned members
    public function show(Project $project)
    {
        $project->load(['department', 'employees', 'tasks.assignee']);

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

    // Show the edit form pre-populated with project data
    public function edit(Project $project)
    {
        $project->load('employees');
        $departments = Department::select('id', 'name')->get();
        $employees = Employee::select('id', 'first_name', 'last_name')->get();

        return Inertia::render('Projects/Edit', [
            'project' => $project,
            'departments' => $departments,
            'employees' => $employees,
        ]);
    }

    // Update project and re-sync employee assignments
    public function update(Request $request, Project $project)
    {
        $validated = $request->validate([
            'title' => 'required|string|max:255',
            'description' => 'nullable|string',
            'department_id' => 'required|exists:departments,id',
            'status' => 'required|in:planning,active,completed,on_hold',
            'deadline' => 'nullable|date',
            'employee_ids' => 'array',
            'employee_ids.*' => 'exists:employees,id',
        ]);

        $project->update($validated);

        // Re-sync pivot table with new assignments
        if (isset($validated['employee_ids'])) {
            $project->employees()->sync($validated['employee_ids']);
        }

        return redirect()->route('projects.index')
            ->with('success', 'Project updated successfully.');
    }

    // Delete a project
    public function destroy(Project $project)
    {
        $project->delete();

        return redirect()->route('projects.index')
            ->with('success', 'Project deleted successfully.');
    }
}

How does role-based scoping work in the index?

The index() method checks the authenticated user's role using Spatie's hasRole() method. Admins get all projects. Managers get projects filtered to their employee's department. Regular users get only projects where their employee ID appears in the project_employee pivot table.

The store() and update() methods call $project->employees()->sync() to manage the many-to-many relationship. This replaces all current assignments with the new selection in a single query.

Next, create the React pages for the project module. Projects need four pages: index, show, create, and edit.

  • Create the resources/js/Pages/Projects folder.
  • Create Index.tsx inside it with the following code:
import { Head, Link } from '@inertiajs/react';

// Type definitions for project list data
interface Project {
    id: number;
    title: string;
    status: string;
    deadline: string | null;
    department: { id: number; name: string } | null;
}

interface Props {
    projects: Project[];
}

// Map status values to badge colors
function statusBadge(status: string) {
    const colors: Record<string, string> = {
        planning: 'bg-yellow-100 text-yellow-800',
        active: 'bg-green-100 text-green-800',
        completed: 'bg-blue-100 text-blue-800',
        on_hold: 'bg-gray-100 text-gray-800',
    };
    return colors[status] || 'bg-gray-100 text-gray-800';
}

export default function Index({ projects }: Props) {
    return (
        <>
            <Head title="Projects" />
            <div className="p-6">
                <div className="flex justify-between items-center mb-6">
                    <h1 className="text-2xl font-bold">Projects</h1>
                    <Link href="/projects/create" className="btn-primary">
                        Create Project
                    </Link>
                </div>

                {/* Project list with status badges and deadlines */}
                <div className="space-y-4">
                    {projects.map((project) => (
                        <div key={project.id} className="border rounded p-4 flex justify-between items-center">
                            <div>
                                <Link href={`/projects/${project.id}`} className="text-lg font-medium text-blue-600">
                                    {project.title}
                                </Link>
                                <p className="text-sm text-gray-500">
                                    {project.department?.name || 'No department'}
                                </p>
                            </div>
                            <div className="flex items-center gap-4">
                                <span className={`px-2 py-1 rounded text-sm ${statusBadge(project.status)}`}>
                                    {project.status}
                                </span>
                                {project.deadline && (
                                    <span className="text-sm text-gray-600">
                                        Due: {new Date(project.deadline).toLocaleDateString()}
                                    </span>
                                )}
                            </div>
                        </div>
                    ))}
                </div>
            </div>
        </>
    );
}
  • Create Show.tsx for the project detail page:
import { Head, Link } from '@inertiajs/react';

interface Employee {
    id: number;
    first_name: string;
    last_name: string;
}

interface Task {
    id: number;
    title: string;
    status: string;
    priority: string;
    due_date: string | null;
    assignee: Employee | null;
}

interface Project {
    id: number;
    title: string;
    description: string | null;
    status: string;
    deadline: string | null;
    department: { id: number; name: string } | null;
    employees: Employee[];
    tasks: Task[];
}

interface Props {
    project: Project;
}

export default function Show({ project }: Props) {
    return (
        <>
            <Head title={project.title} />
            <div className="p-6">
                <div className="flex justify-between items-center mb-6">
                    <h1 className="text-2xl font-bold">{project.title}</h1>
                    <Link href={`/projects/${project.id}/edit`} className="btn-primary">
                        Edit Project
                    </Link>
                </div>

                {/* Project details section */}
                <div className="mb-6">
                    <p className="text-gray-600">{project.description || 'No description'}</p>
                    <div className="mt-2 flex gap-4 text-sm">
                        <span>Status: {project.status}</span>
                        <span>Department: {project.department?.name}</span>
                        {project.deadline && <span>Deadline: {new Date(project.deadline).toLocaleDateString()}</span>}
                    </div>
                </div>

                {/* Assigned members list */}
                <div className="mb-6">
                    <h2 className="text-lg font-semibold mb-2">Assigned Members</h2>
                    <ul className="list-disc pl-5">
                        {project.employees.map((emp) => (
                            <li key={emp.id}>{emp.first_name} {emp.last_name}</li>
                        ))}
                    </ul>
                </div>

                {/* Tasks list for this project */}
                <div>
                    <h2 className="text-lg font-semibold mb-2">Tasks</h2>
                    {project.tasks.length === 0 ? (
                        <p className="text-gray-500">No tasks yet.</p>
                    ) : (
                        <table className="w-full border-collapse">
                            <thead>
                                <tr>
                                    <th className="border p-2 text-left">Title</th>
                                    <th className="border p-2 text-left">Assignee</th>
                                    <th className="border p-2 text-left">Status</th>
                                    <th className="border p-2 text-left">Priority</th>
                                    <th className="border p-2 text-left">Due Date</th>
                                </tr>
                            </thead>
                            <tbody>
                                {project.tasks.map((task) => (
                                    <tr key={task.id}>
                                        <td className="border p-2">{task.title}</td>
                                        <td className="border p-2">
                                            {task.assignee ? `${task.assignee.first_name} ${task.assignee.last_name}` : 'Unassigned'}
                                        </td>
                                        <td className="border p-2">{task.status}</td>
                                        <td className="border p-2">{task.priority}</td>
                                        <td className="border p-2">
                                            {task.due_date ? new Date(task.due_date).toLocaleDateString() : '-'}
                                        </td>
                                    </tr>
                                ))}
                            </tbody>
                        </table>
                    )}
                </div>
            </div>
        </>
    );
}

What does the Show page render?

The project detail page displays three sections. The top shows the project's description, status, department, and deadline. Below that is the list of assigned employees from the many-to-many relationship. Finally, the tasks table shows all tasks belonging to this project, each with its assignee, status, and priority.

  • Create Create.tsx for the project form with the multi-select employee assignment:
import { Head } from '@inertiajs/react';
import { useForm } from '@inertiajs/react';

interface Department {
    id: number;
    name: string;
}

interface Employee {
    id: number;
    first_name: string;
    last_name: string;
}

interface Props {
    departments: Department[];
    employees: Employee[];
}

export default function Create({ departments, employees }: Props) {
    // Form state includes employee_ids array for multi-select
    const { data, setData, post, processing, errors } = useForm({
        title: '',
        description: '',
        department_id: '',
        status: 'planning',
        deadline: '',
        employee_ids: [] as number[],
    });

    function submit(e: React.FormEvent) {
        e.preventDefault();
        post('/projects');
    }

    // Toggle an employee in the multi-select list
    function toggleEmployee(employeeId: number) {
        const current = data.employee_ids;
        if (current.includes(employeeId)) {
            setData('employee_ids', current.filter((id) => id !== employeeId));
        } else {
            setData('employee_ids', [...current, employeeId]);
        }
    }

    return (
        <>
            <Head title="Create Project" />
            <div className="p-6 max-w-lg">
                <h1 className="text-2xl font-bold mb-6">Create Project</h1>
                <form onSubmit={submit} className="space-y-4">
                    {/* Project title */}
                    <div>
                        <label className="block font-medium">Title</label>
                        <input
                            type="text"
                            value={data.title}
                            onChange={(e) => setData('title', e.target.value)}
                            className="w-full border rounded p-2"
                        />
                        {errors.title && <div className="text-red-600 text-sm">{errors.title}</div>}
                    </div>

                    {/* Project description */}
                    <div>
                        <label className="block font-medium">Description</label>
                        <textarea
                            value={data.description}
                            onChange={(e) => setData('description', e.target.value)}
                            className="w-full border rounded p-2"
                        />
                    </div>

                    {/* Department selection */}
                    <div>
                        <label className="block font-medium">Department</label>
                        <select
                            value={data.department_id}
                            onChange={(e) => setData('department_id', e.target.value)}
                            className="w-full border rounded p-2"
                        >
                            <option value="">Select a department</option>
                            {departments.map((dept) => (
                                <option key={dept.id} value={dept.id}>{dept.name}</option>
                            ))}
                        </select>
                        {errors.department_id && <div className="text-red-600 text-sm">{errors.department_id}</div>}
                    </div>

                    {/* Status dropdown with predefined options */}
                    <div>
                        <label className="block font-medium">Status</label>
                        <select
                            value={data.status}
                            onChange={(e) => setData('status', e.target.value)}
                            className="w-full border rounded p-2"
                        >
                            <option value="planning">Planning</option>
                            <option value="active">Active</option>
                            <option value="completed">Completed</option>
                            <option value="on_hold">On Hold</option>
                        </select>
                    </div>

                    {/* Deadline date picker */}
                    <div>
                        <label className="block font-medium">Deadline</label>
                        <input
                            type="date"
                            value={data.deadline}
                            onChange={(e) => setData('deadline', e.target.value)}
                            className="w-full border rounded p-2"
                        />
                    </div>

                    {/* Multi-select employee assignment using checkboxes */}
                    <div>
                        <label className="block font-medium mb-2">Assign Employees</label>
                        <div className="border rounded p-2 max-h-48 overflow-y-auto">
                            {employees.map((emp) => (
                                <label key={emp.id} className="flex items-center gap-2 p-1">
                                    <input
                                        type="checkbox"
                                        checked={data.employee_ids.includes(emp.id)}
                                        onChange={() => toggleEmployee(emp.id)}
                                    />
                                    {emp.first_name} {emp.last_name}
                                </label>
                            ))}
                        </div>
                    </div>

                    <button type="submit" disabled={processing} className="btn-primary">
                        Create Project
                    </button>
                </form>
            </div>
        </>
    );
}

How does the multi-select employee assignment work?

Instead of a standard multi-select dropdown, this form uses checkboxes for each employee. The toggleEmployee() function adds or removes employee IDs from the employee_ids array. When submitted, the controller's sync() method uses this array to populate the pivot table.

  • Create Edit.tsx with the pre-populated project form:
import { Head } from '@inertiajs/react';
import { useForm } from '@inertiajs/react';

interface Department {
    id: number;
    name: string;
}

interface Employee {
    id: number;
    first_name: string;
    last_name: string;
}

interface Project {
    id: number;
    title: string;
    description: string | null;
    department_id: number;
    status: string;
    deadline: string | null;
    employees: Employee[];
}

interface Props {
    project: Project;
    departments: Department[];
    employees: Employee[];
}

export default function Edit({ project, departments, employees }: Props) {
    // Pre-populate form with existing project data including assigned employee IDs
    const { data, setData, put, processing, errors } = useForm({
        title: project.title,
        description: project.description || '',
        department_id: project.department_id.toString(),
        status: project.status,
        deadline: project.deadline || '',
        employee_ids: project.employees.map((emp) => emp.id),
    });

    function submit(e: React.FormEvent) {
        e.preventDefault();
        put(`/projects/${project.id}`);
    }

    function toggleEmployee(employeeId: number) {
        const current = data.employee_ids;
        if (current.includes(employeeId)) {
            setData('employee_ids', current.filter((id) => id !== employeeId));
        } else {
            setData('employee_ids', [...current, employeeId]);
        }
    }

    return (
        <>
            <Head title="Edit Project" />
            <div className="p-6 max-w-lg">
                <h1 className="text-2xl font-bold mb-6">Edit Project</h1>
                <form onSubmit={submit} className="space-y-4">
                    <div>
                        <label className="block font-medium">Title</label>
                        <input
                            type="text"
                            value={data.title}
                            onChange={(e) => setData('title', e.target.value)}
                            className="w-full border rounded p-2"
                        />
                        {errors.title && <div className="text-red-600 text-sm">{errors.title}</div>}
                    </div>

                    <div>
                        <label className="block font-medium">Description</label>
                        <textarea
                            value={data.description}
                            onChange={(e) => setData('description', e.target.value)}
                            className="w-full border rounded p-2"
                        />
                    </div>

                    <div>
                        <label className="block font-medium">Department</label>
                        <select
                            value={data.department_id}
                            onChange={(e) => setData('department_id', e.target.value)}
                            className="w-full border rounded p-2"
                        >
                            {departments.map((dept) => (
                                <option key={dept.id} value={dept.id}>{dept.name}</option>
                            ))}
                        </select>
                    </div>

                    <div>
                        <label className="block font-medium">Status</label>
                        <select
                            value={data.status}
                            onChange={(e) => setData('status', e.target.value)}
                            className="w-full border rounded p-2"
                        >
                            <option value="planning">Planning</option>
                            <option value="active">Active</option>
                            <option value="completed">Completed</option>
                            <option value="on_hold">On Hold</option>
                        </select>
                    </div>

                    <div>
                        <label className="block font-medium">Deadline</label>
                        <input
                            type="date"
                            value={data.deadline}
                            onChange={(e) => setData('deadline', e.target.value)}
                            className="w-full border rounded p-2"
                        />
                    </div>

                    {/* Pre-checked employee assignment checkboxes */}
                    <div>
                        <label className="block font-medium mb-2">Assign Employees</label>
                        <div className="border rounded p-2 max-h-48 overflow-y-auto">
                            {employees.map((emp) => (
                                <label key={emp.id} className="flex items-center gap-2 p-1">
                                    <input
                                        type="checkbox"
                                        checked={data.employee_ids.includes(emp.id)}
                                        onChange={() => toggleEmployee(emp.id)}
                                    />
                                    {emp.first_name} {emp.last_name}
                                </label>
                            ))}
                        </div>
                    </div>

                    <button type="submit" disabled={processing} className="btn-primary">
                        Update Project
                    </button>
                </form>
            </div>
        </>
    );
}
  • Save all four project page files.
  • Add the project routes to routes/web.php after the department routes:
// Project routes - viewable by all authenticated, create/edit/delete for managers and admins
Route::resource('projects', ProjectController::class)
    ->only(['index', 'show'])
    ->middleware(['auth']);

Route::resource('projects', ProjectController::class)
    ->except(['index', 'show'])
    ->middleware(['auth', 'permission:manage projects']);
  • Add the ProjectController import at the top of routes/web.php:
use App\Http\Controllers\ProjectController;
  • Save routes/web.php.

Getting an error about the project_employee table?

If you try to create a project with employee assignments before completing the next substep, you'll see a database error about a missing project_employee table. The pivot table migration comes next. For now, the routes and pages are ready.

help me fix a missing pivot table error in Laravel

Wire up project member assignment

The project form already sends an array of employee IDs, and the controller calls sync() on the relationship. The missing piece is the database pivot table and the Eloquent relationship definition that connects projects to employees.

  • Generate the pivot table migration by running:
php artisan make:migration create_project_employee_table
  • Open the newly created migration file in database/migrations/ and replace the up() method with:
public function up(): void
{
    Schema::create('project_employee', function (Blueprint $table) {
        $table->id();
        // Foreign keys linking to projects and employees tables
        $table->foreignId('project_id')->constrained()->onDelete('cascade');
        $table->foreignId('employee_id')->constrained()->onDelete('cascade');
        $table->timestamps();
    });
}

What is a pivot table?

A pivot table stores the connections between two models in a many-to-many relationship. Each row represents one project-employee assignment. When a project has three members, three rows exist in this table. Laravel's sync() method automatically inserts and removes rows to match the submitted selection.

  • Open app/Models/Project.php and add the many-to-many relationship method:
// In the Project model class, add this relationship method
public function employees()
{
    return $this->belongsToMany(Employee::class, 'project_employee');
}

What does belongsToMany do here?

The belongsToMany method tells Eloquent to look for records in the project_employee pivot table. You can then call $project->employees to get all assigned employees, or $project->employees()->sync([1, 2, 3]) to set exactly which employees are assigned.

  • Run the migration to create the pivot table:
php artisan migrate
  • Save all files.
  • In your browser, navigate to http://localhost:8000/projects/create while logged in as admin. You should see the project form with a title field, department dropdown, status selector, deadline picker, and a scrollable list of employee checkboxes at the bottom.

Multi-select checkboxes not saving correctly?

Make sure the employees() method exists in your Project model and specifies the pivot table name project_employee as the second argument. Without it, Laravel looks for a table called employee_project (alphabetical order) and throws an error.

Also verify the migration ran successfully by checking that php artisan migrate:status shows the project_employee migration as "Ran".

help me debug my many-to-many relationship in Laravel

✔️ Awesome, I've got everything!

Great. Double check you've saved all your files before moving on to the next step.

ⓧ I'd like to double check the full code

Here is the full routes/web.php with all routes registered so far:

<?php

use App\Http\Controllers\DepartmentController;
use App\Http\Controllers\EmployeeController;
use App\Http\Controllers\ProjectController;
use App\Http\Controllers\RoleController;
use Illuminate\Support\Facades\Route;

// Employee resource routes
Route::resource('employees', EmployeeController::class)
    ->middleware(['auth']);

// Role management routes - protected by manage roles permission
Route::resource('roles', RoleController::class)
    ->middleware(['auth', 'permission:manage roles']);

// Department routes - viewable by all, but create/edit/delete restricted to admins
Route::resource('departments', DepartmentController::class)
    ->only(['index'])
    ->middleware(['auth']);

Route::resource('departments', DepartmentController::class)
    ->except(['index', 'show'])
    ->middleware(['auth', 'permission:manage departments']);

// Project routes - viewable by all authenticated, create/edit/delete for managers and admins
Route::resource('projects', ProjectController::class)
    ->only(['index', 'show'])
    ->middleware(['auth']);

Route::resource('projects', ProjectController::class)
    ->except(['index', 'show'])
    ->middleware(['auth', 'permission:manage projects']);

Here is the full ProjectController.php:

<?php

namespace App\Http\Controllers;

use App\Models\Project;
use App\Models\Department;
use App\Models\Employee;
use Illuminate\Http\Request;
use Inertia\Inertia;

class ProjectController extends Controller
{
    public function index(Request $request)
    {
        $user = $request->user();

        if ($user->hasRole('admin')) {
            $projects = Project::with('department:id,name')->get();
        } elseif ($user->hasRole('manager')) {
            $departmentId = $user->employee?->department_id;
            $projects = Project::with('department:id,name')
                ->where('department_id', $departmentId)
                ->get();
        } else {
            $employeeId = $user->employee?->id;
            $projects = Project::with('department:id,name')
                ->whereHas('employees', function ($query) use ($employeeId) {
                    $query->where('employee_id', $employeeId);
                })
                ->get();
        }

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

    public function create()
    {
        $departments = Department::select('id', 'name')->get();
        $employees = Employee::select('id', 'first_name', 'last_name')->get();

        return Inertia::render('Projects/Create', [
            'departments' => $departments,
            'employees' => $employees,
        ]);
    }

    public function store(Request $request)
    {
        $validated = $request->validate([
            'title' => 'required|string|max:255',
            'description' => 'nullable|string',
            'department_id' => 'required|exists:departments,id',
            'status' => 'required|in:planning,active,completed,on_hold',
            'deadline' => 'nullable|date',
            'employee_ids' => 'array',
            'employee_ids.*' => 'exists:employees,id',
        ]);

        $project = Project::create($validated);

        if (isset($validated['employee_ids'])) {
            $project->employees()->sync($validated['employee_ids']);
        }

        return redirect()->route('projects.index')
            ->with('success', 'Project created successfully.');
    }

    public function show(Project $project)
    {
        $project->load(['department', 'employees', 'tasks.assignee']);

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

    public function edit(Project $project)
    {
        $project->load('employees');
        $departments = Department::select('id', 'name')->get();
        $employees = Employee::select('id', 'first_name', 'last_name')->get();

        return Inertia::render('Projects/Edit', [
            'project' => $project,
            'departments' => $departments,
            'employees' => $employees,
        ]);
    }

    public function update(Request $request, Project $project)
    {
        $validated = $request->validate([
            'title' => 'required|string|max:255',
            'description' => 'nullable|string',
            'department_id' => 'required|exists:departments,id',
            'status' => 'required|in:planning,active,completed,on_hold',
            'deadline' => 'nullable|date',
            'employee_ids' => 'array',
            'employee_ids.*' => 'exists:employees,id',
        ]);

        $project->update($validated);

        if (isset($validated['employee_ids'])) {
            $project->employees()->sync($validated['employee_ids']);
        }

        return redirect()->route('projects.index')
            ->with('success', 'Project updated successfully.');
    }

    public function destroy(Project $project)
    {
        $project->delete();

        return redirect()->route('projects.index')
            ->with('success', 'Project deleted successfully.');
    }
}

Here is the full DepartmentController.php:

<?php

namespace App\Http\Controllers;

use App\Models\Department;
use App\Models\Employee;
use Illuminate\Http\Request;
use Inertia\Inertia;

class DepartmentController extends Controller
{
    public function index()
    {
        $departments = Department::withCount('employees')
            ->with('headEmployee:id,first_name,last_name')
            ->get();

        return Inertia::render('Departments/Index', [
            'departments' => $departments,
        ]);
    }

    public function create()
    {
        $employees = Employee::select('id', 'first_name', 'last_name')->get();

        return Inertia::render('Departments/Create', [
            'employees' => $employees,
        ]);
    }

    public function store(Request $request)
    {
        $validated = $request->validate([
            'name' => 'required|string|max:255',
            'description' => 'nullable|string',
            'head_employee_id' => 'nullable|exists:employees,id',
        ]);

        Department::create($validated);

        return redirect()->route('departments.index')
            ->with('success', 'Department created successfully.');
    }

    public function edit(Department $department)
    {
        $employees = Employee::select('id', 'first_name', 'last_name')->get();

        return Inertia::render('Departments/Edit', [
            'department' => $department,
            'employees' => $employees,
        ]);
    }

    public function update(Request $request, Department $department)
    {
        $validated = $request->validate([
            'name' => 'required|string|max:255',
            'description' => 'nullable|string',
            'head_employee_id' => 'nullable|exists:employees,id',
        ]);

        $department->update($validated);

        return redirect()->route('departments.index')
            ->with('success', 'Department updated successfully.');
    }

    public function destroy(Department $department)
    {
        $department->delete();

        return redirect()->route('departments.index')
            ->with('success', 'Department deleted successfully.');
    }
}

Your departments and projects are fully wired up with role-based access control and many-to-many employee assignment. Next up, you'll build the task system that sits inside projects and gives individual employees specific work items to complete.

Build the Task CRUD with Role-Based Visibility

Your departments and projects are in place. Now it's time to add the core unit of work that teams actually track day-to-day: tasks.

Tasks are where role-based visibility matters most. An admin needs to see everything across the company, a manager only needs their department's workload, and a regular employee only needs their own assignments.

In this step, get ready to:
  • Create a TaskController with role-based query scoping.
  • Build React pages for listing, creating, and editing tasks.
  • Add a role-based dashboard showing different stats per user type.
Create the Task controller

The TaskController is the most complex controller in this project because it needs to show different data depending on who is logged in. Spatie permissions will also protect the delete action so only authorized users can remove tasks.

  • Generate a new controller by running this command in your terminal:
php artisan make:controller TaskController
  • Open app/Http/Controllers/TaskController.php and replace its contents with the following code:
<?php

namespace App\Http\Controllers;

use App\Models\Task;
use App\Models\Project;
use App\Models\Employee;
use Illuminate\Http\Request;
use Inertia\Inertia;

class TaskController extends Controller
{
    // List tasks scoped by the current user's role
    public function index(Request $request)
    {
        $user = $request->user();
        $query = Task::with(['project', 'assignee']);

        // Admins see all tasks across the company
        if ($user->hasRole('admin')) {
            // No scoping needed
        } elseif ($user->hasRole('manager')) {
            // Managers see tasks within their department's projects
            $departmentId = $user->employee?->department_id;
            $query->whereHas('project', function ($q) use ($departmentId) {
                $q->where('department_id', $departmentId);
            });
        } else {
            // Regular users see only their own assigned tasks
            $query->where('assigned_to_employee_id', $user->employee?->id);
        }

        // Apply optional filters for status and priority
        if ($request->filled('status')) {
            $query->where('status', $request->status);
        }
        if ($request->filled('priority')) {
            $query->where('priority', $request->priority);
        }

        $tasks = $query->latest()->get();

        return Inertia::render('Tasks/Index', [
            'tasks' => $tasks,
            'filters' => $request->only(['status', 'priority']),
        ]);
    }

    // Show the create task form with dropdown data
    public function create()
    {
        return Inertia::render('Tasks/Create', [
            'projects' => Project::all(['id', 'title']),
            'employees' => Employee::all(['id', 'first_name', 'last_name']),
        ]);
    }

    // Validate and store a new task
    public function store(Request $request)
    {
        $validated = $request->validate([
            'title' => 'required|string|max:255',
            'description' => 'nullable|string',
            'project_id' => 'required|exists:projects,id',
            'assigned_to_employee_id' => 'required|exists:employees,id',
            'status' => 'required|in:pending,in_progress,completed',
            'priority' => 'required|in:low,medium,high',
            'due_date' => 'nullable|date',
        ]);

        Task::create($validated);

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

    // Show the edit form pre-populated with task data
    public function edit(Task $task)
    {
        return Inertia::render('Tasks/Edit', [
            'task' => $task->load(['project', 'assignee']),
            'projects' => Project::all(['id', 'title']),
            'employees' => Employee::all(['id', 'first_name', 'last_name']),
        ]);
    }

    // Validate and update an existing task
    public function update(Request $request, Task $task)
    {
        $validated = $request->validate([
            'title' => 'required|string|max:255',
            'description' => 'nullable|string',
            'project_id' => 'required|exists:projects,id',
            'assigned_to_employee_id' => 'required|exists:employees,id',
            'status' => 'required|in:pending,in_progress,completed',
            'priority' => 'required|in:low,medium,high',
            'due_date' => 'nullable|date',
        ]);

        $task->update($validated);

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

    // Delete a task (protected by permission middleware in routes)
    public function destroy(Task $task)
    {
        $task->delete();

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

What does this code do?

The index() method uses role-based query scoping. It checks the logged-in user's role and adjusts the database query so each user type only sees relevant tasks.

  • hasRole('admin') applies no filter, so admins see everything.
  • Managers are scoped to tasks belonging to projects in their department via whereHas('project').
  • Regular users only see tasks where assigned_to_employee_id matches their linked employee record.
  • Now register the task routes. Open routes/web.php and add the following task resource route alongside your existing department and project routes:
// Task routes with auth middleware and permission-protected delete
Route::resource('tasks', \App\Http\Controllers\TaskController::class)
    ->middleware(['auth', 'verified'])
    ->middlewareFor('destroy', 'permission:delete tasks');

What does this route registration do?

The middlewareFor('destroy', 'permission:delete tasks') call applies Spatie's permission middleware only to the destroy action. All other task routes just require the user to be authenticated and verified.

  • Save both TaskController.php and web.php.
  • In your browser, navigate to /tasks while logged in as admin. You should see the route resolve without a 404 error (the page will be blank or show a missing component error until you build the React pages next).

Seeing a 500 error?

  • Check that the use statements at the top of TaskController.php import all required models (Task, Project, Employee).
  • Make sure your Task model exists from a previous step's migration. Run php artisan migrate:status to confirm the tasks table is present.
  • Verify the route file has no syntax errors by running php artisan route:list and looking for task routes in the output.

Why am I getting a 500 error on my task routes?

Build Task React pages

The task pages need filter controls so users can narrow down by status or priority. You will also use Inertia.js's useForm hook for all form submissions.

  • Create the file resources/js/Pages/Tasks/Index.tsx and add the following code:
import { Head, Link, router } from '@inertiajs/react';
import { useState } from 'react';

// Type definitions for task data
interface Task {
    id: number;
    title: string;
    project: { id: number; title: string };
    assignee: { id: number; first_name: string; last_name: string };
    status: string;
    priority: string;
    due_date: string | null;
}

interface Props {
    tasks: Task[];
    filters: { status?: string; priority?: string };
}

export default function Index({ tasks, filters }: Props) {
    const [status, setStatus] = useState(filters.status || '');
    const [priority, setPriority] = useState(filters.priority || '');

    // Apply filters by reloading the page with query parameters
    function applyFilters() {
        router.get('/tasks', { status, priority }, { preserveState: true });
    }

    // Map status values to display badge colors
    function statusBadge(status: string) {
        const colors: Record<string, string> = {
            pending: 'bg-yellow-100 text-yellow-800',
            in_progress: 'bg-blue-100 text-blue-800',
            completed: 'bg-green-100 text-green-800',
        };
        return colors[status] || 'bg-gray-100 text-gray-800';
    }

    // Map priority values to display badge colors
    function priorityBadge(priority: string) {
        const colors: Record<string, string> = {
            low: 'bg-gray-100 text-gray-800',
            medium: 'bg-orange-100 text-orange-800',
            high: 'bg-red-100 text-red-800',
        };
        return colors[priority] || 'bg-gray-100 text-gray-800';
    }

    return (
        <>
            <Head title="Tasks" />
            <div className="p-6">
                <div className="flex justify-between items-center mb-6">
                    <h1 className="text-2xl font-bold">Tasks</h1>
                    <Link href="/tasks/create" className="btn-primary">
                        Create Task
                    </Link>
                </div>

                {/* Filter controls for status and priority */}
                <div className="flex gap-4 mb-4">
                    <select
                        value={status}
                        onChange={(e) => setStatus(e.target.value)}
                        className="border rounded px-3 py-2"
                    >
                        <option value="">All Statuses</option>
                        <option value="pending">Pending</option>
                        <option value="in_progress">In Progress</option>
                        <option value="completed">Completed</option>
                    </select>
                    <select
                        value={priority}
                        onChange={(e) => setPriority(e.target.value)}
                        className="border rounded px-3 py-2"
                    >
                        <option value="">All Priorities</option>
                        <option value="low">Low</option>
                        <option value="medium">Medium</option>
                        <option value="high">High</option>
                    </select>
                    <button onClick={applyFilters} className="btn-secondary">
                        Filter
                    </button>
                </div>

                {/* Task table with columns for all key fields */}
                <table className="w-full border-collapse">
                    <thead>
                        <tr className="border-b">
                            <th className="text-left p-2">Title</th>
                            <th className="text-left p-2">Project</th>
                            <th className="text-left p-2">Assignee</th>
                            <th className="text-left p-2">Status</th>
                            <th className="text-left p-2">Priority</th>
                            <th className="text-left p-2">Due Date</th>
                            <th className="text-left p-2">Actions</th>
                        </tr>
                    </thead>
                    <tbody>
                        {tasks.map((task) => (
                            <tr key={task.id} className="border-b">
                                <td className="p-2">{task.title}</td>
                                <td className="p-2">{task.project?.title}</td>
                                <td className="p-2">
                                    {task.assignee?.first_name} {task.assignee?.last_name}
                                </td>
                                <td className="p-2">
                                    <span className={`px-2 py-1 rounded text-sm ${statusBadge(task.status)}`}>
                                        {task.status}
                                    </span>
                                </td>
                                <td className="p-2">
                                    <span className={`px-2 py-1 rounded text-sm ${priorityBadge(task.priority)}`}>
                                        {task.priority}
                                    </span>
                                </td>
                                <td className="p-2">{task.due_date || '—'}</td>
                                <td className="p-2 flex gap-2">
                                    <Link href={`/tasks/${task.id}/edit`} className="text-blue-600">
                                        Edit
                                    </Link>
                                    <Link
                                        href={`/tasks/${task.id}`}
                                        method="delete"
                                        as="button"
                                        className="text-red-600"
                                    >
                                        Delete
                                    </Link>
                                </td>
                            </tr>
                        ))}
                    </tbody>
                </table>
            </div>
        </>
    );
}

What does this page do?

  • The filter controls use local state (useState) for the dropdowns, then reload the page with query parameters via router.get when the Filter button is clicked.
  • statusBadge() and priorityBadge() map each value to a Tailwind color class for visual distinction.
  • The Delete link uses Inertia's method="delete" prop to send a DELETE request without a form.
  • Save Index.tsx.
  • Next, create resources/js/Pages/Tasks/Create.tsx with the task creation form:
import { Head, Link } from '@inertiajs/react';
import { useForm } from '@inertiajs/react';

interface Props {
    projects: { id: number; title: string }[];
    employees: { id: number; first_name: string; last_name: string }[];
}

export default function Create({ projects, employees }: Props) {
    // Initialize form state with Inertia's useForm hook
    const { data, setData, post, processing, errors } = useForm({
        title: '',
        description: '',
        project_id: '',
        assigned_to_employee_id: '',
        status: 'pending',
        priority: 'medium',
        due_date: '',
    });

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

    return (
        <>
            <Head title="Create Task" />
            <div className="p-6 max-w-2xl">
                <h1 className="text-2xl font-bold mb-6">Create Task</h1>
                <form onSubmit={handleSubmit} className="space-y-4">
                    {/* Task title input */}
                    <div>
                        <label className="block font-medium">Title</label>
                        <input
                            type="text"
                            value={data.title}
                            onChange={(e) => setData('title', e.target.value)}
                            className="w-full border rounded px-3 py-2"
                        />
                        {errors.title && <p className="text-red-600 text-sm">{errors.title}</p>}
                    </div>

                    {/* Task description textarea */}
                    <div>
                        <label className="block font-medium">Description</label>
                        <textarea
                            value={data.description}
                            onChange={(e) => setData('description', e.target.value)}
                            className="w-full border rounded px-3 py-2"
                            rows={3}
                        />
                    </div>

                    {/* Project selection dropdown */}
                    <div>
                        <label className="block font-medium">Project</label>
                        <select
                            value={data.project_id}
                            onChange={(e) => setData('project_id', e.target.value)}
                            className="w-full border rounded px-3 py-2"
                        >
                            <option value="">Select Project</option>
                            {projects.map((p) => (
                                <option key={p.id} value={p.id}>{p.title}</option>
                            ))}
                        </select>
                        {errors.project_id && <p className="text-red-600 text-sm">{errors.project_id}</p>}
                    </div>

                    {/* Employee assignee dropdown */}
                    <div>
                        <label className="block font-medium">Assign To</label>
                        <select
                            value={data.assigned_to_employee_id}
                            onChange={(e) => setData('assigned_to_employee_id', e.target.value)}
                            className="w-full border rounded px-3 py-2"
                        >
                            <option value="">Select Employee</option>
                            {employees.map((emp) => (
                                <option key={emp.id} value={emp.id}>
                                    {emp.first_name} {emp.last_name}
                                </option>
                            ))}
                        </select>
                        {errors.assigned_to_employee_id && (
                            <p className="text-red-600 text-sm">{errors.assigned_to_employee_id}</p>
                        )}
                    </div>

                    {/* Status dropdown */}
                    <div>
                        <label className="block font-medium">Status</label>
                        <select
                            value={data.status}
                            onChange={(e) => setData('status', e.target.value)}
                            className="w-full border rounded px-3 py-2"
                        >
                            <option value="pending">Pending</option>
                            <option value="in_progress">In Progress</option>
                            <option value="completed">Completed</option>
                        </select>
                    </div>

                    {/* Priority dropdown */}
                    <div>
                        <label className="block font-medium">Priority</label>
                        <select
                            value={data.priority}
                            onChange={(e) => setData('priority', e.target.value)}
                            className="w-full border rounded px-3 py-2"
                        >
                            <option value="low">Low</option>
                            <option value="medium">Medium</option>
                            <option value="high">High</option>
                        </select>
                    </div>

                    {/* Due date picker */}
                    <div>
                        <label className="block font-medium">Due Date</label>
                        <input
                            type="date"
                            value={data.due_date}
                            onChange={(e) => setData('due_date', e.target.value)}
                            className="w-full border rounded px-3 py-2"
                        />
                    </div>

                    {/* Form action buttons */}
                    <div className="flex gap-4">
                        <button
                            type="submit"
                            disabled={processing}
                            className="bg-blue-600 text-white px-4 py-2 rounded"
                        >
                            Create Task
                        </button>
                        <Link href="/tasks" className="px-4 py-2 rounded border">
                            Cancel
                        </Link>
                    </div>
                </form>
            </div>
        </>
    );
}

What does useForm do here?

Inertia's useForm hook manages form state, submission, processing status, and server-side validation errors in one object. When you call post('/tasks'), it sends the form data as a POST request and automatically populates the errors object if validation fails on the server.

  • Save Create.tsx.
  • Create resources/js/Pages/Tasks/Edit.tsx for the edit form with an inline status update control:
import { Head, Link, router } from '@inertiajs/react';
import { useForm } from '@inertiajs/react';

interface Task {
    id: number;
    title: string;
    description: string;
    project_id: number;
    assigned_to_employee_id: number;
    status: string;
    priority: string;
    due_date: string | null;
}

interface Props {
    task: Task;
    projects: { id: number; title: string }[];
    employees: { id: number; first_name: string; last_name: string }[];
}

export default function Edit({ task, projects, employees }: Props) {
    // Pre-populate form with existing task data
    const { data, setData, put, processing, errors } = useForm({
        title: task.title,
        description: task.description || '',
        project_id: String(task.project_id),
        assigned_to_employee_id: String(task.assigned_to_employee_id),
        status: task.status,
        priority: task.priority,
        due_date: task.due_date || '',
    });

    // Submit the updated form data
    function handleSubmit(e: React.FormEvent) {
        e.preventDefault();
        put(`/tasks/${task.id}`);
    }

    // Inline status update without a full form submission
    function updateStatus(newStatus: string) {
        router.put(`/tasks/${task.id}`, {
            ...data,
            status: newStatus,
        }, { preserveState: true });
    }

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

                {/* Inline status update buttons */}
                <div className="mb-6 p-4 bg-gray-50 rounded">
                    <label className="block font-medium mb-2">Quick Status Update</label>
                    <div className="flex gap-2">
                        <button
                            onClick={() => updateStatus('pending')}
                            className={`px-3 py-1 rounded ${data.status === 'pending' ? 'bg-yellow-200' : 'bg-gray-200'}`}
                        >
                            Pending
                        </button>
                        <button
                            onClick={() => updateStatus('in_progress')}
                            className={`px-3 py-1 rounded ${data.status === 'in_progress' ? 'bg-blue-200' : 'bg-gray-200'}`}
                        >
                            In Progress
                        </button>
                        <button
                            onClick={() => updateStatus('completed')}
                            className={`px-3 py-1 rounded ${data.status === 'completed' ? 'bg-green-200' : 'bg-gray-200'}`}
                        >
                            Completed
                        </button>
                    </div>
                </div>

                <form onSubmit={handleSubmit} className="space-y-4">
                    {/* Task title */}
                    <div>
                        <label className="block font-medium">Title</label>
                        <input
                            type="text"
                            value={data.title}
                            onChange={(e) => setData('title', e.target.value)}
                            className="w-full border rounded px-3 py-2"
                        />
                        {errors.title && <p className="text-red-600 text-sm">{errors.title}</p>}
                    </div>

                    {/* Description */}
                    <div>
                        <label className="block font-medium">Description</label>
                        <textarea
                            value={data.description}
                            onChange={(e) => setData('description', e.target.value)}
                            className="w-full border rounded px-3 py-2"
                            rows={3}
                        />
                    </div>

                    {/* Project dropdown */}
                    <div>
                        <label className="block font-medium">Project</label>
                        <select
                            value={data.project_id}
                            onChange={(e) => setData('project_id', e.target.value)}
                            className="w-full border rounded px-3 py-2"
                        >
                            <option value="">Select Project</option>
                            {projects.map((p) => (
                                <option key={p.id} value={p.id}>{p.title}</option>
                            ))}
                        </select>
                        {errors.project_id && <p className="text-red-600 text-sm">{errors.project_id}</p>}
                    </div>

                    {/* Employee assignee dropdown */}
                    <div>
                        <label className="block font-medium">Assign To</label>
                        <select
                            value={data.assigned_to_employee_id}
                            onChange={(e) => setData('assigned_to_employee_id', e.target.value)}
                            className="w-full border rounded px-3 py-2"
                        >
                            <option value="">Select Employee</option>
                            {employees.map((emp) => (
                                <option key={emp.id} value={emp.id}>
                                    {emp.first_name} {emp.last_name}
                                </option>
                            ))}
                        </select>
                        {errors.assigned_to_employee_id && (
                            <p className="text-red-600 text-sm">{errors.assigned_to_employee_id}</p>
                        )}
                    </div>

                    {/* Status dropdown */}
                    <div>
                        <label className="block font-medium">Status</label>
                        <select
                            value={data.status}
                            onChange={(e) => setData('status', e.target.value)}
                            className="w-full border rounded px-3 py-2"
                        >
                            <option value="pending">Pending</option>
                            <option value="in_progress">In Progress</option>
                            <option value="completed">Completed</option>
                        </select>
                    </div>

                    {/* Priority dropdown */}
                    <div>
                        <label className="block font-medium">Priority</label>
                        <select
                            value={data.priority}
                            onChange={(e) => setData('priority', e.target.value)}
                            className="w-full border rounded px-3 py-2"
                        >
                            <option value="low">Low</option>
                            <option value="medium">Medium</option>
                            <option value="high">High</option>
                        </select>
                    </div>

                    {/* Due date */}
                    <div>
                        <label className="block font-medium">Due Date</label>
                        <input
                            type="date"
                            value={data.due_date}
                            onChange={(e) => setData('due_date', e.target.value)}
                            className="w-full border rounded px-3 py-2"
                        />
                    </div>

                    {/* Form action buttons */}
                    <div className="flex gap-4">
                        <button
                            type="submit"
                            disabled={processing}
                            className="bg-blue-600 text-white px-4 py-2 rounded"
                        >
                            Update Task
                        </button>
                        <Link href="/tasks" className="px-4 py-2 rounded border">
                            Cancel
                        </Link>
                    </div>
                </form>
            </div>
        </>
    );
}

What is the inline status update?

The updateStatus() function sends a PUT request with the current form data plus the new status value. This lets users quickly change a task's status with one click instead of editing the whole form.

The preserveState: true option keeps the page state intact after the request completes, so the form doesn't reset.

  • Save Edit.tsx.
  • Refresh your browser and navigate to /tasks. You should see the task list page with filter dropdowns for status and priority, a Create Task button, and a table listing any seeded tasks.
  • Click Create Task to confirm the form loads with project and employee dropdowns populated.

Dropdowns are empty?

  • Confirm you have seeded data in the projects and employees tables. Run php artisan db:seed if your tables are empty.
  • Check that TaskController's create() method passes both projects and employees to the Inertia render call.

Why are my task form dropdowns showing no options?

Add the role-based dashboard

The dashboard is the first thing each user sees after logging in. Instead of one generic landing page, you will show relevant data based on the user's role.

  • Open resources/js/Pages/Dashboard.tsx and replace its contents with the following role-based dashboard:
import { Head } from '@inertiajs/react';

interface Props {
    role: string;
    stats?: {
        totalProjects: number;
        totalTasks: number;
        totalEmployees: number;
        usersWithoutEmployee: number;
    };
    departmentProjects?: { id: number; title: string; status: string }[];
    pendingTasks?: { id: number; title: string; due_date: string | null }[];
    myTasks?: { id: number; title: string; status: string; due_date: string | null }[];
    upcomingDeadlines?: { id: number; title: string; due_date: string }[];
}

export default function Dashboard({
    role,
    stats,
    departmentProjects,
    pendingTasks,
    myTasks,
    upcomingDeadlines,
}: Props) {
    return (
        <>
            <Head title="Dashboard" />
            <div className="p-6">
                <h1 className="text-2xl font-bold mb-6">Dashboard</h1>

                {/* Admin view: company-wide statistics */}
                {role === 'admin' && stats && (
                    <div className="grid grid-cols-4 gap-4">
                        <div className="bg-white p-4 rounded shadow">
                            <p className="text-gray-600">Total Projects</p>
                            <p className="text-3xl font-bold">{stats.totalProjects}</p>
                        </div>
                        <div className="bg-white p-4 rounded shadow">
                            <p className="text-gray-600">Total Tasks</p>
                            <p className="text-3xl font-bold">{stats.totalTasks}</p>
                        </div>
                        <div className="bg-white p-4 rounded shadow">
                            <p className="text-gray-600">Total Employees</p>
                            <p className="text-3xl font-bold">{stats.totalEmployees}</p>
                        </div>
                        <div className="bg-white p-4 rounded shadow">
                            <p className="text-gray-600">Users Without Employee Record</p>
                            <p className="text-3xl font-bold">{stats.usersWithoutEmployee}</p>
                        </div>
                    </div>
                )}

                {/* Manager view: department projects and pending tasks */}
                {role === 'manager' && (
                    <div className="grid grid-cols-2 gap-6">
                        <div>
                            <h2 className="text-xl font-semibold mb-3">Department Projects</h2>
                            <ul className="space-y-2">
                                {departmentProjects?.map((project) => (
                                    <li key={project.id} className="bg-white p-3 rounded shadow">
                                        {project.title} - <span className="text-sm text-gray-500">{project.status}</span>
                                    </li>
                                ))}
                            </ul>
                        </div>
                        <div>
                            <h2 className="text-xl font-semibold mb-3">Pending Tasks</h2>
                            <ul className="space-y-2">
                                {pendingTasks?.map((task) => (
                                    <li key={task.id} className="bg-white p-3 rounded shadow">
                                        {task.title}
                                        {task.due_date && (
                                            <span className="text-sm text-gray-500 ml-2">Due: {task.due_date}</span>
                                        )}
                                    </li>
                                ))}
                            </ul>
                        </div>
                    </div>
                )}

                {/* Employee view: assigned tasks and upcoming deadlines */}
                {role === 'employee' && (
                    <div className="grid grid-cols-2 gap-6">
                        <div>
                            <h2 className="text-xl font-semibold mb-3">My Tasks</h2>
                            <ul className="space-y-2">
                                {myTasks?.map((task) => (
                                    <li key={task.id} className="bg-white p-3 rounded shadow">
                                        {task.title} - <span className="text-sm text-gray-500">{task.status}</span>
                                    </li>
                                ))}
                            </ul>
                        </div>
                        <div>
                            <h2 className="text-xl font-semibold mb-3">Upcoming Deadlines</h2>
                            <ul className="space-y-2">
                                {upcomingDeadlines?.map((task) => (
                                    <li key={task.id} className="bg-white p-3 rounded shadow">
                                        {task.title} - <span className="text-sm text-red-500">Due: {task.due_date}</span>
                                    </li>
                                ))}
                            </ul>
                        </div>
                    </div>
                )}
            </div>
        </>
    );
}

What does the dashboard show for each role?

  • Admin sees four stat cards: total projects, total tasks, total employees, and users without an employee record (useful for spotting orphaned accounts).
  • Manager sees their department's projects and any pending tasks within those projects.
  • Employee sees their own assigned tasks and upcoming deadlines to stay on top of their workload.
  • Save Dashboard.tsx.

The dashboard page needs data from the backend. You need to update the dashboard route to pass role-scoped data.

  • Open routes/web.php and find the existing dashboard route. Replace it with the following route that passes role-scoped data:
use App\Models\Project;
use App\Models\Task;
use App\Models\Employee;
use App\Models\User;

// Dashboard route with role-based data
Route::get('/dashboard', function () {
    $user = auth()->user();
    $props = ['role' => 'employee'];

    if ($user->hasRole('admin')) {
        // Admin sees company-wide stats
        $props = [
            'role' => 'admin',
            'stats' => [
                'totalProjects' => Project::count(),
                'totalTasks' => Task::count(),
                'totalEmployees' => Employee::count(),
                'usersWithoutEmployee' => User::whereDoesntHave('employee')->count(),
            ],
        ];
    } elseif ($user->hasRole('manager')) {
        // Manager sees their department's projects and pending tasks
        $departmentId = $user->employee?->department_id;
        $props = [
            'role' => 'manager',
            'departmentProjects' => Project::where('department_id', $departmentId)->get(['id', 'title', 'status']),
            'pendingTasks' => Task::whereHas('project', fn($q) => $q->where('department_id', $departmentId))
                ->where('status', 'pending')->get(['id', 'title', 'due_date']),
        ];
    } else {
        // Employee sees their own tasks and upcoming deadlines
        $employeeId = $user->employee?->id;
        $props = [
            'role' => 'employee',
            'myTasks' => Task::where('assigned_to_employee_id', $employeeId)->get(['id', 'title', 'status', 'due_date']),
            'upcomingDeadlines' => Task::where('assigned_to_employee_id', $employeeId)
                ->whereNotNull('due_date')
                ->where('due_date', '>=', now())
                ->orderBy('due_date')
                ->limit(5)
                ->get(['id', 'title', 'due_date']),
        ];
    }

    return Inertia\Inertia::render('Dashboard', $props);
})->middleware(['auth', 'verified'])->name('dashboard');

Why is the dashboard logic in the route file?

For a single route with role-based branching, a closure keeps the logic visible in one place. In a larger application, you would extract this into a dedicated DashboardController. For this project, the route closure keeps things straightforward while you focus on the role-scoping pattern.

  • Save web.php.
  • In your browser, navigate to the dashboard while logged in as the admin user. You should see four stat cards showing total projects, total tasks, total employees, and users without employee records.
  • Log out and log in as a manager user. The dashboard should now show your department's projects and pending tasks instead of the admin stats.

Dashboard showing blank content?

  • Check that the role prop is being passed correctly. Add a console.log(role) in your Dashboard component to verify the value.
  • Make sure your user actually has a role assigned. Run php artisan tinker and check with User::find(1)->getRoleNames().
  • If the manager dashboard is empty, confirm the manager's employee record has a valid department_id that matches projects in the database.

My role-based dashboard isn't showing the right content for my user role.

✔️ Awesome, I've got everything!

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

ⓧ I'd like to double check the full code

Here is the complete TaskController for reference:

<?php

namespace App\Http\Controllers;

use App\Models\Task;
use App\Models\Project;
use App\Models\Employee;
use Illuminate\Http\Request;
use Inertia\Inertia;

class TaskController extends Controller
{
    public function index(Request $request)
    {
        $user = $request->user();
        $query = Task::with(['project', 'assignee']);

        if ($user->hasRole('admin')) {
            // No scoping needed
        } elseif ($user->hasRole('manager')) {
            $departmentId = $user->employee?->department_id;
            $query->whereHas('project', function ($q) use ($departmentId) {
                $q->where('department_id', $departmentId);
            });
        } else {
            $query->where('assigned_to_employee_id', $user->employee?->id);
        }

        if ($request->filled('status')) {
            $query->where('status', $request->status);
        }
        if ($request->filled('priority')) {
            $query->where('priority', $request->priority);
        }

        $tasks = $query->latest()->get();

        return Inertia::render('Tasks/Index', [
            'tasks' => $tasks,
            'filters' => $request->only(['status', 'priority']),
        ]);
    }

    public function create()
    {
        return Inertia::render('Tasks/Create', [
            'projects' => Project::all(['id', 'title']),
            'employees' => Employee::all(['id', 'first_name', 'last_name']),
        ]);
    }

    public function store(Request $request)
    {
        $validated = $request->validate([
            'title' => 'required|string|max:255',
            'description' => 'nullable|string',
            'project_id' => 'required|exists:projects,id',
            'assigned_to_employee_id' => 'required|exists:employees,id',
            'status' => 'required|in:pending,in_progress,completed',
            'priority' => 'required|in:low,medium,high',
            'due_date' => 'nullable|date',
        ]);

        Task::create($validated);

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

    public function edit(Task $task)
    {
        return Inertia::render('Tasks/Edit', [
            'task' => $task->load(['project', 'assignee']),
            'projects' => Project::all(['id', 'title']),
            'employees' => Employee::all(['id', 'first_name', 'last_name']),
        ]);
    }

    public function update(Request $request, Task $task)
    {
        $validated = $request->validate([
            'title' => 'required|string|max:255',
            'description' => 'nullable|string',
            'project_id' => 'required|exists:projects,id',
            'assigned_to_employee_id' => 'required|exists:employees,id',
            'status' => 'required|in:pending,in_progress,completed',
            'priority' => 'required|in:low,medium,high',
            'due_date' => 'nullable|date',
        ]);

        $task->update($validated);

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

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

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

And the complete Dashboard page:

import { Head } from '@inertiajs/react';

interface Props {
    role: string;
    stats?: {
        totalProjects: number;
        totalTasks: number;
        totalEmployees: number;
        usersWithoutEmployee: number;
    };
    departmentProjects?: { id: number; title: string; status: string }[];
    pendingTasks?: { id: number; title: string; due_date: string | null }[];
    myTasks?: { id: number; title: string; status: string; due_date: string | null }[];
    upcomingDeadlines?: { id: number; title: string; due_date: string }[];
}

export default function Dashboard({
    role,
    stats,
    departmentProjects,
    pendingTasks,
    myTasks,
    upcomingDeadlines,
}: Props) {
    return (
        <>
            <Head title="Dashboard" />
            <div className="p-6">
                <h1 className="text-2xl font-bold mb-6">Dashboard</h1>

                {role === 'admin' && stats && (
                    <div className="grid grid-cols-4 gap-4">
                        <div className="bg-white p-4 rounded shadow">
                            <p className="text-gray-600">Total Projects</p>
                            <p className="text-3xl font-bold">{stats.totalProjects}</p>
                        </div>
                        <div className="bg-white p-4 rounded shadow">
                            <p className="text-gray-600">Total Tasks</p>
                            <p className="text-3xl font-bold">{stats.totalTasks}</p>
                        </div>
                        <div className="bg-white p-4 rounded shadow">
                            <p className="text-gray-600">Total Employees</p>
                            <p className="text-3xl font-bold">{stats.totalEmployees}</p>
                        </div>
                        <div className="bg-white p-4 rounded shadow">
                            <p className="text-gray-600">Users Without Employee Record</p>
                            <p className="text-3xl font-bold">{stats.usersWithoutEmployee}</p>
                        </div>
                    </div>
                )}

                {role === 'manager' && (
                    <div className="grid grid-cols-2 gap-6">
                        <div>
                            <h2 className="text-xl font-semibold mb-3">Department Projects</h2>
                            <ul className="space-y-2">
                                {departmentProjects?.map((project) => (
                                    <li key={project.id} className="bg-white p-3 rounded shadow">
                                        {project.title} - <span className="text-sm text-gray-500">{project.status}</span>
                                    </li>
                                ))}
                            </ul>
                        </div>
                        <div>
                            <h2 className="text-xl font-semibold mb-3">Pending Tasks</h2>
                            <ul className="space-y-2">
                                {pendingTasks?.map((task) => (
                                    <li key={task.id} className="bg-white p-3 rounded shadow">
                                        {task.title}
                                        {task.due_date && (
                                            <span className="text-sm text-gray-500 ml-2">Due: {task.due_date}</span>
                                        )}
                                    </li>
                                ))}
                            </ul>
                        </div>
                    </div>
                )}

                {role === 'employee' && (
                    <div className="grid grid-cols-2 gap-6">
                        <div>
                            <h2 className="text-xl font-semibold mb-3">My Tasks</h2>
                            <ul className="space-y-2">
                                {myTasks?.map((task) => (
                                    <li key={task.id} className="bg-white p-3 rounded shadow">
                                        {task.title} - <span className="text-sm text-gray-500">{task.status}</span>
                                    </li>
                                ))}
                            </ul>
                        </div>
                        <div>
                            <h2 className="text-xl font-semibold mb-3">Upcoming Deadlines</h2>
                            <ul className="space-y-2">
                                {upcomingDeadlines?.map((task) => (
                                    <li key={task.id} className="bg-white p-3 rounded shadow">
                                        {task.title} - <span className="text-sm text-red-500">Due: {task.due_date}</span>
                                    </li>
                                ))}
                            </ul>
                        </div>
                    </div>
                )}
            </div>
        </>
    );
}

Your task system is live with role-based visibility, and the dashboard gives each user a personalized view of what matters most to them. Next up, you will build the user account management panel so admins can create accounts, assign roles, and link users to employee records.

Build User Account Management

Your task, project, and role CRUD modules are all built. But there is still a gap in the admin experience. Admins need a dedicated panel to manage user accounts separately from employee records.

In this step, you will build the user account management module and wire up shared authentication data across the entire app. This gives admins full control over who can log in, what roles they hold, and whether their account is linked to an employee record.

In this step, get ready to:
  • Create a UserController with full account management protected by the 'manage users' permission.
  • Build React pages for listing, creating, and editing user accounts with role assignment and employee linking.
  • Update the Inertia middleware to share authentication data (permissions, roles, employee) with all pages.
Create the user account controller

The UserController manages login accounts independently from employee records. It lets admins create new accounts, assign roles, and optionally link accounts to employees.

  • Generate a new controller by running this command:
php artisan make:controller UserController
  • Open app/Http/Controllers/UserController.php and replace the contents with the following code:
<?php

namespace App\Http\Controllers;

use App\Models\User;
use App\Models\Employee;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Inertia\Inertia;
use Spatie\Permission\Models\Role;

class UserController extends Controller
{
    // Display all user accounts with their roles and linked employee
    public function index()
    {
        $users = User::with(['roles', 'employee'])
            ->get()
            ->map(function ($user) {
                return [
                    'id' => $user->id,
                    'name' => $user->name,
                    'email' => $user->email,
                    'roles' => $user->getRoleNames(),
                    'employee' => $user->employee,
                    'status' => $user->status ?? 'active',
                ];
            });

        return Inertia::render('Users/Index', [
            'users' => $users,
        ]);
    }

    // Show the form for creating a new user account
    public function create()
    {
        $roles = Role::all();
        // Get employees not yet linked to any user account
        $availableEmployees = Employee::whereNull('user_id')->get();

        return Inertia::render('Users/Create', [
            'roles' => $roles,
            'availableEmployees' => $availableEmployees,
        ]);
    }

    // Store a new user account with role and optional employee link
    public function store(Request $request)
    {
        $validated = $request->validate([
            'name' => 'required|string|max:255',
            'email' => 'required|email|unique:users,email',
            'password' => 'required|string|min:8',
            'role' => 'required|string|exists:roles,name',
            'employee_id' => 'nullable|exists:employees,id',
        ]);

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

        // Assign the selected role
        $user->assignRole($validated['role']);

        // Link to employee record if specified
        if (!empty($validated['employee_id'])) {
            Employee::where('id', $validated['employee_id'])
                ->update(['user_id' => $user->id]);
        }

        return redirect()->route('users.index')
            ->with('message', 'User account created successfully.');
    }

    // Show the form for editing a user account
    public function edit(User $user)
    {
        $roles = Role::all();
        $availableEmployees = Employee::where(function ($query) use ($user) {
            $query->whereNull('user_id')
                ->orWhere('user_id', $user->id);
        })->get();

        return Inertia::render('Users/Edit', [
            'user' => [
                'id' => $user->id,
                'name' => $user->name,
                'email' => $user->email,
                'roles' => $user->getRoleNames(),
                'employee' => $user->employee,
                'status' => $user->status ?? 'active',
            ],
            'roles' => $roles,
            'availableEmployees' => $availableEmployees,
        ]);
    }

    // Update user roles, status, and employee link
    public function update(Request $request, User $user)
    {
        $validated = $request->validate([
            'role' => 'required|string|exists:roles,name',
            'status' => 'required|in:active,inactive',
            'employee_id' => 'nullable|exists:employees,id',
        ]);

        // Sync the role (remove old roles, assign new one)
        $user->syncRoles([$validated['role']]);

        // Update account status
        $user->update(['status' => $validated['status']]);

        // Handle employee linking/unlinking
        // First unlink any currently linked employee
        Employee::where('user_id', $user->id)->update(['user_id' => null]);

        // Then link the new employee if specified
        if (!empty($validated['employee_id'])) {
            Employee::where('id', $validated['employee_id'])
                ->update(['user_id' => $user->id]);
        }

        return redirect()->route('users.index')
            ->with('message', 'User account updated successfully.');
    }
}

What does this code do?

The UserController handles the complete lifecycle of user accounts in your system.

  • index() loads all users with their roles and linked employee data, mapping each into a clean array for the frontend.
  • create() passes available roles and unlinked employees to the form so admins can assign both during account creation.
  • store() creates the user, hashes the password, assigns a role via Spatie's assignRole(), and optionally links to an employee record.
  • update() uses syncRoles() to replace the current role, updates account status, and handles employee link/unlink logic.
  • Now register the routes. Open routes/web.php and add the user management routes with permission middleware by pasting this below your existing task routes:
// User account management routes (admin only)
Route::middleware(['auth', 'permission:manage users'])->group(function () {
    Route::get('/users', [\App\Http\Controllers\UserController::class, 'index'])->name('users.index');
    Route::get('/users/create', [\App\Http\Controllers\UserController::class, 'create'])->name('users.create');
    Route::post('/users', [\App\Http\Controllers\UserController::class, 'store'])->name('users.store');
    Route::get('/users/{user}/edit', [\App\Http\Controllers\UserController::class, 'edit'])->name('users.edit');
    Route::put('/users/{user}', [\App\Http\Controllers\UserController::class, 'update'])->name('users.update');
});

Why wrap routes in permission middleware?

The permission:manage users middleware uses Spatie Laravel Permission to check if the logged-in user has the 'manage users' permission before allowing access. Only users with that permission (typically admins) can reach any of these routes.

  • Save UserController.php and routes/web.php.
  • Verify the routes are registered by running:
php artisan route:list --path=users
  • You should see five routes listed: GET /users, GET /users/create, POST /users, GET /users/{user}/edit, and PUT /users/{user}.

Routes not showing up?

  • Make sure you placed the route group inside the existing routes/web.php file and not in a new file.
  • Confirm the middleware alias permission is registered in bootstrap/app.php from Step 2.
  • Check for missing use-statements or typos in the controller class path.

Why aren't my user management routes appearing in the route list?

Create the user account React pages

The admin needs three views: a table listing all user accounts, a creation form for new accounts, and an edit form to manage roles and employee links.

  • Create the resources/js/Pages/Users directory by running:
mkdir -p resources/js/Pages/Users
  • Create resources/js/Pages/Users/Index.tsx and paste the following code:
import { Head, Link } from '@inertiajs/react';

// Type definitions for user data
interface User {
    id: number;
    name: string;
    email: string;
    roles: string[];
    employee: { id: number; first_name: string; last_name: string } | null;
    status: string;
}

interface Props {
    users: User[];
}

export default function Index({ users }: Props) {
    return (
        <>
            <Head title="User Accounts" />
            <div className="p-6">
                <div className="flex justify-between items-center mb-6">
                    <h1 className="text-2xl font-bold">User Accounts</h1>
                    <Link
                        href={route('users.create')}
                        className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
                    >
                        Create User Account
                    </Link>
                </div>

                {/* User accounts table */}
                <table className="w-full border-collapse bg-white shadow rounded">
                    <thead>
                        <tr className="bg-gray-100 text-left">
                            <th className="p-3">Email</th>
                            <th className="p-3">Roles</th>
                            <th className="p-3">Linked Employee</th>
                            <th className="p-3">Status</th>
                            <th className="p-3">Actions</th>
                        </tr>
                    </thead>
                    <tbody>
                        {users.map((user) => (
                            <tr key={user.id} className="border-t">
                                <td className="p-3">{user.email}</td>
                                <td className="p-3">
                                    {/* Display roles as badges */}
                                    {user.roles.map((role) => (
                                        <span
                                            key={role}
                                            className="bg-purple-100 text-purple-800 text-xs px-2 py-1 rounded mr-1"
                                        >
                                            {role}
                                        </span>
                                    ))}
                                </td>
                                <td className="p-3">
                                    {/* Show linked employee name or placeholder */}
                                    {user.employee
                                        ? `${user.employee.first_name} ${user.employee.last_name}`
                                        : 'No employee record'}
                                </td>
                                <td className="p-3">
                                    <span
                                        className={`text-xs px-2 py-1 rounded ${
                                            user.status === 'active'
                                                ? 'bg-green-100 text-green-800'
                                                : 'bg-red-100 text-red-800'
                                        }`}
                                    >
                                        {user.status}
                                    </span>
                                </td>
                                <td className="p-3">
                                    <Link
                                        href={route('users.edit', user.id)}
                                        className="text-blue-600 hover:underline"
                                    >
                                        Edit
                                    </Link>
                                </td>
                            </tr>
                        ))}
                    </tbody>
                </table>
            </div>
        </>
    );
}

What does this page do?

  • The table displays each user's email, assigned roles as purple badges, the linked employee's full name (or "No employee record"), and an active/inactive status badge.
  • The "Create User Account" button links to the creation form.
  • Each row has an "Edit" link that navigates to the edit form for that specific user.
  • Create resources/js/Pages/Users/Create.tsx and paste the following code:
import { Head, useForm } from '@inertiajs/react';
import { FormEvent } from 'react';

// Type definitions for form props
interface Role {
    id: number;
    name: string;
}

interface Employee {
    id: number;
    first_name: string;
    last_name: string;
}

interface Props {
    roles: Role[];
    availableEmployees: Employee[];
}

export default function Create({ roles, availableEmployees }: Props) {
    // Inertia form helper manages form state and submission
    const { data, setData, post, processing, errors } = useForm({
        name: '',
        email: '',
        password: '',
        role: '',
        employee_id: '',
    });

    function handleSubmit(e: FormEvent) {
        e.preventDefault();
        post(route('users.store'));
    }

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

                <form onSubmit={handleSubmit} className="space-y-4">
                    {/* Name field */}
                    <div>
                        <label className="block font-medium mb-1">Name</label>
                        <input
                            type="text"
                            value={data.name}
                            onChange={(e) => setData('name', e.target.value)}
                            className="w-full border rounded p-2"
                        />
                        {errors.name && <p className="text-red-600 text-sm">{errors.name}</p>}
                    </div>

                    {/* Email field */}
                    <div>
                        <label className="block font-medium mb-1">Email</label>
                        <input
                            type="email"
                            value={data.email}
                            onChange={(e) => setData('email', e.target.value)}
                            className="w-full border rounded p-2"
                        />
                        {errors.email && <p className="text-red-600 text-sm">{errors.email}</p>}
                    </div>

                    {/* Password field */}
                    <div>
                        <label className="block font-medium mb-1">Password</label>
                        <input
                            type="password"
                            value={data.password}
                            onChange={(e) => setData('password', e.target.value)}
                            className="w-full border rounded p-2"
                        />
                        {errors.password && <p className="text-red-600 text-sm">{errors.password}</p>}
                    </div>

                    {/* Role selection */}
                    <div>
                        <label className="block font-medium mb-1">Role</label>
                        <select
                            value={data.role}
                            onChange={(e) => setData('role', e.target.value)}
                            className="w-full border rounded p-2"
                        >
                            <option value="">Select a role</option>
                            {roles.map((role) => (
                                <option key={role.id} value={role.name}>
                                    {role.name}
                                </option>
                            ))}
                        </select>
                        {errors.role && <p className="text-red-600 text-sm">{errors.role}</p>}
                    </div>

                    {/* Optional employee linking */}
                    <div>
                        <label className="block font-medium mb-1">Link to Employee (optional)</label>
                        <select
                            value={data.employee_id}
                            onChange={(e) => setData('employee_id', e.target.value)}
                            className="w-full border rounded p-2"
                        >
                            <option value="">No employee link</option>
                            {availableEmployees.map((emp) => (
                                <option key={emp.id} value={emp.id}>
                                    {emp.first_name} {emp.last_name}
                                </option>
                            ))}
                        </select>
                        {errors.employee_id && <p className="text-red-600 text-sm">{errors.employee_id}</p>}
                    </div>

                    <button
                        type="submit"
                        disabled={processing}
                        className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50"
                    >
                        Create Account
                    </button>
                </form>
            </div>
        </>
    );
}

What's happening here?

  • useForm from Inertia.js manages form state, submission, loading state, and server-side validation errors in one hook.
  • The role dropdown is populated from the roles passed by the controller. The employee dropdown only shows employees that are not already linked to another user account.
  • Server-side validation errors (from Laravel) automatically display below each field because Inertia shares errors back to the form.
  • Create resources/js/Pages/Users/Edit.tsx and paste the following code:
import { Head, useForm } from '@inertiajs/react';
import { FormEvent } from 'react';

// Type definitions for edit form props
interface Role {
    id: number;
    name: string;
}

interface Employee {
    id: number;
    first_name: string;
    last_name: string;
}

interface UserData {
    id: number;
    name: string;
    email: string;
    roles: string[];
    employee: Employee | null;
    status: string;
}

interface Props {
    user: UserData;
    roles: Role[];
    availableEmployees: Employee[];
}

export default function Edit({ user, roles, availableEmployees }: Props) {
    // Pre-populate form with existing user data
    const { data, setData, put, processing, errors } = useForm({
        role: user.roles[0] || '',
        status: user.status,
        employee_id: user.employee?.id?.toString() || '',
    });

    function handleSubmit(e: FormEvent) {
        e.preventDefault();
        put(route('users.update', user.id));
    }

    return (
        <>
            <Head title={`Edit User: ${user.email}`} />
            <div className="p-6 max-w-2xl">
                <h1 className="text-2xl font-bold mb-6">Edit User: {user.email}</h1>

                <form onSubmit={handleSubmit} className="space-y-4">
                    {/* Role assignment */}
                    <div>
                        <label className="block font-medium mb-1">Role</label>
                        <select
                            value={data.role}
                            onChange={(e) => setData('role', e.target.value)}
                            className="w-full border rounded p-2"
                        >
                            <option value="">Select a role</option>
                            {roles.map((role) => (
                                <option key={role.id} value={role.name}>
                                    {role.name}
                                </option>
                            ))}
                        </select>
                        {errors.role && <p className="text-red-600 text-sm">{errors.role}</p>}
                    </div>

                    {/* Account status toggle */}
                    <div>
                        <label className="block font-medium mb-1">Account Status</label>
                        <select
                            value={data.status}
                            onChange={(e) => setData('status', e.target.value)}
                            className="w-full border rounded p-2"
                        >
                            <option value="active">Active</option>
                            <option value="inactive">Inactive</option>
                        </select>
                        {errors.status && <p className="text-red-600 text-sm">{errors.status}</p>}
                    </div>

                    {/* Employee link management */}
                    <div>
                        <label className="block font-medium mb-1">Link to Employee</label>
                        <select
                            value={data.employee_id}
                            onChange={(e) => setData('employee_id', e.target.value)}
                            className="w-full border rounded p-2"
                        >
                            <option value="">No employee link (unlink)</option>
                            {availableEmployees.map((emp) => (
                                <option key={emp.id} value={emp.id}>
                                    {emp.first_name} {emp.last_name}
                                </option>
                            ))}
                        </select>
                        {errors.employee_id && <p className="text-red-600 text-sm">{errors.employee_id}</p>}
                    </div>

                    <button
                        type="submit"
                        disabled={processing}
                        className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50"
                    >
                        Update Account
                    </button>
                </form>
            </div>
        </>
    );
}

What does the edit page do?

  • The form pre-populates with the user's current role, status, and linked employee using the data passed from the controller's edit() method.
  • Setting the employee dropdown to "No employee link" triggers the unlink logic in the controller. The employee record is not deleted, just disconnected from this user account.
  • put() sends a PUT request via Inertia, which maps to the update() method on the controller.
  • Save all three files: Index.tsx, Create.tsx, and Edit.tsx.
  • In your browser, log in as the admin user and navigate to /users. You should see a table listing user accounts with their email, role badges, linked employee names (or "No employee record"), and status badges.

Seeing a blank page or 403 error?

  • A 403 means the logged-in user does not have the 'manage users' permission. Log in as the admin user seeded in Step 3.
  • A blank page may mean the React component path doesn't match. Confirm the file is at resources/js/Pages/Users/Index.tsx (capital U in Users, capital I in Index).
  • If you see TypeScript errors, run npm run build to check for compile errors in the terminal output.

Why am I getting a 403 or blank page on the /users route?

Share auth data via Inertia middleware

Right now each controller manually decides what user data to pass to the frontend. For role-based UI rendering (showing or hiding navigation links, enabling admin-only buttons), every page needs access to the current user's permissions and roles.

Inertia's shared data feature solves this by injecting data into every page response from a single middleware file.

  • Open app/Http/Middleware/HandleInertiaRequests.php and find the share() method.
  • Replace the entire share() method with the following code:
public function share(Request $request): array
{
    return array_merge(parent::share($request), [
        // Share the authenticated user's permissions, roles, and employee data
        'auth.user' => fn () => $request->user()
            ? [
                'id' => $request->user()->id,
                'name' => $request->user()->name,
                'email' => $request->user()->email,
                'permissions' => $request->user()->getAllPermissions()->pluck('name')->toArray(),
                'roles' => $request->user()->getRoleNames()->toArray(),
                'employee' => $request->user()->employee,
            ]
            : null,
    ]);
}

What does this code do?

The share() method in HandleInertiaRequests runs on every request and merges data into the page props for all React pages.

  • getAllPermissions()->pluck('name')->toArray() returns a flat array of permission names (e.g. ['manage users', 'view employees']) that includes both direct and role-inherited permissions.
  • getRoleNames()->toArray() returns the user's role names as an array (e.g. ['admin']).
  • The employee key returns the linked employee model (or null if the user has no employee record). This enables the frontend to show employee-specific data without extra API calls.

How do I access this data in React?

In any React page or component, use usePage().props.auth.user to access the shared auth data. For example, you can conditionally render navigation links based on auth.user.permissions.includes('manage users').

  • Save HandleInertiaRequests.php.
  • Refresh your browser while logged in. Open your browser's developer tools (F12) and go to the Console tab.
  • In the React DevTools or console, check that the page props include auth.user.permissions and auth.user.roles arrays. The admin user should show all permissions.

Permissions array is empty?

  • Make sure the User model has the HasRoles trait from Step 2. Without it, getAllPermissions() and getRoleNames() are not available.
  • Confirm the seeder from Step 3 ran successfully and assigned roles to users. Run php artisan db:seed again if needed.

Why is my auth.user.permissions array empty even though I seeded roles?

✔️ Awesome, I've got everything!

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

ⓧ I'd like to double check the full code

Here is the full code for each file created or modified in this step.

<?php

namespace App\Http\Controllers;

use App\Models\User;
use App\Models\Employee;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Inertia\Inertia;
use Spatie\Permission\Models\Role;

class UserController extends Controller
{
    // Display all user accounts with their roles and linked employee
    public function index()
    {
        $users = User::with(['roles', 'employee'])
            ->get()
            ->map(function ($user) {
                return [
                    'id' => $user->id,
                    'name' => $user->name,
                    'email' => $user->email,
                    'roles' => $user->getRoleNames(),
                    'employee' => $user->employee,
                    'status' => $user->status ?? 'active',
                ];
            });

        return Inertia::render('Users/Index', [
            'users' => $users,
        ]);
    }

    // Show the form for creating a new user account
    public function create()
    {
        $roles = Role::all();
        // Get employees not yet linked to any user account
        $availableEmployees = Employee::whereNull('user_id')->get();

        return Inertia::render('Users/Create', [
            'roles' => $roles,
            'availableEmployees' => $availableEmployees,
        ]);
    }

    // Store a new user account with role and optional employee link
    public function store(Request $request)
    {
        $validated = $request->validate([
            'name' => 'required|string|max:255',
            'email' => 'required|email|unique:users,email',
            'password' => 'required|string|min:8',
            'role' => 'required|string|exists:roles,name',
            'employee_id' => 'nullable|exists:employees,id',
        ]);

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

        // Assign the selected role
        $user->assignRole($validated['role']);

        // Link to employee record if specified
        if (!empty($validated['employee_id'])) {
            Employee::where('id', $validated['employee_id'])
                ->update(['user_id' => $user->id]);
        }

        return redirect()->route('users.index')
            ->with('message', 'User account created successfully.');
    }

    // Show the form for editing a user account
    public function edit(User $user)
    {
        $roles = Role::all();
        $availableEmployees = Employee::where(function ($query) use ($user) {
            $query->whereNull('user_id')
                ->orWhere('user_id', $user->id);
        })->get();

        return Inertia::render('Users/Edit', [
            'user' => [
                'id' => $user->id,
                'name' => $user->name,
                'email' => $user->email,
                'roles' => $user->getRoleNames(),
                'employee' => $user->employee,
                'status' => $user->status ?? 'active',
            ],
            'roles' => $roles,
            'availableEmployees' => $availableEmployees,
        ]);
    }

    // Update user roles, status, and employee link
    public function update(Request $request, User $user)
    {
        $validated = $request->validate([
            'role' => 'required|string|exists:roles,name',
            'status' => 'required|in:active,inactive',
            'employee_id' => 'nullable|exists:employees,id',
        ]);

        // Sync the role (remove old roles, assign new one)
        $user->syncRoles([$validated['role']]);

        // Update account status
        $user->update(['status' => $validated['status']]);

        // Handle employee linking/unlinking
        // First unlink any currently linked employee
        Employee::where('user_id', $user->id)->update(['user_id' => null]);

        // Then link the new employee if specified
        if (!empty($validated['employee_id'])) {
            Employee::where('id', $validated['employee_id'])
                ->update(['user_id' => $user->id]);
        }

        return redirect()->route('users.index')
            ->with('message', 'User account updated successfully.');
    }
}
// User account management routes (admin only)
Route::middleware(['auth', 'permission:manage users'])->group(function () {
    Route::get('/users', [\App\Http\Controllers\UserController::class, 'index'])->name('users.index');
    Route::get('/users/create', [\App\Http\Controllers\UserController::class, 'create'])->name('users.create');
    Route::post('/users', [\App\Http\Controllers\UserController::class, 'store'])->name('users.store');
    Route::get('/users/{user}/edit', [\App\Http\Controllers\UserController::class, 'edit'])->name('users.edit');
    Route::put('/users/{user}', [\App\Http\Controllers\UserController::class, 'update'])->name('users.update');
});
import { Head, Link } from '@inertiajs/react';

// Type definitions for user data
interface User {
    id: number;
    name: string;
    email: string;
    roles: string[];
    employee: { id: number; first_name: string; last_name: string } | null;
    status: string;
}

interface Props {
    users: User[];
}

export default function Index({ users }: Props) {
    return (
        <>
            <Head title="User Accounts" />
            <div className="p-6">
                <div className="flex justify-between items-center mb-6">
                    <h1 className="text-2xl font-bold">User Accounts</h1>
                    <Link
                        href={route('users.create')}
                        className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
                    >
                        Create User Account
                    </Link>
                </div>

                {/* User accounts table */}
                <table className="w-full border-collapse bg-white shadow rounded">
                    <thead>
                        <tr className="bg-gray-100 text-left">
                            <th className="p-3">Email</th>
                            <th className="p-3">Roles</th>
                            <th className="p-3">Linked Employee</th>
                            <th className="p-3">Status</th>
                            <th className="p-3">Actions</th>
                        </tr>
                    </thead>
                    <tbody>
                        {users.map((user) => (
                            <tr key={user.id} className="border-t">
                                <td className="p-3">{user.email}</td>
                                <td className="p-3">
                                    {/* Display roles as badges */}
                                    {user.roles.map((role) => (
                                        <span
                                            key={role}
                                            className="bg-purple-100 text-purple-800 text-xs px-2 py-1 rounded mr-1"
                                        >
                                            {role}
                                        </span>
                                    ))}
                                </td>
                                <td className="p-3">
                                    {/* Show linked employee name or placeholder */}
                                    {user.employee
                                        ? `${user.employee.first_name} ${user.employee.last_name}`
                                        : 'No employee record'}
                                </td>
                                <td className="p-3">
                                    <span
                                        className={`text-xs px-2 py-1 rounded ${
                                            user.status === 'active'
                                                ? 'bg-green-100 text-green-800'
                                                : 'bg-red-100 text-red-800'
                                        }`}
                                    >
                                        {user.status}
                                    </span>
                                </td>
                                <td className="p-3">
                                    <Link
                                        href={route('users.edit', user.id)}
                                        className="text-blue-600 hover:underline"
                                    >
                                        Edit
                                    </Link>
                                </td>
                            </tr>
                        ))}
                    </tbody>
                </table>
            </div>
        </>
    );
}
import { Head, useForm } from '@inertiajs/react';
import { FormEvent } from 'react';

// Type definitions for form props
interface Role {
    id: number;
    name: string;
}

interface Employee {
    id: number;
    first_name: string;
    last_name: string;
}

interface Props {
    roles: Role[];
    availableEmployees: Employee[];
}

export default function Create({ roles, availableEmployees }: Props) {
    // Inertia form helper manages form state and submission
    const { data, setData, post, processing, errors } = useForm({
        name: '',
        email: '',
        password: '',
        role: '',
        employee_id: '',
    });

    function handleSubmit(e: FormEvent) {
        e.preventDefault();
        post(route('users.store'));
    }

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

                <form onSubmit={handleSubmit} className="space-y-4">
                    {/* Name field */}
                    <div>
                        <label className="block font-medium mb-1">Name</label>
                        <input
                            type="text"
                            value={data.name}
                            onChange={(e) => setData('name', e.target.value)}
                            className="w-full border rounded p-2"
                        />
                        {errors.name && <p className="text-red-600 text-sm">{errors.name}</p>}
                    </div>

                    {/* Email field */}
                    <div>
                        <label className="block font-medium mb-1">Email</label>
                        <input
                            type="email"
                            value={data.email}
                            onChange={(e) => setData('email', e.target.value)}
                            className="w-full border rounded p-2"
                        />
                        {errors.email && <p className="text-red-600 text-sm">{errors.email}</p>}
                    </div>

                    {/* Password field */}
                    <div>
                        <label className="block font-medium mb-1">Password</label>
                        <input
                            type="password"
                            value={data.password}
                            onChange={(e) => setData('password', e.target.value)}
                            className="w-full border rounded p-2"
                        />
                        {errors.password && <p className="text-red-600 text-sm">{errors.password}</p>}
                    </div>

                    {/* Role selection */}
                    <div>
                        <label className="block font-medium mb-1">Role</label>
                        <select
                            value={data.role}
                            onChange={(e) => setData('role', e.target.value)}
                            className="w-full border rounded p-2"
                        >
                            <option value="">Select a role</option>
                            {roles.map((role) => (
                                <option key={role.id} value={role.name}>
                                    {role.name}
                                </option>
                            ))}
                        </select>
                        {errors.role && <p className="text-red-600 text-sm">{errors.role}</p>}
                    </div>

                    {/* Optional employee linking */}
                    <div>
                        <label className="block font-medium mb-1">Link to Employee (optional)</label>
                        <select
                            value={data.employee_id}
                            onChange={(e) => setData('employee_id', e.target.value)}
                            className="w-full border rounded p-2"
                        >
                            <option value="">No employee link</option>
                            {availableEmployees.map((emp) => (
                                <option key={emp.id} value={emp.id}>
                                    {emp.first_name} {emp.last_name}
                                </option>
                            ))}
                        </select>
                        {errors.employee_id && <p className="text-red-600 text-sm">{errors.employee_id}</p>}
                    </div>

                    <button
                        type="submit"
                        disabled={processing}
                        className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50"
                    >
                        Create Account
                    </button>
                </form>
            </div>
        </>
    );
}
import { Head, useForm } from '@inertiajs/react';
import { FormEvent } from 'react';

// Type definitions for edit form props
interface Role {
    id: number;
    name: string;
}

interface Employee {
    id: number;
    first_name: string;
    last_name: string;
}

interface UserData {
    id: number;
    name: string;
    email: string;
    roles: string[];
    employee: Employee | null;
    status: string;
}

interface Props {
    user: UserData;
    roles: Role[];
    availableEmployees: Employee[];
}

export default function Edit({ user, roles, availableEmployees }: Props) {
    // Pre-populate form with existing user data
    const { data, setData, put, processing, errors } = useForm({
        role: user.roles[0] || '',
        status: user.status,
        employee_id: user.employee?.id?.toString() || '',
    });

    function handleSubmit(e: FormEvent) {
        e.preventDefault();
        put(route('users.update', user.id));
    }

    return (
        <>
            <Head title={`Edit User: ${user.email}`} />
            <div className="p-6 max-w-2xl">
                <h1 className="text-2xl font-bold mb-6">Edit User: {user.email}</h1>

                <form onSubmit={handleSubmit} className="space-y-4">
                    {/* Role assignment */}
                    <div>
                        <label className="block font-medium mb-1">Role</label>
                        <select
                            value={data.role}
                            onChange={(e) => setData('role', e.target.value)}
                            className="w-full border rounded p-2"
                        >
                            <option value="">Select a role</option>
                            {roles.map((role) => (
                                <option key={role.id} value={role.name}>
                                    {role.name}
                                </option>
                            ))}
                        </select>
                        {errors.role && <p className="text-red-600 text-sm">{errors.role}</p>}
                    </div>

                    {/* Account status toggle */}
                    <div>
                        <label className="block font-medium mb-1">Account Status</label>
                        <select
                            value={data.status}
                            onChange={(e) => setData('status', e.target.value)}
                            className="w-full border rounded p-2"
                        >
                            <option value="active">Active</option>
                            <option value="inactive">Inactive</option>
                        </select>
                        {errors.status && <p className="text-red-600 text-sm">{errors.status}</p>}
                    </div>

                    {/* Employee link management */}
                    <div>
                        <label className="block font-medium mb-1">Link to Employee</label>
                        <select
                            value={data.employee_id}
                            onChange={(e) => setData('employee_id', e.target.value)}
                            className="w-full border rounded p-2"
                        >
                            <option value="">No employee link (unlink)</option>
                            {availableEmployees.map((emp) => (
                                <option key={emp.id} value={emp.id}>
                                    {emp.first_name} {emp.last_name}
                                </option>
                            ))}
                        </select>
                        {errors.employee_id && <p className="text-red-600 text-sm">{errors.employee_id}</p>}
                    </div>

                    <button
                        type="submit"
                        disabled={processing}
                        className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50"
                    >
                        Update Account
                    </button>
                </form>
            </div>
        </>
    );
}
public function share(Request $request): array
{
    return array_merge(parent::share($request), [
        // Share the authenticated user's permissions, roles, and employee data
        'auth.user' => fn () => $request->user()
            ? [
                'id' => $request->user()->id,
                'name' => $request->user()->name,
                'email' => $request->user()->email,
                'permissions' => $request->user()->getAllPermissions()->pluck('name')->toArray(),
                'roles' => $request->user()->getRoleNames()->toArray(),
                'employee' => $request->user()->employee,
            ]
            : null,
    ]);
}

Your user account management module is fully functional. Admins can create accounts, assign roles, and link them to employee records. The shared auth data now powers role-based UI rendering across your entire app. Next up, you will deploy this complete system to SiteGround shared hosting.

Deploy to SiteGround Shared Hosting

You have a fully working corporate project management system running locally. The next goal is to make it accessible on the internet by deploying it to your SiteGround shared hosting account.

Shared hosting serves files from a single document root called public_html. Laravel stores its application code outside the web root for security, so you will clone your project into a private directory and point public_html at the compiled assets only.

In this step, get ready to:
  • Build and commit your production-ready application to GitHub.
  • Clone and configure the application on SiteGround via SSH.
  • Wire up the public directory and verify the live site.
Prepare the build for production

Before pushing code to a live server, you need to compile the React frontend into static files and switch Laravel out of debug mode. This prevents sensitive error details from appearing to visitors.

  • Open your .env file in the project root (corporate-pm/.env).
  • Change APP_ENV to production and APP_DEBUG to false.
# In your .env file, update these two lines:
APP_ENV=production
APP_DEBUG=false

Why set APP_DEBUG to false?

When APP_DEBUG is true, Laravel shows full stack traces and environment variables on error pages. On a public site, this would expose your database credentials and app secrets to anyone who triggers an error.

  • Compile the frontend assets for production by running this command in your project's root directory:
npm run build

What does this command do?

Vite compiles and minifies all your React components, TypeScript, and Tailwind CSS into optimized static files. The output goes into public/build/ as JavaScript bundles and CSS files ready for a browser to load directly.

  • Confirm the build succeeded by checking that a public/build/ directory now exists:
ls public/build

You should see manifest.json and an assets folder containing .js and .css files.

  • Initialize a Git repository and commit all your code by running these commands:
git init
git add .
git commit -m "Initial commit - corporate PM system"
  • Create a new repository on GitHub (name it corporate-pm or similar).
  • Push your code to GitHub by running:
git remote add origin [[YOUR_REPO_URL="https://github.com/YOUR_USERNAME/corporate-pm.git"]]
git branch -M main
git push -u origin main

What does pushing to GitHub accomplish?

GitHub acts as a bridge between your local machine and the server. Once your code is on GitHub, you can clone it directly onto SiteGround without manually uploading files.

Save your .env changes and confirm the push completed with no errors in your terminal.

Push failing with authentication errors?

GitHub requires a personal access token (not your password) for HTTPS pushes. Generate a token in GitHub under Settings > Developer settings > Personal access tokens and use it as your password when prompted.

Help me fix my Git push to GitHub.

Upload and configure on SiteGround

With your code on GitHub, you can now pull it onto your SiteGround server. SiteGround uses key-based SSH authentication on port 18765. You will also create a MySQL database through Site Tools.

  • Log in to your SiteGround account and open Site Tools for your website.
  • Navigate to Site > MySQL.
  • Click the Databases tab, then click Create Database. Note the auto-generated database name.
  • Click the Users tab, then click Create User. Note the auto-generated username and password.
  • Under Manage Users, select the user you just created and click Add New Database. Select all privileges and click Confirm.

Why create a dedicated database user?

Each application should have its own database user with only the permissions it needs. This limits damage if credentials are ever exposed.

  • Navigate to Devs > SSH Keys Manager in Site Tools.
  • Generate an SSH key pair if you have not already. Copy the private key and save it to a file on your local machine.
  • Under Manage SSH Keys, click the three-dot menu next to your key and select SSH Credentials. Note the hostname, username, and port.
  • Connect to your SiteGround server via SSH. SiteGround uses port 18765:
ssh [[SSH_USER="your-ssh-username"]]@[[SSH_HOST="your-hostname"]] -p18765

Connection timed out or refused?

Make sure you are using port 18765, not the default SSH port 22. Also verify you have loaded your private key into your SSH agent first.

Help me connect to SiteGround via SSH.

  • Once connected, clone your repository into a directory outside public_html by running:
cd ~
git clone [[YOUR_REPO_URL="https://github.com/YOUR_USERNAME/corporate-pm.git"]] corporate-pm
  • Move into the project directory and install PHP dependencies (without dev packages) by running:
cd ~/corporate-pm
composer install --no-dev --optimize-autoloader

What does --no-dev do?

The --no-dev flag skips development-only packages (testing tools, debug bars). This keeps your production server lean and avoids exposing debugging utilities.

You should see Composer download and install all required packages. This may take a minute.

  • Create the production .env file on the server by running:
cp .env.example .env
nano .env
  • Update the .env file with your production values. Replace the database placeholders with the credentials you noted from Site Tools:
APP_ENV=production
APP_DEBUG=false
APP_URL=https://your-domain.com

DB_CONNECTION=mysql
DB_HOST=localhost
DB_PORT=3306
DB_DATABASE=your-siteground-db-name
DB_USERNAME=your-siteground-db-user
DB_PASSWORD=your-siteground-db-password

Where do I find my database credentials?

Go back to Site Tools > Site > MySQL. Your database name appears on the Databases tab. Your username and password appear on the Users tab (the password was shown once when you created the user).

  • Generate the application key by running:
php artisan key:generate
  • Run the database migrations and seeders to set up your tables and sample data:
php artisan migrate --force
php artisan db:seed --force

Why the --force flag?

Laravel protects production databases by requiring the --force flag to run migrations and seeders when APP_ENV is set to production. This prevents accidental data changes.

You should see the migration output listing each table being created, followed by the seeder output.

Seeing a database connection error?

Double-check that the DB_HOST is set to localhost and DB_PORT is 3306. Also ensure the database user has been assigned to the database with full privileges in Site Tools.

Help me debug my Laravel database connection on SiteGround.

Configure the public directory and test

SiteGround serves your site from the public_html directory. You need to copy the contents of Laravel's public/ folder there and update index.php to point back to your Laravel app directory.

  • Copy the contents of your Laravel public/ directory into public_html by running:
cp -r ~/corporate-pm/public/* ~/public_html/
cp -r ~/corporate-pm/public/.htaccess ~/public_html/
  • Make sure the compiled frontend assets are in place by confirming the build/ directory was copied:
ls ~/public_html/build

You should see the manifest.json and assets folder listed.

  • Update index.php in public_html to point to your Laravel application directory. Open the file:
nano ~/public_html/index.php
  • Find the two require lines near the top of the file. They currently look like this:
require __DIR__.'/../vendor/autoload.php';
$app = require_once __DIR__.'/../bootstrap/app.php';
  • Replace them so they point to your corporate-pm directory:
require __DIR__.'/../corporate-pm/vendor/autoload.php';
$app = require_once __DIR__.'/../corporate-pm/bootstrap/app.php';

What do these path changes do?

The original index.php expects the Laravel app to be one directory above public/. Since you moved public/ contents into public_html, you now need to tell it the real location of your application code in ~/corporate-pm.

  • Save the file and exit the editor (in nano: press Ctrl+X, then Y, then Enter).
  • Set the storage directory permissions so Laravel can write logs and cache:
chmod -R 775 ~/corporate-pm/storage
chmod -R 775 ~/corporate-pm/bootstrap/cache
  • Open your browser and navigate to your domain (e.g., https://yourdomain.com).
  • You should see the Laravel login page.
  • Log in with the admin credentials from your seeder.
  • Navigate through the application. Confirm that roles load on the Role Management page, employees appear on the Employees page, and the dashboard shows stats.

Seeing a blank page or 500 error?

Check the Laravel log file for details:

cat ~/corporate-pm/storage/logs/laravel.log | tail -20

Common causes: incorrect index.php paths (typo in the directory name), missing .htaccess file in public_html, or storage directory permissions too restrictive.

Help me fix my Laravel 500 error on SiteGround.

Secret mission

Add a Project Activity Feed

Your corporate PM system tracks projects, tasks, and employees. But right now, nobody can see what's happening across the company without checking each module individually. Build a real-time activity feed that logs every important action and displays it in a company-wide timeline.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project uses your existing SiteGround hosting plan, so there are no additional ongoing costs beyond your plan subscription.

Resources you used:

  • A MySQL database on SiteGround (included with your hosting plan, no extra cost).
  • Files uploaded to SiteGround shared hosting (the Laravel app directory and public_html contents).
  • The local corporate-pm project folder on your Desktop.

Keep everything running

No action needed. Choose this if you are still actively building or want to keep the live site available.

  • Your app remains live on SiteGround as long as your hosting plan is active.
  • The MySQL database stays available at no additional cost beyond your plan.
  • Your local corporate-pm folder stays on your Desktop for future development.

Pause - I'll come back to this later

No action needed for shared hosting. Your site stays available unless you delete files. SiteGround shared hosting does not have a separate "pause" state.

  • Your live site continues to run on SiteGround with no extra charges.
  • You can return to develop locally at any time by opening the corporate-pm folder in VS Code.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow each step below to fully clean up.

  • Delete the MySQL database: In SiteGround Site Tools, navigate to Site, then MySQL, then Databases. In the Manage Databases section, find the database you created for this project. Click the kebab menu (three dots) next to it, select Delete, then click Confirm on the confirmation page.
  • Delete the Laravel app directory on SiteGround: SSH into your SiteGround server and remove the project directory by running the command below.
rm -rf ~/corporate-pm
  • Delete the public_html project files: Remove the application files you placed in public_html by running the commands below. Be careful to only remove files related to this project if you have other sites on the same hosting.
rm -rf ~/public_html/build
rm ~/public_html/index.php
  • Delete the local project folder: On your own machine, open a terminal and run the command for your operating system.

macOS/Linux:

rm -rf ~/Desktop/corporate-pm

Windows:

Remove-Item -Recurse -Force ~\Desktop\corporate-pm

That's a Wrap!

That's a Wrap!

Nice work! You just shipped a full-stack corporate project management system with separated user/employee architecture, multiple CRUD modules, dynamic role management, and shared hosting deployment.

You've learned how to:

  • Built a Laravel 12 application with a React 19 frontend using Inertia.js 3, including authentication, role-based dashboards, and production deployment to SiteGround shared hosting.
  • Architected a separated User (authentication) and Employee (HR data) system with optional linking, proving you can model complex business relationships with Eloquent relationships.
  • Built dynamic role and permission management where admins create custom roles from the UI using Spatie Laravel Permission, with full CRUD modules for employees, departments, projects, and tasks.
  • Added a company-wide activity feed tracking all system changes using polymorphic relationships and a reusable LogsActivity trait.

Ready to quiz yourself?