Example Module — API Keys
This walkthrough builds a complete API Keys module from scratch. Subscribers can generate API keys for their SaaS subscription. Admins can view all keys. The number of keys is limited by the user's plan.
By the end you will have used:
- Migrations and models
- Admin sidebar injection
- Account sidebar injection with a condition
saas.authmiddlewareSubscriptionActivatedevent listener- Plan entitlement feature gating
1. Scaffold
php artisan module:make ApiKeys
php artisan module:install ApiKeys2. module.json
{
"name": "API Keys",
"slug": "api-keys",
"version": "1.0.0",
"description": "Let subscribers generate and manage API keys.",
"author": "Your Name",
"requires": {
"chargepanda": ">=1.0.0"
}
}3. Migration
modules/ApiKeys/database/migrations/2026_01_01_000001_create_api_keys_table.php
<?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::create('api_keys', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('subscription_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->string('key', 64)->unique();
$table->timestamp('last_used_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('api_keys');
}
};4. Model
modules/ApiKeys/Models/ApiKey.php
<?php
namespace Modules\ApiKeys\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
class ApiKey extends Model
{
protected $guarded = ['id'];
protected static function booted(): void
{
static::creating(function (ApiKey $key) {
$key->key = 'sk_' . Str::random(40);
});
}
public function user()
{
return $this->belongsTo(\App\Models\User::class);
}
public function subscription()
{
return $this->belongsTo(\App\Models\Subscription::class);
}
}5. Controllers
Account controller
modules/ApiKeys/Http/Controllers/ApiKeysAccountController.php
<?php
namespace Modules\ApiKeys\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Modules\ApiKeys\Models\ApiKey;
use App\Models\PlanEntitlement;
class ApiKeysAccountController extends Controller
{
public function index(Request $request)
{
$subscription = $request->attributes->get('saas_subscription');
$tier = $request->attributes->get('saas_tier');
$plan = $tier->plan;
$keys = ApiKey::where('user_id', auth()->id())->latest()->get();
// Check plan limit
$limitEntitlement = PlanEntitlement::where('plan_id', $plan->id)
->where('key', 'api_keys')
->first();
$limit = $limitEntitlement?->numericLimit(); // null = unlimited
$canCreate = $limit === null || $keys->count() < $limit;
return view('api-keys::account.index', compact('keys', 'limit', 'canCreate', 'subscription'));
}
public function store(Request $request)
{
$request->validate(['name' => 'required|string|max:100']);
$subscription = $request->attributes->get('saas_subscription');
$tier = $request->attributes->get('saas_tier');
$plan = $tier->plan;
// Enforce limit
$limitEntitlement = PlanEntitlement::where('plan_id', $plan->id)
->where('key', 'api_keys')
->first();
$limit = $limitEntitlement?->numericLimit();
if ($limit !== null) {
$current = ApiKey::where('user_id', auth()->id())->count();
abort_if($current >= $limit, 422, 'API key limit reached for your plan.');
}
ApiKey::create([
'user_id' => auth()->id(),
'subscription_id' => $subscription->id,
'name' => $request->name,
]);
return back()->with('success', 'API key created.');
}
public function destroy(int $id)
{
ApiKey::where('id', $id)
->where('user_id', auth()->id())
->firstOrFail()
->delete();
return back()->with('success', 'API key deleted.');
}
}Admin controller
modules/ApiKeys/Http/Controllers/Admin/ApiKeysAdminController.php
<?php
namespace Modules\ApiKeys\Http\Controllers\Admin;
use Illuminate\Routing\Controller;
use Modules\ApiKeys\Models\ApiKey;
class ApiKeysAdminController extends Controller
{
public function index()
{
$keys = ApiKey::with('user')->latest()->paginate(30);
return view('api-keys::admin.index', compact('keys'));
}
}6. Routes
modules/ApiKeys/routes/web.php
<?php
use Modules\ApiKeys\Http\Controllers\Admin\ApiKeysAdminController;
use Modules\ApiKeys\Http\Controllers\ApiKeysAccountController;
// Admin
Route::prefix('ch-admin/api-keys')
->middleware(['web', 'auth', 'role:administrator'])
->name('ch-admin.api-keys.')
->group(function () {
Route::get('/', [ApiKeysAdminController::class, 'index'])->name('index');
});
// Customer account (requires active SaaS subscription)
Route::prefix('account/api-keys')
->middleware(['web', 'auth', 'saas.auth'])
->name('api-keys.account.')
->group(function () {
Route::get('/', [ApiKeysAccountController::class, 'index'])->name('index');
Route::post('/', [ApiKeysAccountController::class, 'store'])->name('store');
Route::delete('/{id}',[ApiKeysAccountController::class, 'destroy'])->name('destroy');
});7. Event listener — auto-generate first key on subscribe
modules/ApiKeys/Listeners/CreateInitialApiKey.php
<?php
namespace Modules\ApiKeys\Listeners;
use App\Events\SubscriptionActivated;
use Modules\ApiKeys\Models\ApiKey;
class CreateInitialApiKey
{
public function handle(SubscriptionActivated $event): void
{
$subscription = $event->subscription;
// Only for our product
$linkedProductId = setting('saas.linked_product_id');
if (! $linkedProductId) {
return;
}
// Don't create duplicates
if (ApiKey::where('subscription_id', $subscription->id)->exists()) {
return;
}
ApiKey::create([
'user_id' => $subscription->user_id,
'subscription_id' => $subscription->id,
'name' => 'Default',
]);
}
}8. Views
Account page
modules/ApiKeys/resources/views/account/index.blade.php
@extends('theme::layouts.app', ['title' => 'API Keys'])
@section('content')
<main class="flex-grow w-full max-w-[1440px] mx-auto px-6 lg:px-10 py-10">
<div class="flex flex-col lg:flex-row gap-10">
<aside class="w-full lg:w-64 flex-shrink-0">
@include('theme::partials.account.nav')
</aside>
<section class="flex-grow">
<div class="flex items-center justify-between mb-8">
<div>
<h1 class="text-2xl font-bold text-text-main dark:text-white">API Keys</h1>
@if($limit !== null)
<p class="text-sm text-text-muted mt-1">{{ $keys->count() }} / {{ $limit }} keys used</p>
@endif
</div>
@if($canCreate)
<button onclick="document.getElementById('create-form').classList.toggle('hidden')"
class="px-4 py-2 bg-primary text-white rounded-xl text-sm font-semibold hover:bg-primary-dark transition-all">
+ New Key
</button>
@endif
</div>
{{-- Create form --}}
<div id="create-form" class="hidden mb-6 bg-white dark:bg-surface-dark border border-gray-100 dark:border-gray-800 rounded-2xl p-5 shadow-sm">
<form method="POST" action="{{ route('api-keys.account.store') }}" class="flex gap-3 items-end">
@csrf
<div class="flex-1">
<label class="block text-xs font-bold uppercase tracking-wider text-text-muted mb-1">Key Name</label>
<input type="text" name="name" placeholder="e.g. Production" required
class="w-full border border-gray-200 dark:border-gray-700 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary/30">
</div>
<button type="submit" class="px-4 py-2.5 bg-primary text-white rounded-xl text-sm font-semibold hover:bg-primary-dark transition-all">
Create
</button>
</form>
</div>
{{-- Keys list --}}
<div class="bg-white dark:bg-surface-dark border border-gray-100 dark:border-gray-800 rounded-2xl shadow-sm overflow-hidden">
@forelse($keys as $apiKey)
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-50 dark:border-gray-800 last:border-0">
<div>
<p class="text-sm font-semibold text-text-main dark:text-white">{{ $apiKey->name }}</p>
<p class="text-xs font-mono text-text-muted mt-0.5">{{ $apiKey->key }}</p>
</div>
<form method="POST" action="{{ route('api-keys.account.destroy', $apiKey->id) }}"
onsubmit="return confirm('Delete this key? This cannot be undone.')">
@csrf @method('DELETE')
<button type="submit" class="text-xs text-red-500 hover:text-red-700 font-semibold">Delete</button>
</form>
</div>
@empty
<div class="px-6 py-10 text-center text-sm text-text-muted">
No API keys yet.
@if($canCreate) Click <strong>+ New Key</strong> to create one. @endif
</div>
@endforelse
</div>
@if(! $canCreate)
<p class="text-sm text-text-muted mt-4">
You've reached your plan's API key limit.
<a href="{{ route('saas.pricing') }}" class="text-primary font-semibold hover:underline">Upgrade your plan</a> to create more.
</p>
@endif
</section>
</div>
</main>
@endsectionAdmin page
modules/ApiKeys/resources/views/admin/index.blade.php
@extends('admin.layouts.app', ['title' => 'API Keys'])
@section('content')
<div class="container-xl">
<div class="page-header mb-4">
<h2 class="page-title">API Keys</h2>
</div>
<div class="card">
<div class="table-responsive">
<table class="table table-vcenter card-table">
<thead>
<tr>
<th>User</th>
<th>Name</th>
<th>Key</th>
<th>Last Used</th>
<th>Created</th>
</tr>
</thead>
<tbody>
@forelse($keys as $key)
<tr>
<td>{{ $key->user->name }}</td>
<td>{{ $key->name }}</td>
<td class="font-mono text-muted">{{ $key->key }}</td>
<td>{{ $key->last_used_at?->diffForHumans() ?? '—' }}</td>
<td>{{ $key->created_at->format('M j, Y') }}</td>
</tr>
@empty
<tr><td colspan="5" class="text-center text-muted">No API keys found.</td></tr>
@endforelse
</tbody>
</table>
</div>
<div class="card-footer">{{ $keys->links() }}</div>
</div>
</div>
@endsection9. ModuleServiceProvider
modules/ApiKeys/ModuleServiceProvider.php
<?php
namespace Modules\ApiKeys;
use App\Events\SubscriptionActivated;
use App\Support\AbstractModuleServiceProvider;
use Modules\ApiKeys\Listeners\CreateInitialApiKey;
class ModuleServiceProvider extends AbstractModuleServiceProvider
{
protected function modulePath(): string { return __DIR__; }
protected function moduleNamespace(): string { return 'Modules\\ApiKeys'; }
public function boot(): void
{
$this->loadMigrations();
$this->loadWebRoutes();
$this->loadViews();
// Admin sidebar
$this->registerAdminMenuItem(
label: 'API Keys',
route: 'ch-admin.api-keys.index',
icon: 'ti-key',
order: 25,
);
// Customer account sidebar — only visible to active SaaS subscribers
$this->registerAccountMenuItem(
label: 'API Keys',
route: 'api-keys.account.index',
icon: 'key',
order: 25,
routes: ['api-keys.account.*'],
condition: fn () => auth()->check() && \App\Models\Subscription::where('user_id', auth()->id())
->whereIn('status', [getConstant('SUBSCRIPTION_STATUS_ACTIVE'), getConstant('SUBSCRIPTION_STATUS_TRIAL')])
->exists(),
);
// Auto-generate first key when a subscription activates
$this->app['events']->listen(SubscriptionActivated::class, CreateInitialApiKey::class);
}
}10. Seed entitlements
Add an api_keys entitlement to each SaaS plan so admins can control the limit without code changes. You can do this in your own setup seeder (triggered by ModuleActivated), or add it to the SaaS module's SaasSetupSeeder:
// Free plan: 2 keys, Pro: 10 keys, Enterprise: unlimited
$limits = ['Free' => '2', 'Pro' => '10', 'Enterprise' => null];
foreach ($plans as $plan) {
PlanEntitlement::firstOrCreate(
['plan_id' => $plan->id, 'key' => 'api_keys'],
[
'value' => $limits[$plan->name] ?? '2',
'label' => $limits[$plan->name] === null ? 'Unlimited API keys' : ($limits[$plan->name] . ' API keys'),
'is_visible' => true,
]
);
}What we demonstrated
| Concept | Where |
|---|---|
| Migration + model | api_keys table, ApiKey model |
| Admin page | ApiKeysAdminController + admin view |
| Account page | ApiKeysAccountController + account view |
saas.auth middleware | Account routes |
| Conditional account nav | condition callable in registerAccountMenuItem() |
| Event listener | CreateInitialApiKey on SubscriptionActivated |
| Plan entitlement gating | api_keys entitlement enforced in store() |
| Upgrade prompt | Link to pricing when limit reached |
This is a production-shaped starting point. Adapt the patterns to your own module's domain.