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.php registers everything with the admin panel
  • entitled:projects middleware gates API access by plan
  • org.can:* middleware enforces role-based permissions
  • isOverLimit('projects') enforces plan resource limits
  • WebhookDispatcher fires events to subscriber endpoints
  • navItems.ts renders the sidebar link (hidden if feature/permission missing)
  • routes.tsx maps URLs to React pages with access control