Example Module: Projects
SaasKitFy ships with a complete example module — Projects — that demonstrates how all the extension points connect. This walkthrough covers every piece so you can use it as a template for your own features.
1. Migration
File: database/migrations/0005_01_01_000000_create_example_module_tables.php
Schema::create('projects', function (Blueprint $table) {
$table->id();
$table->foreignId('organization_id')
->constrained()->cascadeOnDelete();
$table->foreignId('created_by')
->constrained('users')->cascadeOnDelete();
$table->string('name');
$table->text('description')->nullable();
$table->string('status')->default('backlog');
$table->timestamp('due_date')->nullable();
$table->timestamps();
$table->index(['organization_id', 'status']);
});
Schema::create('project_comments', function (Blueprint $table) {
$table->id();
$table->foreignId('project_id')
->constrained()->cascadeOnDelete();
$table->foreignId('user_id')
->constrained()->cascadeOnDelete();
$table->foreignId('parent_id')->nullable()
->constrained('project_comments')->nullOnDelete();
$table->text('body');
$table->timestamps();
});
2. Models
File: app/Models/Custom/Project.php
namespace App\Models\Custom;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
class Project extends Model
{
const STATUSES = ['backlog', 'in_progress', 'review', 'done'];
protected $fillable = [
'organization_id', 'created_by', 'name',
'description', 'status', 'due_date',
];
public function organization() { return $this->belongsTo(Organization::class); }
public function creator() { return $this->belongsTo(User::class, 'created_by'); }
public function comments() { return $this->hasMany(ProjectComment::class); }
}
3. Config Registration
File: config/custom.php
return [
'features' => [
'projects' => [
'label' => 'Projects',
'description' => 'Project management with statuses and comments',
],
],
'webhook_events' => [
'project.created' => ['group' => 'Projects', 'description' => 'A new project was created'],
'project.status_changed' => ['group' => 'Projects', 'description' => 'A project status was updated'],
'comment.added' => ['group' => 'Projects', 'description' => 'A comment was added to a project'],
],
'limits' => [
'projects' => 'Projects',
],
'permissions' => [
'view_projects',
'create_projects',
'update_projects',
'delete_projects',
],
];
This single file registration gives you:
- A "Projects" feature toggle in Admin → Settings → Features
- Per-plan entitlement in Admin → Plans
- Three webhook events in the webhook UI
- A "Projects" resource limit per plan
- Four permissions assignable to org roles
4. Routes
File: routes/custom.php
use App\Http\Controllers\Custom\ProjectController;
Route::prefix('projects')
->middleware('entitled:projects')
->group(function () {
Route::get('/', [ProjectController::class, 'index'])->middleware('org.can:view_projects');
Route::post('/', [ProjectController::class, 'store'])->middleware('org.can:create_projects');
Route::get('/{project}', [ProjectController::class, 'show'])->middleware('org.can:view_projects');
Route::patch('/{project}', [ProjectController::class, 'update'])->middleware('org.can:update_projects');
Route::delete('/{project}', [ProjectController::class, 'destroy'])->middleware('org.can:delete_projects');
});
5. Controller
File: app/Http/Controllers/Custom/ProjectController.php
Key patterns demonstrated in the controller:
- Org scoping:
$org = $request->user()->activeOrganization() - Limit checking:
$org->isOverLimit('projects')before creating - Ownership verification:
abort_if($project->organization_id !== $org->id, 403) - Webhook dispatch:
WebhookDispatcher::dispatch($org->id, 'project.created', [...])
public function store(Request $request): JsonResponse
{
$org = $request->user()->activeOrganization();
if ($org->isOverLimit('projects')) {
return response()->json([
'message' => 'Project limit reached on your current plan.',
'upgrade' => true,
], 403);
}
$data = $request->validate([
'name' => 'required|string|max:200',
'description' => 'nullable|string|max:5000',
'status' => 'sometimes|in:backlog,in_progress,review,done',
'due_date' => 'nullable|date',
]);
$project = Project::create([
...$data,
'organization_id' => $org->id,
'created_by' => $request->user()->id,
]);
WebhookDispatcher::dispatch($org->id, 'project.created', [
'project_id' => $project->id,
'name' => $project->name,
]);
return response()->json($project, 201);
}
6. Frontend Nav Item
File: frontend/src/custom/navItems.ts
import { FolderKanban } from 'lucide-react'
export const customNavItems: CustomNavItem[] = [
{
label: 'Projects',
icon: FolderKanban,
href: '/org/projects',
requires: 'org:member',
feature: 'projects',
permission: 'view_projects',
},
]
7. Frontend Routes
File: frontend/src/custom/routes.tsx
import ProjectsPage from '@/pages/custom/ProjectsPage'
import ProjectDetailPage from '@/pages/custom/ProjectDetailPage'
export const customRoutes: CustomRoute[] = [
{ path: '/org/projects', element: <ProjectsPage />,
permission: 'view_projects', feature: 'projects' },
{ path: '/org/projects/:projectId', element: <ProjectDetailPage />,
permission: 'view_projects', feature: 'projects' },
]
How It All Connects
config/custom.phpregisters everything with the admin panelentitled:projectsmiddleware gates API access by planorg.can:*middleware enforces role-based permissionsisOverLimit('projects')enforces plan resource limitsWebhookDispatcherfires events to subscriber endpointsnavItems.tsrenders the sidebar link (hidden if feature/permission missing)routes.tsxmaps URLs to React pages with access control