Multi-Tenant Task Manager with Laravel

Build a multi-tenant SaaS task manager with subdomain isolation and role-based access.

Introduction

30 Second Summary

Every team productivity tool you've ever used, whether it's Asana, Linear, or Monday.com, solves the same core challenge. Multiple companies share one application, but each company's data stays completely invisible to the others.

In this project, you will build Clusterfiy, a multi-tenant team task manager where each company gets its own subdomain with isolated data, a 5-level role permission system, and an interactive Kanban board.

What You'll Build

Clusterfiy is a Laravel 13 application with subdomain-based multi-tenancy, role-based access control, and a Livewire + Alpine.js task board.

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

  • A working subdomain-based tenant isolation system where each company (e.g., acme.clusterfiy.test) sees only its own tasks and users.
  • A 5-level role permission hierarchy (Super Admin, Company Admin, Project Lead, Member, Viewer) controlling who can create, assign, and manage tasks.
  • An interactive Kanban-style task board with drag-and-drop status changes, priority badges, and assignee avatars built with Livewire and Alpine.js.
  • Secret Mission: an activity log timeline that records every task status change with user attribution and relative timestamps.

Are there any prerequisites?

You should have a basic understanding of what PHP is and how web apps work (routes serve pages, databases store data). No Laravel experience is required.

You'll also need Laragon installed on Windows with PHP, MySQL, Apache, and Composer ready to go. Node.js is required for building frontend assets.

Before We Start

Before We Start...

Install Laravel 13 and Jetstream with Teams

Every multi-tenant app starts with a solid foundation. Before you can build subdomain routing, role permissions, or a Kanban board, you need a framework that handles authentication, team management, and component rendering out of the box.

In this step, you will install Laravel 13 and Jetstream with the Livewire + Teams stack. This gives you a full authentication system, team switching, and dark mode right from the start.

In this step, get ready to:
  • Verify your Laragon environment and install the Laravel installer.
  • Create the Clusterfiy project and install Jetstream with Livewire and Teams.
  • Finalize the installation and confirm the app runs with team switching.
Verify Laragon and install the Laravel installer

Laragon bundles PHP, Composer, and MySQL in one package. Before creating the project, you need to confirm these tools are accessible in the terminal and then install the Laravel CLI installer.

  • Make sure Laragon is running. If you do not see its icon in the system tray (near the clock), press the Windows key, type Laragon, and press Enter to launch it.
  • Right-click the Laragon icon in the system tray.
  • Click Terminal to open the Laragon Terminal.

What is the Laragon Terminal?

Laragon Terminal is a Cmder-based command line that has PHP, Composer, Node.js, and Git already on its PATH. It opens in C:\laragon\www by default, which is where all your projects live.

  • Verify PHP is available by running this command:
php -v
  • You should see a version number starting with 8.3 or higher (Laragon ships with PHP 8.5).
  • Verify Composer is available by running:
composer --version
  • You should see Composer version followed by a number.

Command not found?

If either command is not recognized, Laragon's PATH may not be set. Close the terminal, right-click the Laragon tray icon, then go to Tools then Path then Add Laragon to Path. Reopen the terminal and try again.

help me fix my Laragon Terminal PATH so PHP and Composer are recognized

  • Install the Laravel installer globally by running:
composer global require laravel/installer

What does this command do?

This installs the laravel command as a global Composer package. You only need to do this once. After this, the laravel command is available in any Laragon Terminal session.

  • Confirm the installer works by running:
laravel --version
  • You should see the Laravel Installer version number in the output.

laravel command not found after install?

Composer's global bin directory may not be on your PATH. Close and reopen the Laragon Terminal. If it still does not work, check that Composer's vendor bin path is included in Laragon's PATH settings.

help me get the laravel command working after installing it with Composer global require

Create the Clusterfiy project with Jetstream

Now you will create the Laravel 13 project and layer Jetstream on top of it. Jetstream must be installed into a fresh Laravel application.

  • Create the Laravel project by running:
composer create-project laravel/laravel clusterfiy

Why Composer create-project?

This command downloads the latest Laravel 13 release and sets up the full project structure in a clusterfiy folder inside C:\laragon\www. Because Laragon Terminal starts in the www directory, the project lands exactly where Laragon expects it.

  • Move into the new project folder by running:
cd clusterfiy
  • Install Jetstream as a dependency by running:
composer require laravel/jetstream
  • You should see Composer download and install the laravel/jetstream package.
  • Scaffold Jetstream with Livewire, Teams, and Dark Mode by running:
php artisan jetstream:install livewire --teams --dark

What does this command do?

This single command scaffolds three major features at once:

  • Livewire gives you reactive, server-rendered components without writing JavaScript.
  • Teams adds team creation, team switching, member invitations, and team-scoped roles. This is the foundation for multi-tenancy.
  • Dark Mode pre-configures Tailwind CSS for dark theme support.
  • You should see output confirming that Livewire, Jetstream views, and team scaffolding have been published.

Seeing a "Jetstream should only be installed into new Laravel applications" error?

This means the project already has auth scaffolding or a conflicting package. Make sure you ran composer create-project laravel/laravel clusterfiy to start from a completely fresh project.

help me fix the Jetstream installation error about not being a new Laravel application

Finalize installation and verify

Jetstream's frontend depends on Node.js packages for building CSS and JavaScript assets. You also need to run the database migrations so all of Jetstream's tables (users, teams, team invitations) are created.

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

What does this command do?

The first half (npm install) downloads all JavaScript and CSS dependencies listed in package.json. The second half (npm run build) compiles Tailwind CSS and any JavaScript into production-ready files that the browser can load.

  • You should see a success message from Vite confirming the build completed.

Next, you need to configure the database connection and run migrations.

  • Confirm Laragon's MySQL is running. Right-click the Laragon tray icon and check that MySQL shows a green indicator. If it does not, click Start All.
  • Open the .env file in your clusterfiy folder with your code editor.
  • Find the DB_DATABASE line and change its value to clusterfiy.
  • Confirm DB_CONNECTION is set to mysql, DB_USERNAME is root, and DB_PASSWORD is empty. These are Laragon's default MySQL credentials.
  • Save the .env file.

Why change the database name?

Laravel defaults to a database called laravel. Naming it clusterfiy keeps things organized, especially if you have multiple projects in Laragon.

  • Run the database migrations by running:
php artisan migrate
  • Laravel will ask if you want to create the database since it does not exist yet. Type yes and press Enter.
  • You should see a list of migrations being run, including create_users_table, create_teams_table, and create_team_invitations_table.

Migration fails with connection refused?

This means MySQL is not running. Right-click the Laragon tray icon and click Start All. Wait a few seconds, then run php artisan migrate again.

help me fix my Laravel database connection so migrations can run

  • Start the development server by running:
php artisan serve
  • You should see output showing the server started at http://localhost:8000.
  • Open your browser and visit http://localhost:8000.
  • Click Register in the top right corner.
  • Fill in a name, email, and password, then click Register.
  • Confirm you see the Jetstream dashboard with your name and a team name in the top right corner.

What is the team in the top right?

Jetstream automatically creates a "Personal Team" for every new user. You can click your name to see team switching options. In later steps, you will replace these personal teams with company teams that map to subdomains.

Page loads but styles are broken?

This usually means the asset build did not complete. Go back to your terminal, stop the server with Ctrl+C, run npm run build again, then restart with php artisan serve.

help me fix broken styles on my Jetstream dashboard

Your Laravel 13 foundation is ready with authentication, teams, and Livewire. Next up, you will configure subdomain routing so each team gets its own isolated URL.

Configure Subdomain-Based Tenant Routing

Your Clusterfiy app is running, but right now every user sees the same dashboard at the same URL. In a real multi-tenant SaaS like Linear or Asana, each company gets its own subdomain.

In this step, you will configure Laragon to handle wildcard subdomains, then build a custom middleware that maps each subdomain to a team. By the end, visiting acme.clusterfiy.test will load the Acme team's data while stark.clusterfiy.test loads Stark's.

In this step, get ready to:
  • Configure Laragon's Apache vhost for wildcard subdomains.
  • Create tenant resolution middleware that maps subdomains to teams.
  • Set up central and tenant route groups with subdomain isolation.
Configure Laragon for wildcard subdomains

Laragon auto-generates an Apache virtual host config for each project in its www directory. You need to customize this config so that any subdomain of clusterfiy.test also points to your project.

  • Open the Laragon Apache vhost config folder. Navigate to:
C:\laragon\etc\apache2\sites-enabled\
  • Find the file named auto.clusterfiy.test.conf.
  • Rename it to clusterfiy.test.conf (remove the auto. prefix). This prevents Laragon from overwriting your custom config.
  • Open clusterfiy.test.conf in Cursor and replace its contents with:
<VirtualHost *:80>
    # Main domain and wildcard alias for all subdomains
    ServerName clusterfiy.test
    ServerAlias *.clusterfiy.test

    # Point to the Laravel public directory
    DocumentRoot "C:/laragon/www/clusterfiy/public"

    <Directory "C:/laragon/www/clusterfiy/public">
        AllowOverride All
        Require all granted
    </Directory>
</VirtualHost>

What does this config do?

ServerName clusterfiy.test tells Apache this vhost handles the main domain.

ServerAlias *.clusterfiy.test is a wildcard that catches every subdomain (like acme.clusterfiy.test or stark.clusterfiy.test) and routes them to the same project.

  • Save the vhost config file.

Next, you need to update the Windows hosts file. Windows does not support wildcard entries in the hosts file, so each subdomain must be listed explicitly.

  • Open Notepad as Administrator (right-click Notepad, select Run as administrator).
  • Click File, then Open.
  • Navigate to C:\Windows\System32\drivers\etc\ and change the file filter from "Text Documents" to All Files.
  • Open the hosts file and add these three lines at the bottom:
127.0.0.1 clusterfiy.test
127.0.0.1 acme.clusterfiy.test
127.0.0.1 stark.clusterfiy.test

Why do I need to edit the hosts file?

The hosts file maps domain names to IP addresses on your local machine. Without these entries, your browser would try to look up acme.clusterfiy.test on the internet and fail.

By pointing them to 127.0.0.1 (localhost), your browser sends those requests to your local Laragon Apache server instead.

  • Save the hosts file and close Notepad.
  • Restart Laragon's Apache by right-clicking the Laragon tray icon, selecting Apache, then Reload.
  • Open your browser and visit http://clusterfiy.test. You should see the Laravel welcome page.

Not seeing the Laravel page?

  • Make sure you removed the auto. prefix from the vhost config filename. If the auto. prefix remains, Laragon will overwrite your changes on restart.
  • Confirm the DocumentRoot path matches your actual project location. If your Laragon root is different from C:\laragon, update the path accordingly.
  • Check that you saved the hosts file with Administrator privileges. Without admin access, the save will silently fail.

Help me troubleshoot my Laragon subdomain configuration.

Create tenant resolution middleware

Now that Apache routes all subdomains to your Laravel app, you need middleware that intercepts each request, reads which subdomain it came from, and loads the corresponding team.

The middleware will use a slug column on the teams table to match subdomains to teams. First, you need to add that column.

  • Generate a migration to add the slug column by running this in your Laragon Terminal:
php artisan make:migration add_slug_to_teams_table --table=teams
  • Open the newly created migration file in database/migrations/ and add the slug column:
<?php

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

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('teams', function (Blueprint $table) {
            // Add a unique slug column for subdomain resolution
            $table->string('slug')->unique()->after('name');
        });
    }

    public function down(): void
    {
        Schema::table('teams', function (Blueprint $table) {
            $table->dropColumn('slug');
        });
    }
};

What does this migration do?

This adds a slug column to the teams table. The slug is a URL-friendly version of the team name (e.g., "acme" for "Acme Corp"). Your middleware will match subdomains against this column.

  • Run the migration by executing:
php artisan migrate
  • You should see a message confirming the migration ran successfully.

Now create the middleware itself.

  • Create a new file at app/Http/Middleware/ResolveTenant.php and add the following code:
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use App\Models\Team;
use Symfony\Component\HttpFoundation\Response;

class ResolveTenant
{
    /**
     * Extract the subdomain and resolve the matching team.
     */
    public function handle(Request $request, Closure $next): Response
    {
        // Get the full hostname from the request
        $host = $request->getHost();
        $parts = explode('.', $host);

        // If there are only 2 parts (e.g., clusterfiy.test), no subdomain exists
        if (count($parts) <= 2) {
            return $next($request);
        }

        // The subdomain is the first segment (e.g., "acme" from "acme.clusterfiy.test")
        $subdomain = $parts[0];

        // Look up the team by its slug column
        $team = Team::where('slug', $subdomain)->first();

        // Abort with 404 if no team matches this subdomain
        if (!$team) {
            abort(404);
        }

        // Set the resolved team as the current team for the authenticated user
        if ($request->user()) {
            $request->user()->switchTeam($team);
        }

        return $next($request);
    }
}

What does this middleware do?

  • explode('.', $host) splits the hostname into parts. For acme.clusterfiy.test, this produces ['acme', 'clusterfiy', 'test'].
  • Team::where('slug', $subdomain)->first() looks up the team whose slug matches the subdomain.
  • $request->user()->switchTeam($team) uses Jetstream's built-in method to set the resolved team as active for the current user.
  • Save ResolveTenant.php.

Now register the middleware as an alias so you can reference it by name in your routes.

  • Open bootstrap/app.php and find the ->withMiddleware section.
  • Add the alias registration inside the middleware closure:
->withMiddleware(function (Middleware $middleware) {
    // Register the tenant middleware alias for use in route groups
    $middleware->alias([
        'tenant' => \App\Http\Middleware\ResolveTenant::class,
    ]);
})

What does this registration do?

This creates a shorthand alias tenant that you can use in route definitions instead of typing the full class path every time.

  • Save bootstrap/app.php.

Getting a class not found error?

  • Check that the namespace in ResolveTenant.php is exactly App\Http\Middleware. A typo in the namespace means Laravel cannot find the class.
  • Make sure the file is saved at app/Http/Middleware/ResolveTenant.php. The folder path must match the namespace.

Help me fix my middleware registration in Laravel.

✔️ Awesome, I've got everything!

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

ⓧ I'd like to double check the full code

Here is the full ResolveTenant.php file:

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use App\Models\Team;
use Symfony\Component\HttpFoundation\Response;

class ResolveTenant
{
    /**
     * Extract the subdomain and resolve the matching team.
     */
    public function handle(Request $request, Closure $next): Response
    {
        // Get the full hostname from the request
        $host = $request->getHost();
        $parts = explode('.', $host);

        // If there are only 2 parts (e.g., clusterfiy.test), no subdomain exists
        if (count($parts) <= 2) {
            return $next($request);
        }

        // The subdomain is the first segment (e.g., "acme" from "acme.clusterfiy.test")
        $subdomain = $parts[0];

        // Look up the team by its slug column
        $team = Team::where('slug', $subdomain)->first();

        // Abort with 404 if no team matches this subdomain
        if (!$team) {
            abort(404);
        }

        // Set the resolved team as the current team for the authenticated user
        if ($request->user()) {
            $request->user()->switchTeam($team);
        }

        return $next($request);
    }
}

And here is the relevant section of 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 the tenant middleware alias for use in route groups
        $middleware->alias([
            'tenant' => \App\Http\Middleware\ResolveTenant::class,
        ]);
    })
    ->withExceptions(function (Exceptions $exceptions) {
        //
    })->create();
Set up central vs tenant route groups

Your app needs two distinct route groups. The central group handles the main domain (login, registration, marketing pages). The tenant group handles subdomain requests and applies your new middleware.

  • Open routes/web.php and replace its contents with the following:
<?php

use Illuminate\Support\Facades\Route;

// Central routes: main domain (clusterfiy.test)
// Handles login, registration, and marketing pages
Route::domain('clusterfiy.test')->group(function () {
    Route::get('/', function () {
        return view('welcome');
    });
});

// Tenant routes: subdomain requests (*.clusterfiy.test)
// The tenant middleware resolves the subdomain to a team
Route::domain('{tenant}.clusterfiy.test')
    ->middleware(['web', 'auth', 'tenant'])
    ->group(function () {
        Route::get('/dashboard', function () {
            return view('dashboard');
        })->name('tenant.dashboard');
    });

What does this routing setup do?

  • Route::domain('clusterfiy.test') catches requests to the bare domain. Only login and marketing routes live here.
  • Route::domain('{tenant}.clusterfiy.test') uses Laravel's subdomain routing. The {tenant} parameter captures the subdomain value (e.g., "acme").
  • The tenant middleware alias triggers your ResolveTenant class, which maps the subdomain to a team before the route handler runs.
  • Save routes/web.php.

Finally, update your environment configuration so auth cookies work across subdomains.

  • Open the .env file in your clusterfiy project root.
  • Find this line:
APP_URL=http://localhost
  • Replace it with:
APP_URL=http://clusterfiy.test
  • Now add a new line below SESSION_DRIVER (or anywhere in the session section):
SESSION_DOMAIN=.clusterfiy.test

Why the leading dot in SESSION_DOMAIN?

The leading dot in .clusterfiy.test tells the browser to send the session cookie to the main domain AND all subdomains. Without it, logging in on clusterfiy.test would not keep you authenticated on acme.clusterfiy.test.

  • Save the .env file.
  • Visit http://clusterfiy.test in your browser. You should see the Laravel welcome page.
  • Visit http://acme.clusterfiy.test/dashboard. You should be redirected to the login page (because the auth middleware requires authentication).

Getting a 404 on the subdomain?

  • Make sure you restarted Apache after editing the vhost config. Without a restart, the new ServerAlias is not active.
  • Verify your hosts file entries are saved correctly. Open a command prompt and run ping acme.clusterfiy.test to confirm it resolves to 127.0.0.1.
  • Clear your browser DNS cache. In Chrome, visit chrome://net-internals/#dns and click Clear host cache.

Help me debug why my subdomain routes are returning 404.

✔️ Awesome, I've got everything!

Great. Make sure all files are saved before continuing.

ⓧ I'd like to double check the full code

Here is the full routes/web.php:

<?php

use Illuminate\Support\Facades\Route;

// Central routes: main domain (clusterfiy.test)
// Handles login, registration, and marketing pages
Route::domain('clusterfiy.test')->group(function () {
    Route::get('/', function () {
        return view('welcome');
    });
});

// Tenant routes: subdomain requests (*.clusterfiy.test)
// The tenant middleware resolves the subdomain to a team
Route::domain('{tenant}.clusterfiy.test')
    ->middleware(['web', 'auth', 'tenant'])
    ->group(function () {
        Route::get('/dashboard', function () {
            return view('dashboard');
        })->name('tenant.dashboard');
    });

And the relevant .env lines:

APP_URL=http://clusterfiy.test
SESSION_DOMAIN=.clusterfiy.test

Your subdomain routing is wired up. Next up, you will define a 5-level role permission system that controls exactly who can create, assign, and manage tasks within each team.

Define the Role Permission System

Your subdomain routing is working. Each company's requests now land on the correct team. But routing alone does not answer a critical question: who on that team is allowed to do what?

In this step, you will build a 5-level role hierarchy that controls access to every task action. Jetstream's built-in role system lets you define granular permissions per role, then check those permissions inside authorization policies.

In this step, get ready to:
  • Define 5 custom roles with granular task permissions in JetstreamServiceProvider.
  • Create a TaskPolicy that authorizes each action based on team permissions.
  • Add role-based UI gating in Blade templates using @can directives.
Configure 5 custom roles in JetstreamServiceProvider

Jetstream groups permissions into roles. Each role is a named collection of permission strings that control what a team member can do.

  • Open app/Providers/JetstreamServiceProvider.php in your editor.
  • Find the configurePermissions method. Replace its contents with the following role definitions:
protected function configurePermissions(): void
{
    Jetstream::defaultApiTokenPermissions(['read']);

    // Super Admin: full access to everything
    Jetstream::role('super-admin', 'Super Admin', [
        'task:create',
        'task:update',
        'task:delete',
        'task:assign',
        'task:change-status',
        'team:manage',
    ])->description('Super Admins have full access to all resources.');

    // Company Admin: manage the team and all tasks
    Jetstream::role('company-admin', 'Company Admin', [
        'task:create',
        'task:update',
        'task:delete',
        'task:assign',
        'task:change-status',
        'team:manage',
    ])->description('Company Admins can manage the team and all tasks.');

    // Project Lead: create, update, assign, and change task status
    Jetstream::role('project-lead', 'Project Lead', [
        'task:create',
        'task:update',
        'task:assign',
        'task:change-status',
    ])->description('Project Leads can create, update, assign, and change status of tasks.');

    // Member: create tasks and update their own
    Jetstream::role('member', 'Member', [
        'task:create',
        'task:update',
    ])->description('Members can create tasks and update their own tasks.');

    // Viewer: read-only access
    Jetstream::role('viewer', 'Viewer', [
    ])->description('Viewers have read-only access to tasks.');
}

What does this code do?

Each Jetstream::role() call accepts a slug, a display name, and an array of permission strings. The chained ->description() adds a human-readable summary shown in the team management UI.

The permission strings (task:create, task:update, etc.) are arbitrary identifiers you define. They become the values you check later with hasTeamPermission().

Why check permissions instead of roles?

Jetstream treats roles as a presentational concept for grouping permissions. You never check if a user is a Project Lead directly. Instead, you check if the user has the task:assign permission.

This makes the system flexible. If you add or rearrange roles later, the permission checks still work without updating every authorization call.

  • Save JetstreamServiceProvider.php.
  • Refresh http://clusterfiy.test in your browser. You should see the same dashboard as before with no errors.

Seeing a class not found error?

Make sure the use Laravel\Jetstream\Jetstream; statement exists at the top of the file. Jetstream scaffolded this import when you ran the install command, so it should already be there.

Help me fix a class not found error in JetstreamServiceProvider

Create a TaskPolicy

Permissions define what a role is allowed to do. A policy is where you actually enforce those permissions by checking them against the current user and team.

  • Generate the policy file by running this command in Laragon Terminal:
php artisan make:policy TaskPolicy
  • Open the newly created app/Policies/TaskPolicy.php file.
  • Replace the entire contents with the following policy code:
<?php

namespace App\Policies;

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

class TaskPolicy
{
    /**
     * Any team member can view tasks on their team.
     */
    public function view(User $user, Task $task): bool
    {
        return $user->belongsToTeam($task->team);
    }

    /**
     * Check if the user has task:create permission on their current team.
     */
    public function create(User $user): bool
    {
        $team = $user->currentTeam;

        return $user->hasTeamPermission($team, 'task:create');
    }

    /**
     * Members can only update their own tasks.
     * Project Leads and above can update any task.
     */
    public function update(User $user, Task $task): bool
    {
        $team = $task->team;

        if (! $user->hasTeamPermission($team, 'task:update')) {
            return false;
        }

        // Members can only update tasks they created
        if ($user->teamRole($team)->key === 'member') {
            return $task->created_by === $user->id;
        }

        return true;
    }

    /**
     * Only roles with task:delete permission (Company Admin+).
     */
    public function delete(User $user, Task $task): bool
    {
        return $user->hasTeamPermission($task->team, 'task:delete');
    }

    /**
     * Only roles with task:change-status permission.
     */
    public function changeStatus(User $user, Task $task): bool
    {
        return $user->hasTeamPermission($task->team, 'task:change-status');
    }

    /**
     * Only roles with task:assign permission (Project Lead+).
     */
    public function assign(User $user, Task $task): bool
    {
        return $user->hasTeamPermission($task->team, 'task:assign');
    }
}

What does this code do?

Each method corresponds to a task action. The view method checks team membership. The create, delete, changeStatus, and assign methods call hasTeamPermission() to verify the user's role grants that specific permission.

The update method has extra logic. Members can only update tasks they created (created_by === $user->id), while higher roles can update any task on the team.

Now register the policy so Laravel knows to use it for the Task model.

  • Open app/Providers/AppServiceProvider.php.
  • Add the following imports at the top of the file, below the existing use statements:
use App\Models\Task;
use App\Policies\TaskPolicy;
use Illuminate\Support\Facades\Gate;
  • Inside the boot() method, add this line to register the policy:
// Register the Task policy for authorization checks
Gate::policy(Task::class, TaskPolicy::class);

What does this registration do?

The Gate::policy() call tells Laravel to use TaskPolicy whenever authorization is checked against a Task model. This enables @can directives and $this->authorize() calls to resolve your policy methods automatically.

  • Save AppServiceProvider.php.
  • Refresh http://clusterfiy.test in your browser. You should see the same dashboard with no errors.

Seeing a server error?

Double check that the use statements at the top of AppServiceProvider.php are correct. The Task model does not exist yet (you will create it in the next step), but registering the policy will not cause an error until you actually try to authorize against it.

Help me debug a server error after registering my TaskPolicy

Add role-based UI gating in Blade

With roles defined and a policy registered, you can now conditionally show or hide UI elements based on the logged-in user's permissions.

  • You will build the full task board in Step 5. For now, review the following reference patterns that demonstrate how to gate UI elements in Blade views:
{{-- Only show the New Task button for users who can create tasks --}}
@can('create', App\Models\Task::class)
    <button>New Task</button>
@endcan

{{-- Only show the Assign button for users with assign permission --}}
@can('assign', $task)
    <button>Assign</button>
@endcan

{{-- Only show the Delete button for Company Admins and above --}}
@can('delete', $task)
    <button>Delete</button>
@endcan

{{-- Direct permission check without the policy --}}
@if(auth()->user()->hasTeamPermission(auth()->user()->currentTeam, 'team:manage'))
    <a href="/settings">Team Settings</a>
@endif

How do these directives work?

The @can('create', App\Models\Task::class) directive calls the create method on your TaskPolicy. If it returns true, the enclosed HTML renders. Otherwise it is hidden.

For actions on a specific task (like assign or delete), pass the $task instance. Laravel resolves the correct policy method and injects the model automatically.

The hasTeamPermission() check is useful for permissions that do not map to a specific model, like team:manage.

You will not see visible changes from this pattern yet because the Task model and board views do not exist. The next step builds the Task model and migration.

✔️ Awesome, I've got everything!

Great. Double check you have saved both JetstreamServiceProvider.php and AppServiceProvider.php.

ⓧ I'd like to double check the full code

Here is the full configurePermissions method in JetstreamServiceProvider.php:

protected function configurePermissions(): void
{
    Jetstream::defaultApiTokenPermissions(['read']);

    Jetstream::role('super-admin', 'Super Admin', [
        'task:create',
        'task:update',
        'task:delete',
        'task:assign',
        'task:change-status',
        'team:manage',
    ])->description('Super Admins have full access to all resources.');

    Jetstream::role('company-admin', 'Company Admin', [
        'task:create',
        'task:update',
        'task:delete',
        'task:assign',
        'task:change-status',
        'team:manage',
    ])->description('Company Admins can manage the team and all tasks.');

    Jetstream::role('project-lead', 'Project Lead', [
        'task:create',
        'task:update',
        'task:assign',
        'task:change-status',
    ])->description('Project Leads can create, update, assign, and change status of tasks.');

    Jetstream::role('member', 'Member', [
        'task:create',
        'task:update',
    ])->description('Members can create tasks and update their own tasks.');

    Jetstream::role('viewer', 'Viewer', [
    ])->description('Viewers have read-only access to tasks.');
}

Here is the full TaskPolicy.php:

<?php

namespace App\Policies;

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

class TaskPolicy
{
    public function view(User $user, Task $task): bool
    {
        return $user->belongsToTeam($task->team);
    }

    public function create(User $user): bool
    {
        $team = $user->currentTeam;

        return $user->hasTeamPermission($team, 'task:create');
    }

    public function update(User $user, Task $task): bool
    {
        $team = $task->team;

        if (! $user->hasTeamPermission($team, 'task:update')) {
            return false;
        }

        if ($user->teamRole($team)->key === 'member') {
            return $task->created_by === $user->id;
        }

        return true;
    }

    public function delete(User $user, Task $task): bool
    {
        return $user->hasTeamPermission($task->team, 'task:delete');
    }

    public function changeStatus(User $user, Task $task): bool
    {
        return $user->hasTeamPermission($task->team, 'task:change-status');
    }

    public function assign(User $user, Task $task): bool
    {
        return $user->hasTeamPermission($task->team, 'task:assign');
    }
}

Here is the policy registration in AppServiceProvider.php:

<?php

namespace App\Providers;

use App\Models\Task;
use App\Policies\TaskPolicy;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        //
    }

    public function boot(): void
    {
        Gate::policy(Task::class, TaskPolicy::class);
    }
}

Your permission system is locked in. Every task action now has a clear rule about who can perform it. Next up, you will build the Task model and database migration so these permissions have something to protect.

Build the Task Model and Migration

Your permission system is ready to guard actions. Now you need something to guard.

Every task in Clusterfiy belongs to a team, has a status and priority, can be assigned to multiple people, and tracks who created it. In this step, you will define the database tables that store this data and the Eloquent model that makes it easy to query.

In this step, get ready to:
  • Create a tasks migration with status and priority enums.
  • Create a pivot table migration for multiple task assignees.
  • Build the Task model with relationships and a team scope.
Create the tasks migration

The tasks table is the heart of Clusterfiy. Each row represents one task scoped to a specific team, with a status that tracks progress and a priority that signals urgency.

  • Generate the tasks migration by running this command in your Laragon Terminal:
php artisan make:migration create_tasks_table
  • Open the newly created migration file inside database/migrations/. It will have a timestamp prefix followed by create_tasks_table.php.
  • Replace the contents of the up() method with this schema definition:
<?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) {
            // Primary key
            $table->id();

            // Foreign key linking task to a team (tenant)
            $table->foreignId('team_id')->constrained()->cascadeOnDelete();

            // Core task fields
            $table->string('title');
            $table->text('description')->nullable();

            // Status workflow: tracks where the task is in its lifecycle
            $table->enum('status', ['todo', 'in_progress', 'in_review', 'blocked', 'done'])
                  ->default('todo');

            // Priority level: signals urgency to the team
            $table->enum('priority', ['low', 'medium', 'high', 'urgent'])
                  ->default('medium');

            // Optional due date for deadline tracking
            $table->date('due_date')->nullable();

            // Foreign key tracking who created the task
            $table->foreignId('created_by')->constrained('users')->cascadeOnDelete();

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

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

What does this migration do?

This creates the tasks table with team-scoped isolation built in. Every task belongs to exactly one team via team_id.

  • The status enum defines 5 stages: todo, in_progress, in_review, blocked, and done. These map to your Kanban board columns later.
  • The priority enum has 4 levels: low, medium, high, and urgent.
  • The cascadeOnDelete() ensures tasks are automatically removed when their parent team or creator is deleted.
  • Save the migration file.

Seeing a syntax error in the migration?

  • Make sure the enum() values are wrapped in square brackets as an array. Each value is a single-quoted string separated by commas.
  • Check that ->constrained() is chained after foreignId() with -> not ..

Why is my migration showing a syntax error?

Create the task assignees pivot table

A single task can have multiple people working on it. In relational database terms, this is a many-to-many relationship between tasks and users. You need a pivot table to connect them.

  • Generate the pivot table migration by running:
php artisan make:migration create_task_user_table
  • Open the new migration file (it will be named with a timestamp followed by create_task_user_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('task_user', function (Blueprint $table) {
            // Foreign keys linking tasks to users
            $table->foreignId('task_id')->constrained()->cascadeOnDelete();
            $table->foreignId('user_id')->constrained()->cascadeOnDelete();

            // Prevent duplicate assignments
            $table->unique(['task_id', 'user_id']);
        });
    }

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

What is a pivot table?

A pivot table sits between two other tables to represent a many-to-many relationship. Here, task_user connects tasks and users so one task can have many assignees and one user can be assigned to many tasks.

The unique composite index on ['task_id', 'user_id'] prevents the same user from being assigned to the same task twice.

  • Save the migration file.

Migration file not appearing?

  • Make sure you are running the command from the clusterfiy project directory. Run cd C:\laragon\www\clusterfiy first if needed.
  • Check the database/migrations/ folder for files with today's date in the filename.

Why can't I find my new migration file?

Create the Task model with relationships

The model is where you define how your application interacts with the tasks table. It declares which columns can be filled, how enums are cast to PHP types, and the relationships between tasks, teams, and users.

  • Generate the Task model by running:
php artisan make:model Task
  • Open app/Models/Task.php and replace its contents with the following:
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;

class Task extends Model
{
    // Columns that can be mass-assigned
    protected $fillable = [
        'team_id',
        'title',
        'description',
        'status',
        'priority',
        'due_date',
        'created_by',
    ];

    /**
     * Cast attributes to native types.
     * Enum columns are stored as strings in MySQL
     * but cast here for cleaner PHP comparisons.
     */
    protected function casts(): array
    {
        return [
            'status' => 'string',
            'priority' => 'string',
            'due_date' => 'date',
        ];
    }

    /**
     * The team (tenant) this task belongs to.
     */
    public function team(): BelongsTo
    {
        return $this->belongsTo(Team::class);
    }

    /**
     * The user who created this task.
     */
    public function creator(): BelongsTo
    {
        return $this->belongsTo(User::class, 'created_by');
    }

    /**
     * Users assigned to this task (many-to-many).
     */
    public function assignees(): BelongsToMany
    {
        return $this->belongsToMany(User::class, 'task_user');
    }

    /**
     * Scope queries to only return tasks for a given team.
     * Usage: Task::forTeam($teamId)->get()
     */
    public function scopeForTeam($query, $teamId)
    {
        return $query->where('team_id', $teamId);
    }
}

What does this model do?

  • The $fillable array lists which columns can be set via mass assignment (like Task::create([...]). This protects against unwanted field injection.
  • The casts() method tells Eloquent how to convert raw database values into PHP types. The due_date becomes a Carbon date object for easy date manipulation.
  • The team() and creator() methods define belongsTo relationships. The assignees() method uses belongsToMany to link through the pivot table.
  • The scopeForTeam local scope filters tasks by team. This is how you enforce tenant isolation at the query level.
  • Save app/Models/Task.php.
  • Run both migrations by executing:
php artisan migrate
  • You should see output confirming both create_tasks_table and create_task_user_table migrations ran successfully.

Migration failed with a foreign key error?

  • Make sure the teams and users tables already exist. Jetstream creates them during initial setup. If you skipped the earlier php artisan migrate, run it now.
  • Confirm MySQL is running in Laragon. Right-click the Laragon tray icon and check that MySQL shows a green indicator.
  • Verify your .env database credentials match your Laragon MySQL setup (default is root with no password).

Why is my migration failing with a foreign key constraint error?

✔️ Awesome, I've got everything!

Great. Double check you have saved both migration files and app/Models/Task.php.

ⓧ I'd like to double check the full code

Here is the full Task.php model for comparison:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;

class Task extends Model
{
    // Columns that can be mass-assigned
    protected $fillable = [
        'team_id',
        'title',
        'description',
        'status',
        'priority',
        'due_date',
        'created_by',
    ];

    /**
     * Cast attributes to native types.
     * Enum columns are stored as strings in MySQL
     * but cast here for cleaner PHP comparisons.
     */
    protected function casts(): array
    {
        return [
            'status' => 'string',
            'priority' => 'string',
            'due_date' => 'date',
        ];
    }

    /**
     * The team (tenant) this task belongs to.
     */
    public function team(): BelongsTo
    {
        return $this->belongsTo(Team::class);
    }

    /**
     * The user who created this task.
     */
    public function creator(): BelongsTo
    {
        return $this->belongsTo(User::class, 'created_by');
    }

    /**
     * Users assigned to this task (many-to-many).
     */
    public function assignees(): BelongsToMany
    {
        return $this->belongsToMany(User::class, 'task_user');
    }

    /**
     * Scope queries to only return tasks for a given team.
     * Usage: Task::forTeam($teamId)->get()
     */
    public function scopeForTeam($query, $teamId)
    {
        return $query->where('team_id', $teamId);
    }
}

Here is the full create_tasks_table migration:

<?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) {
            // Primary key
            $table->id();

            // Foreign key linking task to a team (tenant)
            $table->foreignId('team_id')->constrained()->cascadeOnDelete();

            // Core task fields
            $table->string('title');
            $table->text('description')->nullable();

            // Status workflow: tracks where the task is in its lifecycle
            $table->enum('status', ['todo', 'in_progress', 'in_review', 'blocked', 'done'])
                  ->default('todo');

            // Priority level: signals urgency to the team
            $table->enum('priority', ['low', 'medium', 'high', 'urgent'])
                  ->default('medium');

            // Optional due date for deadline tracking
            $table->date('due_date')->nullable();

            // Foreign key tracking who created the task
            $table->foreignId('created_by')->constrained('users')->cascadeOnDelete();

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

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

Here is the full create_task_user_table migration:

<?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('task_user', function (Blueprint $table) {
            // Foreign keys linking tasks to users
            $table->foreignId('task_id')->constrained()->cascadeOnDelete();
            $table->foreignId('user_id')->constrained()->cascadeOnDelete();

            // Prevent duplicate assignments
            $table->unique(['task_id', 'user_id']);
        });
    }

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

Your data layer is locked in. Next up, you will build the Livewire and Alpine.js task board that brings these tasks to life as an interactive Kanban interface.

Build the Livewire + Alpine.js Task Board

Your Task model, migrations, and permission system are all in place. Now it is time to bring the task board to life.

In this step, you will build an interactive Kanban board where tasks flow visually through status columns. Livewire handles server-side state (loading tasks, updating statuses, creating new tasks), while Alpine.js handles instant client-side interactions (expanding cards, toggling dropdowns, animating modals).

In this step, get ready to:
  • Create the TaskBoard Livewire component with grouped tasks and status updates.
  • Build the Kanban board Blade view with Alpine.js interactivity.
  • Add task creation through a Livewire-powered modal.
Create the TaskBoard Livewire component

The TaskBoard component is the brain of your Kanban board. It loads tasks grouped by status for the current team, authorizes status changes through the TaskPolicy, and handles new task creation.

  • Generate the class-based Livewire component by running this command in Laragon Terminal:
php artisan make:livewire TaskBoard --class

What does this command do?

The --class flag tells Livewire to generate a traditional class-based component instead of the default single-file component. This creates two files: the component class at app/Livewire/TaskBoard.php and the Blade view at resources/views/livewire/task-board.blade.php.

  • Confirm the files were created by running:
ls app/Livewire/TaskBoard.php resources/views/livewire/task-board.blade.php

You should see both file paths listed in the output.

  • Open app/Livewire/TaskBoard.php in Cursor and replace its entire contents with the following code:
<?php

namespace App\Livewire;

use App\Models\Task;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Computed;
use Livewire\Component;

class TaskBoard extends Component
{
    // Form properties for creating a new task
    public $title = '';
    public $description = '';
    public $priority = 'medium';
    public $due_date = '';
    public $assignees = [];

    // Controls modal visibility
    public $showModal = false;

    // Available statuses for the Kanban columns
    public $statuses = ['todo', 'in_progress', 'in_review', 'blocked', 'done'];

    // Validation rules for task creation
    protected $rules = [
        'title' => 'required|string|max:255',
        'description' => 'nullable|string',
        'priority' => 'required|in:low,medium,high,urgent',
        'due_date' => 'nullable|date',
        'assignees' => 'array',
    ];

    #[Computed]
    public function tasks()
    {
        // Load tasks for the current team, grouped by status
        $team = Auth::user()->currentTeam;

        return Task::forTeam($team->id)
            ->with(['creator', 'assignees'])
            ->get()
            ->groupBy('status');
    }

    #[Computed]
    public function teamMembers()
    {
        // Get all members of the current team for assignee selection
        return Auth::user()->currentTeam->allUsers();
    }

    public function updateStatus($taskId, $newStatus)
    {
        $task = Task::findOrFail($taskId);

        // Authorize the action using TaskPolicy
        $this->authorize('changeStatus', $task);

        $task->update(['status' => $newStatus]);
    }

    public function createTask()
    {
        $this->validate();

        $team = Auth::user()->currentTeam;

        // Authorize task creation
        $this->authorize('create', Task::class);

        // Create the task for the current team
        $task = Task::create([
            'team_id' => $team->id,
            'title' => $this->title,
            'description' => $this->description,
            'status' => 'todo',
            'priority' => $this->priority,
            'due_date' => $this->due_date ?: null,
            'created_by' => Auth::id(),
        ]);

        // Attach assignees if selected
        if (!empty($this->assignees)) {
            $task->assignees()->attach($this->assignees);
        }

        // Reset form and close modal
        $this->reset(['title', 'description', 'priority', 'due_date', 'assignees', 'showModal']);
    }

    public function render()
    {
        return view('livewire.task-board');
    }
}

What does this code do?

  • The #[Computed] attribute on tasks() makes it a cached computed property. In the Blade template you access it as $this->tasks. It loads all tasks for the current team and groups them by their status value.
  • The updateStatus() method checks the TaskPolicy before allowing a status change. Viewers and unauthorized users cannot move tasks between columns.
  • The createTask() method validates input, authorizes the action, creates the task with the current team ID, attaches assignees, then resets the form fields.
  • Save app/Livewire/TaskBoard.php.

Now wire the component to a route so it renders when tenants visit their dashboard.

  • Open routes/web.php and find the tenant route group (the one applying the ResolveTenant middleware).
  • Add this import at the top of the file:
use App\Livewire\TaskBoard;
  • Inside the tenant route group, add the dashboard route by pasting this code:
// Tenant dashboard - renders the Kanban task board
Route::livewire('/dashboard', TaskBoard::class)
    ->middleware(['auth'])
    ->name('tenant.dashboard');

What is Route::livewire()?

In Livewire 4, Route::livewire() is the recommended way to route full-page Livewire components. It renders the component inside your application layout when a user visits the URL. For class-based components you pass the class reference directly.

  • Save routes/web.php.

Seeing a 404 on the tenant subdomain?

  • Confirm the route is inside the tenant route group that applies the ResolveTenant middleware, not the central route group.
  • Make sure you added the use App\Livewire\TaskBoard; import at the top of the file.
  • Check that Laragon's Apache is running and the hosts file entries are in place from Step 2.

help me debug my tenant routing

Build the Kanban board Blade view with Alpine.js

The Blade view renders 5 status columns. Each task card uses Alpine.js directives for instant client-side interactions without waiting for a server round-trip.

  • Open resources/views/livewire/task-board.blade.php and replace its contents with the following Kanban board structure:
<div class="p-6">
    <!-- Header with New Task button -->
    <div class="flex justify-between items-center mb-6">
        <h2 class="text-2xl font-bold text-white">Task Board</h2>

        @can('create', App\Models\Task::class)
            <button
                x-data
                x-on:click="$wire.set('showModal', true)"
                class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700"
            >
                + New Task
            </button>
        @endcan
    </div>

    <!-- Kanban columns -->
    <div class="grid grid-cols-5 gap-4">
        @foreach($this->statuses as $status)
            <div class="bg-gray-800 rounded-lg p-4">
                <!-- Column header with task count -->
                <h3 class="text-sm font-semibold text-gray-400 uppercase mb-4">
                    {{ str_replace('_', ' ', $status) }}
                    <span class="ml-2 text-xs bg-gray-700 px-2 py-1 rounded-full">
                        {{ isset($this->tasks[$status]) ? $this->tasks[$status]->count() : 0 }}
                    </span>
                </h3>

                <!-- Task cards for this status -->
                <div class="space-y-3">
                    @foreach(($this->tasks[$status] ?? []) as $task)
                        <div
                            x-data="{ expanded: false }"
                            class="bg-gray-900 rounded-lg p-3 border border-gray-700"
                        >
                            <!-- Task title and priority badge -->
                            <div class="flex justify-between items-start">
                                <h4
                                    x-on:click="expanded = !expanded"
                                    class="text-white font-medium cursor-pointer hover:text-indigo-400"
                                >
                                    {{ $task->title }}
                                </h4>
                                <span class="text-xs px-2 py-1 rounded-full
                                    @if($task->priority === 'urgent') bg-red-900 text-red-200
                                    @elseif($task->priority === 'high') bg-orange-900 text-orange-200
                                    @elseif($task->priority === 'medium') bg-yellow-900 text-yellow-200
                                    @else bg-green-900 text-green-200
                                    @endif
                                ">
                                    {{ $task->priority }}
                                </span>
                            </div>

                            <!-- Expandable task details -->
                            <div x-show="expanded" x-transition class="mt-3 space-y-2">
                                @if($task->description)
                                    <p class="text-gray-400 text-sm">{{ $task->description }}</p>
                                @endif

                                @if($task->due_date)
                                    <p class="text-xs text-gray-500">
                                        Due: {{ $task->due_date->format('M d, Y') }}
                                    </p>
                                @endif

                                <!-- Assignee avatars -->
                                <div class="flex -space-x-2">
                                    @foreach($task->assignees as $assignee)
                                        <div class="w-6 h-6 rounded-full bg-indigo-600 flex items-center justify-center text-xs text-white border border-gray-900">
                                            {{ substr($assignee->name, 0, 1) }}
                                        </div>
                                    @endforeach
                                </div>

                                <!-- Status change dropdown -->
                                @can('changeStatus', $task)
                                    <div x-data="{ open: false }" class="relative mt-2">
                                        <button
                                            x-on:click="open = !open"
                                            class="text-xs text-gray-400 hover:text-white"
                                        >
                                            Move to...
                                        </button>
                                        <div
                                            x-show="open"
                                            x-on:click.away="open = false"
                                            x-transition
                                            class="absolute z-10 mt-1 bg-gray-800 border border-gray-700 rounded-lg shadow-lg"
                                        >
                                            @foreach($this->statuses as $s)
                                                @if($s !== $status)
                                                    <button
                                                        x-on:click="$wire.updateStatus({{ $task->id }}, '{{ $s }}'); open = false"
                                                        class="block w-full text-left px-4 py-2 text-sm text-gray-300 hover:bg-gray-700"
                                                    >
                                                        {{ str_replace('_', ' ', $s) }}
                                                    </button>
                                                @endif
                                            @endforeach
                                        </div>
                                    </div>
                                @endcan
                            </div>
                        </div>
                    @endforeach
                </div>
            </div>
        @endforeach
    </div>

    <!-- New Task Modal -->
    @if($showModal)
        <div
            x-data="{ show: true }"
            x-show="show"
            x-transition
            class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"
        >
            <div class="bg-gray-800 rounded-lg p-6 w-full max-w-md">
                <h3 class="text-lg font-bold text-white mb-4">Create New Task</h3>

                <form wire:submit="createTask">
                    <!-- Title field -->
                    <div class="mb-4">
                        <label class="block text-sm text-gray-400 mb-1">Title</label>
                        <input
                            type="text"
                            wire:model="title"
                            class="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white"
                        />
                        @error('title')
                            <span class="text-red-400 text-xs">{{ $message }}</span>
                        @enderror
                    </div>

                    <!-- Description field -->
                    <div class="mb-4">
                        <label class="block text-sm text-gray-400 mb-1">Description</label>
                        <textarea
                            wire:model="description"
                            rows="3"
                            class="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white"
                        ></textarea>
                    </div>

                    <!-- Priority select -->
                    <div class="mb-4">
                        <label class="block text-sm text-gray-400 mb-1">Priority</label>
                        <select
                            wire:model="priority"
                            class="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white"
                        >
                            <option value="low">Low</option>
                            <option value="medium">Medium</option>
                            <option value="high">High</option>
                            <option value="urgent">Urgent</option>
                        </select>
                    </div>

                    <!-- Due date field -->
                    <div class="mb-4">
                        <label class="block text-sm text-gray-400 mb-1">Due Date</label>
                        <input
                            type="date"
                            wire:model="due_date"
                            class="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white"
                        />
                    </div>

                    <!-- Assignees multi-select -->
                    <div class="mb-4">
                        <label class="block text-sm text-gray-400 mb-1">Assignees</label>
                        <select
                            wire:model="assignees"
                            multiple
                            class="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white"
                        >
                            @foreach($this->teamMembers as $member)
                                <option value="{{ $member->id }}">{{ $member->name }}</option>
                            @endforeach
                        </select>
                    </div>

                    <!-- Form actions -->
                    <div class="flex justify-end space-x-3">
                        <button
                            type="button"
                            wire:click="$set('showModal', false)"
                            class="px-4 py-2 text-gray-400 hover:text-white"
                        >
                            Cancel
                        </button>
                        <button
                            type="submit"
                            class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700"
                        >
                            Create Task
                        </button>
                    </div>
                </form>
            </div>
        </div>
    @endif
</div>

What are the Alpine.js directives doing?

  • Each task card has x-data="{ expanded: false }" which gives it local state for toggling details open and closed.
  • Clicking the title triggers x-on:click="expanded = !expanded" to flip the state instantly in the browser.
  • The details section uses x-show="expanded" with x-transition for a smooth fade and slide animation.
  • The status dropdown calls $wire.updateStatus() to invoke the Livewire component method directly from Alpine. This sends the request to the server, updates the database, and re-renders the board.

Why use $wire instead of wire:click?

The $wire magic property lets Alpine.js call Livewire methods with dynamic arguments. Since the task ID and new status come from loop variables inside Alpine, $wire is the cleanest way to pass them to the server without extra Blade templating.

  • Save resources/views/livewire/task-board.blade.php.

Board renders but no columns appear?

  • Make sure you used $this->statuses and $this->tasks (with $this->) in the Blade template. Computed properties in Livewire 4 must be accessed on the $this object.
  • Check that your Task model has the scopeForTeam scope from Step 4. The Task::forTeam() call depends on it.

help me debug my Kanban board

Add task creation with a Livewire modal

The modal is already included in the Blade view you just pasted. It uses Alpine.js for its open/close animation and Livewire for form submission and validation.

How does the modal work?

The @if($showModal) directive conditionally renders the modal HTML. Livewire controls whether the modal exists in the DOM.

Alpine's x-show="show" with x-transition adds a smooth fade-in animation when the modal appears. The form uses wire:submit="createTask" to call the Livewire method on submit. Validation errors display inline thanks to Blade's @error directive.

  • Start the development server by running this command in Laragon Terminal:
php artisan serve
  • Visit http://acme.clusterfiy.test/dashboard in your browser.
  • Confirm the page loads without errors and shows the 5 column headers (todo, in progress, in review, blocked, done).

The columns will be empty for now. After you seed data in the next step, you will see the full board populated with tasks.

Seeing a blank page or error?

  • Check that the Blade template has a single root <div> element. Livewire components must have exactly one root element.
  • Run npm run build to recompile assets if Tailwind classes are not rendering.
  • Check the Laravel log at storage/logs/laravel.log for stack traces pointing to syntax issues.

help me fix my task board rendering

✔️ Awesome, I've got everything!

Great. Double check you have saved both app/Livewire/TaskBoard.php and resources/views/livewire/task-board.blade.php.

ⓧ I'd like to double check the full code

Here is the complete TaskBoard.php component:

<?php

namespace App\Livewire;

use App\Models\Task;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Computed;
use Livewire\Component;

class TaskBoard extends Component
{
    // Form properties for creating a new task
    public $title = '';
    public $description = '';
    public $priority = 'medium';
    public $due_date = '';
    public $assignees = [];

    // Controls modal visibility
    public $showModal = false;

    // Available statuses for the Kanban columns
    public $statuses = ['todo', 'in_progress', 'in_review', 'blocked', 'done'];

    // Validation rules for task creation
    protected $rules = [
        'title' => 'required|string|max:255',
        'description' => 'nullable|string',
        'priority' => 'required|in:low,medium,high,urgent',
        'due_date' => 'nullable|date',
        'assignees' => 'array',
    ];

    #[Computed]
    public function tasks()
    {
        // Load tasks for the current team, grouped by status
        $team = Auth::user()->currentTeam;

        return Task::forTeam($team->id)
            ->with(['creator', 'assignees'])
            ->get()
            ->groupBy('status');
    }

    #[Computed]
    public function teamMembers()
    {
        // Get all members of the current team for assignee selection
        return Auth::user()->currentTeam->allUsers();
    }

    public function updateStatus($taskId, $newStatus)
    {
        $task = Task::findOrFail($taskId);

        // Authorize the action using TaskPolicy
        $this->authorize('changeStatus', $task);

        $task->update(['status' => $newStatus]);
    }

    public function createTask()
    {
        $this->validate();

        $team = Auth::user()->currentTeam;

        // Authorize task creation
        $this->authorize('create', Task::class);

        // Create the task for the current team
        $task = Task::create([
            'team_id' => $team->id,
            'title' => $this->title,
            'description' => $this->description,
            'status' => 'todo',
            'priority' => $this->priority,
            'due_date' => $this->due_date ?: null,
            'created_by' => Auth::id(),
        ]);

        // Attach assignees if selected
        if (!empty($this->assignees)) {
            $task->assignees()->attach($this->assignees);
        }

        // Reset form and close modal
        $this->reset(['title', 'description', 'priority', 'due_date', 'assignees', 'showModal']);
    }

    public function render()
    {
        return view('livewire.task-board');
    }
}

Here is the complete task-board.blade.php view:

<div class="p-6">
    <!-- Header with New Task button -->
    <div class="flex justify-between items-center mb-6">
        <h2 class="text-2xl font-bold text-white">Task Board</h2>

        @can('create', App\Models\Task::class)
            <button
                x-data
                x-on:click="$wire.set('showModal', true)"
                class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700"
            >
                + New Task
            </button>
        @endcan
    </div>

    <!-- Kanban columns -->
    <div class="grid grid-cols-5 gap-4">
        @foreach($this->statuses as $status)
            <div class="bg-gray-800 rounded-lg p-4">
                <!-- Column header with task count -->
                <h3 class="text-sm font-semibold text-gray-400 uppercase mb-4">
                    {{ str_replace('_', ' ', $status) }}
                    <span class="ml-2 text-xs bg-gray-700 px-2 py-1 rounded-full">
                        {{ isset($this->tasks[$status]) ? $this->tasks[$status]->count() : 0 }}
                    </span>
                </h3>

                <!-- Task cards for this status -->
                <div class="space-y-3">
                    @foreach(($this->tasks[$status] ?? []) as $task)
                        <div
                            x-data="{ expanded: false }"
                            class="bg-gray-900 rounded-lg p-3 border border-gray-700"
                        >
                            <!-- Task title and priority badge -->
                            <div class="flex justify-between items-start">
                                <h4
                                    x-on:click="expanded = !expanded"
                                    class="text-white font-medium cursor-pointer hover:text-indigo-400"
                                >
                                    {{ $task->title }}
                                </h4>
                                <span class="text-xs px-2 py-1 rounded-full
                                    @if($task->priority === 'urgent') bg-red-900 text-red-200
                                    @elseif($task->priority === 'high') bg-orange-900 text-orange-200
                                    @elseif($task->priority === 'medium') bg-yellow-900 text-yellow-200
                                    @else bg-green-900 text-green-200
                                    @endif
                                ">
                                    {{ $task->priority }}
                                </span>
                            </div>

                            <!-- Expandable task details -->
                            <div x-show="expanded" x-transition class="mt-3 space-y-2">
                                @if($task->description)
                                    <p class="text-gray-400 text-sm">{{ $task->description }}</p>
                                @endif

                                @if($task->due_date)
                                    <p class="text-xs text-gray-500">
                                        Due: {{ $task->due_date->format('M d, Y') }}
                                    </p>
                                @endif

                                <!-- Assignee avatars -->
                                <div class="flex -space-x-2">
                                    @foreach($task->assignees as $assignee)
                                        <div class="w-6 h-6 rounded-full bg-indigo-600 flex items-center justify-center text-xs text-white border border-gray-900">
                                            {{ substr($assignee->name, 0, 1) }}
                                        </div>
                                    @endforeach
                                </div>

                                <!-- Status change dropdown -->
                                @can('changeStatus', $task)
                                    <div x-data="{ open: false }" class="relative mt-2">
                                        <button
                                            x-on:click="open = !open"
                                            class="text-xs text-gray-400 hover:text-white"
                                        >
                                            Move to...
                                        </button>
                                        <div
                                            x-show="open"
                                            x-on:click.away="open = false"
                                            x-transition
                                            class="absolute z-10 mt-1 bg-gray-800 border border-gray-700 rounded-lg shadow-lg"
                                        >
                                            @foreach($this->statuses as $s)
                                                @if($s !== $status)
                                                    <button
                                                        x-on:click="$wire.updateStatus({{ $task->id }}, '{{ $s }}'); open = false"
                                                        class="block w-full text-left px-4 py-2 text-sm text-gray-300 hover:bg-gray-700"
                                                    >
                                                        {{ str_replace('_', ' ', $s) }}
                                                    </button>
                                                @endif
                                            @endforeach
                                        </div>
                                    </div>
                                @endcan
                            </div>
                        </div>
                    @endforeach
                </div>
            </div>
        @endforeach
    </div>

    <!-- New Task Modal -->
    @if($showModal)
        <div
            x-data="{ show: true }"
            x-show="show"
            x-transition
            class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"
        >
            <div class="bg-gray-800 rounded-lg p-6 w-full max-w-md">
                <h3 class="text-lg font-bold text-white mb-4">Create New Task</h3>

                <form wire:submit="createTask">
                    <!-- Title field -->
                    <div class="mb-4">
                        <label class="block text-sm text-gray-400 mb-1">Title</label>
                        <input
                            type="text"
                            wire:model="title"
                            class="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white"
                        />
                        @error('title')
                            <span class="text-red-400 text-xs">{{ $message }}</span>
                        @enderror
                    </div>

                    <!-- Description field -->
                    <div class="mb-4">
                        <label class="block text-sm text-gray-400 mb-1">Description</label>
                        <textarea
                            wire:model="description"
                            rows="3"
                            class="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white"
                        ></textarea>
                    </div>

                    <!-- Priority select -->
                    <div class="mb-4">
                        <label class="block text-sm text-gray-400 mb-1">Priority</label>
                        <select
                            wire:model="priority"
                            class="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white"
                        >
                            <option value="low">Low</option>
                            <option value="medium">Medium</option>
                            <option value="high">High</option>
                            <option value="urgent">Urgent</option>
                        </select>
                    </div>

                    <!-- Due date field -->
                    <div class="mb-4">
                        <label class="block text-sm text-gray-400 mb-1">Due Date</label>
                        <input
                            type="date"
                            wire:model="due_date"
                            class="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white"
                        />
                    </div>

                    <!-- Assignees multi-select -->
                    <div class="mb-4">
                        <label class="block text-sm text-gray-400 mb-1">Assignees</label>
                        <select
                            wire:model="assignees"
                            multiple
                            class="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white"
                        >
                            @foreach($this->teamMembers as $member)
                                <option value="{{ $member->id }}">{{ $member->name }}</option>
                            @endforeach
                        </select>
                    </div>

                    <!-- Form actions -->
                    <div class="flex justify-end space-x-3">
                        <button
                            type="button"
                            wire:click="$set('showModal', false)"
                            class="px-4 py-2 text-gray-400 hover:text-white"
                        >
                            Cancel
                        </button>
                        <button
                            type="submit"
                            class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700"
                        >
                            Create Task
                        </button>
                    </div>
                </form>
            </div>
        </div>
    @endif
</div>

Here is the route addition for routes/web.php (inside the tenant route group):

use App\Livewire\TaskBoard;

// Inside the tenant route group:
Route::livewire('/dashboard', TaskBoard::class)
    ->middleware(['auth'])
    ->name('tenant.dashboard');

Your interactive task board is built. In the next step, you will seed demo data for two companies and prove that tenant isolation keeps each team's tasks completely separate.

Seed Demo Data and Test Isolation

Your Kanban board is built, your roles are defined, and your subdomain routing is live. There is one problem: the app has no data to display.

In this step, you will write a database seeder that creates two complete companies with users in every role and populates each company with realistic tasks. Then you will prove that tenant isolation works by visiting each subdomain and confirming complete data separation.

In this step, get ready to:
  • Create a DatabaseSeeder with two companies, role-assigned users, and 10+ tasks per team.
  • Run the seeder and verify tenant isolation across subdomains.
  • Test role-based access by logging in as different user levels.
Create a comprehensive DatabaseSeeder

Laravel includes a DatabaseSeeder class at database/seeders/DatabaseSeeder.php. It already exists in your project from the initial Laravel setup. You will replace its contents with seed logic that creates two companies, assigns users to roles, and generates tasks.

  • Open database/seeders/DatabaseSeeder.php in your editor.
  • Replace the entire file contents with the following seeder code:
<?php

namespace Database\Seeders;

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

class DatabaseSeeder extends Seeder
{
    public function run(): void
    {
        // Define two companies to seed
        $companies = [
            ['name' => 'Acme Corp', 'slug' => 'acme'],
            ['name' => 'Stark Industries', 'slug' => 'stark'],
        ];

        foreach ($companies as $company) {
            // Create the company admin who owns the team
            $admin = User::create([
                'name' => $company['name'] . ' Admin',
                'email' => 'admin@' . $company['slug'] . '.test',
                'password' => Hash::make('password'),
            ]);

            // Create the team with forceCreate to bypass mass assignment
            $team = Team::forceCreate([
                'user_id' => $admin->id,
                'name' => $company['name'],
                'slug' => $company['slug'],
                'personal_team' => false,
            ]);

            // Attach admin with the company-admin role
            $admin->teams()->attach($team, ['role' => 'company-admin']);
            $admin->switchTeam($team);

            // Create a project lead
            $projectLead = User::create([
                'name' => $company['name'] . ' Lead',
                'email' => 'lead@' . $company['slug'] . '.test',
                'password' => Hash::make('password'),
            ]);
            $projectLead->teams()->attach($team, ['role' => 'project-lead']);

            // Create two members
            $member1 = User::create([
                'name' => $company['name'] . ' Member 1',
                'email' => 'member1@' . $company['slug'] . '.test',
                'password' => Hash::make('password'),
            ]);
            $member1->teams()->attach($team, ['role' => 'member']);

            $member2 = User::create([
                'name' => $company['name'] . ' Member 2',
                'email' => 'member2@' . $company['slug'] . '.test',
                'password' => Hash::make('password'),
            ]);
            $member2->teams()->attach($team, ['role' => 'member']);

            // Create a viewer
            $viewer = User::create([
                'name' => $company['name'] . ' Viewer',
                'email' => 'viewer@' . $company['slug'] . '.test',
                'password' => Hash::make('password'),
            ]);
            $viewer->teams()->attach($team, ['role' => 'viewer']);

            // Collect assignable team members
            $teamMembers = [$admin, $projectLead, $member1, $member2];

            // Define 12 tasks with varied statuses and priorities
            $tasks = [
                ['title' => 'Set up CI/CD pipeline', 'status' => 'done', 'priority' => 'high', 'created_by' => $admin->id],
                ['title' => 'Design landing page mockup', 'status' => 'in_review', 'priority' => 'medium', 'created_by' => $projectLead->id],
                ['title' => 'Write API documentation', 'status' => 'in_progress', 'priority' => 'low', 'created_by' => $member1->id],
                ['title' => 'Fix authentication bug', 'status' => 'in_progress', 'priority' => 'urgent', 'created_by' => $projectLead->id],
                ['title' => 'Database schema review', 'status' => 'blocked', 'priority' => 'high', 'created_by' => $admin->id],
                ['title' => 'Implement search feature', 'status' => 'todo', 'priority' => 'medium', 'created_by' => $member2->id],
                ['title' => 'Update user onboarding flow', 'status' => 'in_progress', 'priority' => 'high', 'created_by' => $projectLead->id],
                ['title' => 'Refactor notification service', 'status' => 'todo', 'priority' => 'low', 'created_by' => $member1->id],
                ['title' => 'Add dark mode toggle', 'status' => 'in_review', 'priority' => 'medium', 'created_by' => $member2->id],
                ['title' => 'Performance load testing', 'status' => 'todo', 'priority' => 'high', 'created_by' => $admin->id],
                ['title' => 'Mobile responsive fixes', 'status' => 'in_progress', 'priority' => 'urgent', 'created_by' => $projectLead->id],
                ['title' => 'Set up error monitoring', 'status' => 'done', 'priority' => 'medium', 'created_by' => $admin->id],
            ];

            // Create each task and assign 1-2 random team members
            foreach ($tasks as $taskData) {
                $task = Task::create([
                    'team_id' => $team->id,
                    'title' => $taskData['title'],
                    'description' => 'Sample task for ' . $company['name'],
                    'status' => $taskData['status'],
                    'priority' => $taskData['priority'],
                    'due_date' => now()->addDays(rand(1, 30)),
                    'created_by' => $taskData['created_by'],
                ]);

                // Randomly assign 1-2 team members to the task
                $assignees = collect($teamMembers)->random(rand(1, 2));
                $task->assignees()->attach($assignees->pluck('id'));
            }
        }
    }
}

What does this seeder do?

The seeder loops through two companies (Acme Corp and Stark Industries) and for each one creates a full team structure.

  • Team::forceCreate() bypasses mass assignment protection, which is necessary because Jetstream's Team model guards certain fields.
  • Each user is attached to the team via the pivot table with a specific role value matching the roles you defined in JetstreamServiceProvider.
  • The 12 tasks per team cover all 5 statuses (todo, in_progress, in_review, blocked, done) and all 4 priority levels (low, medium, high, urgent).
  • Each task gets 1-2 random assignees via the task_user pivot table.
  • Save database/seeders/DatabaseSeeder.php.

Seeing a class not found error?

Make sure you have the correct use statements at the top of the file importing App\Models\Task, App\Models\Team, and App\Models\User. Also confirm that your Task model exists at app/Models/Task.php from the previous step.

Help me debug a class not found error in my Laravel DatabaseSeeder

Run the seeder and verify tenant isolation

With your seeder written, it is time to populate the database and confirm that each subdomain shows only its own team's data.

  • Reset your database and run the seeder by running this command in Laragon Terminal:
php artisan db:seed

What does this command do?

php artisan db:seed executes the run() method in your DatabaseSeeder class, populating your database with all the demo teams, users, and tasks you just defined.

  • Open your browser and visit http://acme.clusterfiy.test.
  • Log in with email admin@acme.test and password password.
  • Confirm that your Kanban board displays only Acme Corp tasks spread across the 5 status columns.

You should see 12 task cards distributed across the Todo, In Progress, In Review, Blocked, and Done columns.

  • Open a new browser tab (or use an incognito window) and visit http://stark.clusterfiy.test.
  • Log in with email admin@stark.test and password password.
  • Confirm that this board shows only Stark Industries tasks. None of Acme's tasks should appear here.

Why is this proof important?

Tenant isolation is the foundation of any multi-tenant SaaS. If data leaks between tenants, it is a critical security vulnerability.

By verifying that each subdomain serves only its own team's data, you have confirmed that your ResolveTenant middleware and scopeForTeam query scope are working correctly together.

Seeing an empty board or a 404?

  • Check that your hosts file includes entries for both 127.0.0.1 acme.clusterfiy.test and 127.0.0.1 stark.clusterfiy.test.
  • Confirm Laragon's Apache is running and your vhost config has ServerAlias *.clusterfiy.test.
  • Verify your .env has SESSION_DOMAIN=.clusterfiy.test (with the leading dot) so the auth cookie works across subdomains.
  • If you see a duplicate entry error, run php artisan migrate:fresh --seed to drop all tables and start from a clean database.

Help me troubleshoot why my Laravel subdomain routing shows a 404 or empty page

Test role-based access

Tenant isolation proves data separation. Now you need to confirm that the role hierarchy actually restricts what each user can do within their own team.

  • Log out of the admin account on http://acme.clusterfiy.test.
  • Log in as the viewer with email viewer@acme.test and password password.
  • Confirm that the Kanban board appears in read-only mode. The New Task button should not be visible, and task cards should not show edit or status-change controls.
  • Log out and log in as a member with email member1@acme.test and password password.
  • Confirm that the New Task button is visible and you can create a task.
  • Confirm that you can update tasks you created, but the Assign button does not appear on task cards.
  • Log out and log in as the project lead with email lead@acme.test and password password.
  • Confirm that you can assign team members to tasks and change the status of any task on the board.
  • Log out and log in as the company admin with email admin@acme.test and password password.
  • Confirm that you can delete tasks and manage team members through Jetstream's built-in team settings page.

What are you verifying?

Each login test confirms a different tier of the permission hierarchy you defined in JetstreamServiceProvider. The @can directives in your Blade templates and the TaskPolicy checks in your Livewire component work together to show or hide UI elements based on the authenticated user's role.

Viewer can still see the New Task button?

  • Check that your Blade template wraps the button in @can('create', App\Models\Task::class) or uses hasTeamPermission to gate the element.
  • Confirm that the viewer role in JetstreamServiceProvider does not include task:create in its permissions array.
  • Make sure you fully logged out before logging in as the viewer. Session cookies may persist the previous user's permissions.

Help me debug why my viewer role still sees create buttons on the task board

✔️ Awesome, I've got everything!

Great. Double check that you have saved database/seeders/DatabaseSeeder.php and that your seeder ran without errors.

ⓧ I'd like to double check the full code

Here is the complete DatabaseSeeder.php file for comparison:

<?php

namespace Database\Seeders;

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

class DatabaseSeeder extends Seeder
{
    public function run(): void
    {
        // Define two companies to seed
        $companies = [
            ['name' => 'Acme Corp', 'slug' => 'acme'],
            ['name' => 'Stark Industries', 'slug' => 'stark'],
        ];

        foreach ($companies as $company) {
            // Create the company admin who owns the team
            $admin = User::create([
                'name' => $company['name'] . ' Admin',
                'email' => 'admin@' . $company['slug'] . '.test',
                'password' => Hash::make('password'),
            ]);

            // Create the team with forceCreate to bypass mass assignment
            $team = Team::forceCreate([
                'user_id' => $admin->id,
                'name' => $company['name'],
                'slug' => $company['slug'],
                'personal_team' => false,
            ]);

            // Attach admin with the company-admin role
            $admin->teams()->attach($team, ['role' => 'company-admin']);
            $admin->switchTeam($team);

            // Create a project lead
            $projectLead = User::create([
                'name' => $company['name'] . ' Lead',
                'email' => 'lead@' . $company['slug'] . '.test',
                'password' => Hash::make('password'),
            ]);
            $projectLead->teams()->attach($team, ['role' => 'project-lead']);

            // Create two members
            $member1 = User::create([
                'name' => $company['name'] . ' Member 1',
                'email' => 'member1@' . $company['slug'] . '.test',
                'password' => Hash::make('password'),
            ]);
            $member1->teams()->attach($team, ['role' => 'member']);

            $member2 = User::create([
                'name' => $company['name'] . ' Member 2',
                'email' => 'member2@' . $company['slug'] . '.test',
                'password' => Hash::make('password'),
            ]);
            $member2->teams()->attach($team, ['role' => 'member']);

            // Create a viewer
            $viewer = User::create([
                'name' => $company['name'] . ' Viewer',
                'email' => 'viewer@' . $company['slug'] . '.test',
                'password' => Hash::make('password'),
            ]);
            $viewer->teams()->attach($team, ['role' => 'viewer']);

            // Collect assignable team members
            $teamMembers = [$admin, $projectLead, $member1, $member2];

            // Define 12 tasks with varied statuses and priorities
            $tasks = [
                ['title' => 'Set up CI/CD pipeline', 'status' => 'done', 'priority' => 'high', 'created_by' => $admin->id],
                ['title' => 'Design landing page mockup', 'status' => 'in_review', 'priority' => 'medium', 'created_by' => $projectLead->id],
                ['title' => 'Write API documentation', 'status' => 'in_progress', 'priority' => 'low', 'created_by' => $member1->id],
                ['title' => 'Fix authentication bug', 'status' => 'in_progress', 'priority' => 'urgent', 'created_by' => $projectLead->id],
                ['title' => 'Database schema review', 'status' => 'blocked', 'priority' => 'high', 'created_by' => $admin->id],
                ['title' => 'Implement search feature', 'status' => 'todo', 'priority' => 'medium', 'created_by' => $member2->id],
                ['title' => 'Update user onboarding flow', 'status' => 'in_progress', 'priority' => 'high', 'created_by' => $projectLead->id],
                ['title' => 'Refactor notification service', 'status' => 'todo', 'priority' => 'low', 'created_by' => $member1->id],
                ['title' => 'Add dark mode toggle', 'status' => 'in_review', 'priority' => 'medium', 'created_by' => $member2->id],
                ['title' => 'Performance load testing', 'status' => 'todo', 'priority' => 'high', 'created_by' => $admin->id],
                ['title' => 'Mobile responsive fixes', 'status' => 'in_progress', 'priority' => 'urgent', 'created_by' => $projectLead->id],
                ['title' => 'Set up error monitoring', 'status' => 'done', 'priority' => 'medium', 'created_by' => $admin->id],
            ];

            // Create each task and assign 1-2 random team members
            foreach ($tasks as $taskData) {
                $task = Task::create([
                    'team_id' => $team->id,
                    'title' => $taskData['title'],
                    'description' => 'Sample task for ' . $company['name'],
                    'status' => $taskData['status'],
                    'priority' => $taskData['priority'],
                    'due_date' => now()->addDays(rand(1, 30)),
                    'created_by' => $taskData['created_by'],
                ]);

                // Randomly assign 1-2 team members to the task
                $assignees = collect($teamMembers)->random(rand(1, 2));
                $task->assignees()->attach($assignees->pluck('id'));
            }
        }
    }
}

Your multi-tenant task manager now has two fully isolated companies, each with a complete user roster and a populated Kanban board. The role system gates every action based on the authenticated user's permissions. Next up, you will take this further with an activity log that tracks every status change.

Secret mission

Add an Activity Log Timeline

Every professional project management tool tracks what happened, who did it, and when. Your Kanban board works great, but there is no history. If a task jumps from "In Progress" to "Done" overnight, nobody knows who moved it or why. Build an activity log that records every status change and displays it as a timeline on each task card.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally using Laragon's MySQL database and Apache server. No cloud resources or paid services were used, so there are no ongoing costs.

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • The clusterfiy project folder in C:\laragon\www\.
  • MySQL database created by Laravel migrations.
  • Custom Apache vhost config for clusterfiy.test wildcard subdomains.
  • Windows hosts file entries for clusterfiy.test subdomains.
  • Laravel installer installed globally via Composer.

Keep everything running

No action needed. Choose this if you're still actively building Clusterfiy or plan to extend it with more features.

  • Leave Laragon running. Your MySQL data persists between restarts automatically.
  • Your custom vhost config and hosts file entries stay in place for subdomain routing.
  • The Laravel installer remains available globally for creating new projects.

Pause - I'll come back to this later

Shut down running processes to free up memory. All your data and configuration are preserved.

  • Right-click the Laragon icon in your system tray and select Stop All.
  • This stops Apache and MySQL but leaves your project files, database, and configuration intact.
  • When you're ready to resume, open Laragon and click Start All to bring everything back up.

Delete - I don't want to use this again

Remove all project resources and start fresh. This is permanent.

  • Drop the database. Open Laragon Terminal and run:
cd C:\laragon\www\clusterfiy
php artisan db:wipe
  • Delete the project folder. In Laragon Terminal, run:
cd C:\laragon\www
rmdir /s /q clusterfiy
  • Remove the custom Apache vhost config. Delete the file clusterfiy.test.conf from C:\laragon\etc\apache2\sites-enabled\.
  • Clean up your hosts file. Open C:\Windows\System32\drivers\etc\hosts in a text editor (run as Administrator) and remove the lines you added for clusterfiy.test, acme.clusterfiy.test, and stark.clusterfiy.test.
  • Remove the Laravel installer globally by running:
composer global remove laravel/installer
  • Restart Laragon's Apache so it picks up the removed vhost config.

Nice Work!

Nice Work!

You just built a production-architecture multi-tenant SaaS from scratch. Clusterfiy has subdomain isolation, a 5-role permission system, and an interactive Kanban board. This is the exact foundation pattern behind tools like Linear, Asana, and Monday.com.

You've learned how to:

  • Configure subdomain-based tenant isolation with custom Apache vhosts, Laravel middleware, and session cookies shared across subdomains.
  • Define a 5-role permission hierarchy (Super Admin through Viewer) with granular task permissions enforced by policies and Blade directives.
  • Build an interactive Kanban board using Livewire for server-state and Alpine.js for client-side reactivity with status workflows, priority badges, and assignee avatars.
  • Build an activity log timeline tracking every task status change with user attribution and relative timestamps.

Ready to quiz yourself?