T
Tenanto
Documentation / Example Module

Example Module

Updated Jan 25, 2026

Example Module - Projects & Tasks

This document describes the Example Module that demonstrates multi-tenant data isolation and FilamentPHP integration in Tenanto.


Overview

The Example Module provides a complete project management system with:

This module serves as a reference implementation for building tenant-isolated features.


Architecture

Domain Structure

app/Domain/ExampleApp/
├── Enums/
│   ├── TaskStatus.php      # Task workflow states
│   └── TaskPriority.php    # Task urgency levels
└── Models/
    ├── Project.php         # Project entity with BelongsToTenant
    └── Task.php            # Task entity with relations

Filament Resources

app/Filament/Tenant/
├── Resources/
│   ├── ProjectResource/
│   │   ├── Pages/
│   │   │   ├── ListProjects.php
│   │   │   ├── CreateProject.php
│   │   │   ├── ViewProject.php
│   │   │   └── EditProject.php
│   │   └── RelationManagers/
│   │       └── TasksRelationManager.php
│   ├── ProjectResource.php
│   └── TaskResource/
│       └── Pages/...
│       └── TaskResource.php
└── Widgets/
    ├── ProjectsOverviewWidget.php
    └── RecentTasksWidget.php

Models

Project Model

Projects are the primary container for organizing tasks.

Key Features:

Usage:

use App\Domain\ExampleApp\Models\Project;

// Create a project (tenant auto-assigned from context)
$project = Project::create([
    'name' => 'Website Redesign',
    'description' => 'Complete overhaul of company website',
    'color' => '#3B82F6',
]);

// Archive a project
$project->archive();

// Get completion percentage
$percentage = $project->getCompletionPercentage(); // 40.0

// Use settings
$project->setSetting('notifications.email', true);
$project->save();
$notify = $project->getSetting('notifications.email'); // true

// Query scopes
$active = Project::active()->get();      // Non-archived projects
$archived = Project::archived()->get();  // Archived only

Properties:

Property Type Description
id int Internal ID
uuid string Public UUID
tenant_id int Owner tenant
name string Project name
description string null
color string Hex color code
is_archived bool Archive status
settings array Flexible JSON settings

Computed Attributes:

Attribute Type Description
open_tasks_count int Count of non-completed tasks
completed_tasks_count int Count of completed tasks

Task Model

Tasks represent individual work items within a project.

Key Features:

Usage:

use App\Domain\ExampleApp\Models\Task;
use App\Domain\ExampleApp\Enums\TaskStatus;
use App\Domain\ExampleApp\Enums\TaskPriority;

// Create a task
$task = Task::create([
    'project_id' => $project->id,
    'title' => 'Design homepage mockup',
    'description' => 'Create high-fidelity mockup',
    'status' => TaskStatus::TODO,
    'priority' => TaskPriority::HIGH,
    'due_date' => now()->addDays(7),
]);

// Assign to user
$task->assignTo($user);

// Mark as completed
$task->markAsDone();

// Check status
$task->isOverdue();    // true if past due and still open
$task->isDueSoon();    // true if due within 3 days
$task->isCompleted();  // true if status is DONE

// Query scopes
$open = Task::open()->get();                    // Non-completed tasks
$myTasks = Task::assignedTo($user)->get();      // User's assignments
$urgent = Task::orderByPriority()->get();       // Sorted by urgency
$overdue = Task::overdue()->get();              // Past due date

Properties:

Property Type Description
id int Internal ID
uuid string Public UUID
tenant_id int Owner tenant
project_id int Parent project
assigned_to int null
created_by int null
title string Task title
description string null
status TaskStatus Workflow state
priority TaskPriority Urgency level
due_date Carbon null
completed_at Carbon null
sort_order int Display order

Enums

TaskStatus

Workflow states for task progression.

Value Label Color Open?
TODO To Do gray Yes
IN_PROGRESS In Progress blue Yes
IN_REVIEW In Review yellow Yes
DONE Done green No
CANCELLED Cancelled red No

Methods:

TaskStatus::TODO->label();     // "To Do"
TaskStatus::TODO->color();     // "gray"
TaskStatus::TODO->icon();      // "heroicon-o-circle"
TaskStatus::TODO->isOpen();    // true

TaskStatus::values();          // ['todo', 'in_progress', ...]
TaskStatus::options();         // ['todo' => 'To Do', ...]

TaskPriority

Urgency levels for task prioritization.

Value Label Color Sort Order
LOW Low gray 4
MEDIUM Medium blue 3
HIGH High warning 2
URGENT Urgent danger 1

Methods:

TaskPriority::URGENT->label();      // "Urgent"
TaskPriority::URGENT->color();      // "danger"
TaskPriority::URGENT->icon();       // "heroicon-o-exclamation-triangle"
TaskPriority::URGENT->sortOrder();  // 1

TaskPriority::values();             // ['low', 'medium', ...]
TaskPriority::options();            // ['low' => 'Low', ...]

Tenant Isolation

Automatic Scoping

Both Project and Task models use the BelongsToTenant trait which:

  1. Automatically filters queries to current tenant
  2. Auto-assigns tenant_id on create
  3. Prevents cross-tenant data access
// This trait provides automatic isolation
use App\Domain\Tenancy\Traits\BelongsToTenant;

class Project extends Model
{
    use BelongsToTenant;
}

Global Scope

The TenantScope is automatically applied:

// These queries are automatically scoped to current tenant
Project::all();                    // Only current tenant's projects
Task::where('status', 'todo');     // Only current tenant's tasks

// Admin bypass (use with caution!)
Project::withoutGlobalScopes()->get();  // All tenants (admin only)

Policy Authorization

Policies provide an additional security layer:

// ProjectPolicy.php
public function view(User $user, Project $project): bool
{
    return $user->can(TenantPermission::VIEW_PROJECTS->value)
        && $this->belongsToUserTenant($user, $project);
}

private function belongsToUserTenant(User $user, Project $project): bool
{
    return $user->tenant_id === $project->tenant_id;
}

Filament Integration

Tenant Panel

The tenant admin panel is accessible at /app on any tenant subdomain.

Configuration: app/Providers/Filament/TenantPanelProvider.php

$panel
    ->id('tenant')
    ->path('app')
    ->middleware([
        // ... standard middleware
        TenantMiddleware::class,
    ])
    ->discoverResources(in: app_path('Filament/Tenant/Resources'))
    ->discoverWidgets(in: app_path('Filament/Tenant/Widgets'))

Project Resource

Full CRUD with:

Task Resource

Full CRUD with:

Dashboard Widgets

ProjectsOverviewWidget:

RecentTasksWidget:


Testing

Tenant Isolation Tests

Located in tests/Feature/ExampleApp/TenantIsolationTest.php

Test Categories:

  1. Data Isolation - Users can only see their tenant's data
  2. Cross-Tenant Access - Queries never leak data between tenants
  3. Policy Enforcement - Authorization blocks cross-tenant access
  4. Relation Scoping - Related models respect tenant boundaries
  5. Admin Override - withoutGlobalScopes() works for admins
# Run isolation tests
php artisan test --filter=TenantIsolationTest

# Expected: 17 tests, 26 assertions

Unit Tests

TaskStatusTest - Enum method verification ProjectModelTest - Model functionality and scopes

# Run all Example Module tests
php artisan test tests/Unit/Domain/ExampleApp
php artisan test tests/Feature/ExampleApp

Database Schema

projects Table

CREATE TABLE projects (
    id BIGINT PRIMARY KEY,
    uuid VARCHAR(36) UNIQUE NOT NULL,
    tenant_id BIGINT NOT NULL REFERENCES tenants(id),
    name VARCHAR(255) NOT NULL,
    description TEXT NULL,
    color VARCHAR(7) DEFAULT '#6B7280',
    is_archived BOOLEAN DEFAULT FALSE,
    settings JSON NULL,
    created_at TIMESTAMP,
    updated_at TIMESTAMP,

    INDEX idx_projects_tenant_archived (tenant_id, is_archived),
    INDEX idx_projects_tenant_created (tenant_id, created_at)
);

tasks Table

CREATE TABLE tasks (
    id BIGINT PRIMARY KEY,
    uuid VARCHAR(36) UNIQUE NOT NULL,
    tenant_id BIGINT NOT NULL REFERENCES tenants(id),
    project_id BIGINT NOT NULL REFERENCES projects(id),
    assigned_to BIGINT NULL REFERENCES users(id),
    created_by BIGINT NULL REFERENCES users(id),
    title VARCHAR(255) NOT NULL,
    description TEXT NULL,
    status VARCHAR(20) DEFAULT 'todo',
    priority VARCHAR(20) DEFAULT 'medium',
    due_date DATE NULL,
    completed_at TIMESTAMP NULL,
    sort_order INT DEFAULT 0,
    created_at TIMESTAMP,
    updated_at TIMESTAMP,

    INDEX idx_tasks_tenant_status (tenant_id, status),
    INDEX idx_tasks_tenant_project (tenant_id, project_id),
    INDEX idx_tasks_tenant_assigned (tenant_id, assigned_to),
    INDEX idx_tasks_project_sort (project_id, sort_order)
);

Extending the Module

Adding a New Task Status

  1. Add case to TaskStatus enum:
case BLOCKED = 'blocked';
  1. Update enum methods:
public function label(): string
{
    return match ($this) {
        // ...
        self::BLOCKED => 'Blocked',
    };
}

public function isOpen(): bool
{
    return match ($this) {
        // ...
        self::BLOCKED => true,  // Still open
    };
}
  1. Update database migration if needed

Adding Custom Project Settings

Settings use dot-notation for nested values:

// Store complex settings
$project->setSetting('workflow.require_review', true);
$project->setSetting('notifications.channels', ['email', 'slack']);
$project->save();

// Retrieve with defaults
$requireReview = $project->getSetting('workflow.require_review', false);
$channels = $project->getSetting('notifications.channels', []);

Creating a Custom Scope

Add to the model class:

public function scopeHighPriority(Builder $query): Builder
{
    return $query->whereHas('tasks', function ($q) {
        $q->where('priority', TaskPriority::URGENT->value)
          ->orWhere('priority', TaskPriority::HIGH->value);
    });
}

// Usage
Project::highPriority()->get();

Security Considerations

  1. Tenant Isolation - Enforced at model (global scope) and policy levels
  2. Permission Checks - All actions require appropriate TenantPermission
  3. User Scoping - Assignment dropdowns only show tenant members
  4. No Direct ID Access - UUIDs used for public references
  5. Defense in Depth - Multiple layers prevent data leaks

Demo Data

The ExampleAppSeeder creates realistic demo data:

Projects:

Tasks per Project: 4-5 tasks with varying statuses, priorities, and due dates

# Seed demo data
php artisan db:seed --class=ExampleAppSeeder

Related Documentation