Skip to content

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.auth middleware
  • SubscriptionActivated event listener
  • Plan entitlement feature gating

1. Scaffold

bash
php artisan module:make ApiKeys
php artisan module:install ApiKeys

2. module.json

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
<?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
<?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
<?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
<?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
<?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
<?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

blade
@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>
@endsection

Admin page

modules/ApiKeys/resources/views/admin/index.blade.php

blade
@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>
@endsection

9. ModuleServiceProvider

modules/ApiKeys/ModuleServiceProvider.php

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:

php
// 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

ConceptWhere
Migration + modelapi_keys table, ApiKey model
Admin pageApiKeysAdminController + admin view
Account pageApiKeysAccountController + account view
saas.auth middlewareAccount routes
Conditional account navcondition callable in registerAccountMenuItem()
Event listenerCreateInitialApiKey on SubscriptionActivated
Plan entitlement gatingapi_keys entitlement enforced in store()
Upgrade promptLink to pricing when limit reached

This is a production-shaped starting point. Adapt the patterns to your own module's domain.

Released under the Commercial License.