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:
- Projects - Organize work into containers with color coding and archiving
- Tasks - Track individual work items with status, priority, and assignments
- Dashboard - Real-time overview of project health and personal tasks
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:
- UUID for public identification
- Color-coded for visual distinction
- Archive/unarchive capability
- Flexible settings storage (JSON)
- Automatic tenant assignment
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:
- Status workflow (Todo → In Progress → In Review → Done)
- Priority levels (Low, Medium, High, Urgent)
- Assignment to team members
- Due date tracking with overdue detection
- Auto-completion timestamp
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:
- Automatically filters queries to current tenant
- Auto-assigns
tenant_idon create - 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:
- Color picker for project identification
- Progress percentage display
- Archive/unarchive toggle action
- Related tasks management via relation manager
- Filter for archived projects
Task Resource
Full CRUD with:
- Status and priority badge displays
- User assignment with tenant-scoped dropdown
- Quick "Complete" action for open tasks
- Rich filters: project, status, priority, assignee, overdue, my tasks
- Navigation badge showing open task count
Dashboard Widgets
ProjectsOverviewWidget:
- Active project count
- Total open tasks
- Overdue task count (with warning color)
- Completed this week count
RecentTasksWidget:
- Shows 5 most recent tasks assigned to current user
- Sorted by priority (urgent first)
- Quick complete action
- Links to full task view
Testing
Tenant Isolation Tests
Located in tests/Feature/ExampleApp/TenantIsolationTest.php
Test Categories:
- Data Isolation - Users can only see their tenant's data
- Cross-Tenant Access - Queries never leak data between tenants
- Policy Enforcement - Authorization blocks cross-tenant access
- Relation Scoping - Related models respect tenant boundaries
- 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
- Add case to
TaskStatusenum:
case BLOCKED = 'blocked';
- 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
};
}
- 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
- Tenant Isolation - Enforced at model (global scope) and policy levels
- Permission Checks - All actions require appropriate
TenantPermission - User Scoping - Assignment dropdowns only show tenant members
- No Direct ID Access - UUIDs used for public references
- Defense in Depth - Multiple layers prevent data leaks
Demo Data
The ExampleAppSeeder creates realistic demo data:
Projects:
- Website Redesign (blue)
- Mobile App Development (green)
- Marketing Campaign (amber)
Tasks per Project: 4-5 tasks with varying statuses, priorities, and due dates
# Seed demo data
php artisan db:seed --class=ExampleAppSeeder