# Public API v1 Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add a versioned public REST API (v1) with API key authentication, rate limiting, and a documentation page. **Architecture:** API key auth via `Authorization: Bearer` header or `?api_key=` query param; all endpoints under `/api/v1/` prefix with 60 req/min throttle. API key management lives in the web app under `/settings/api-keys`. JSON controllers live in `App\Http\Controllers\Api\V1\`. **Tech Stack:** Laravel 11, PHP 8.3, Blade + Tailwind + Alpine.js, no additional packages required. --- ## File Map **Create:** - `database/migrations/2026_05_31_000001_create_api_keys_table.php` - `app/Models/ApiKey.php` - `app/Http/Middleware/AuthenticateApiKey.php` - `app/Http/Controllers/ApiKeyController.php` - `app/Http/Controllers/Api/V1/GameApiController.php` - `app/Http/Controllers/Api/V1/MetaApiController.php` - `app/Http/Controllers/Api/V1/UserApiController.php` - `resources/views/settings/api-keys.blade.php` - `resources/views/api/docs.blade.php` - `routes/api.php` **Modify:** - `bootstrap/app.php` — register `api.key` middleware alias, load `routes/api.php`, add JSON 404 - `app/Providers/AppServiceProvider.php` — add rate limiter - `routes/web.php` — add API key management routes - `resources/views/layouts/app.blade.php` — add "API" footer/nav link --- ### Task 1: Database migration for `api_keys` **Files:** - Create: `database/migrations/2026_05_31_000001_create_api_keys_table.php` - [ ] **Step 1: Create migration file** ```php id(); $table->foreignId('user_id')->constrained()->cascadeOnDelete(); $table->string('name', 50); $table->string('key', 64)->unique(); $table->timestamp('last_used_at')->nullable(); $table->timestamps(); }); } public function down(): void { Schema::dropIfExists('api_keys'); } }; ``` - [ ] **Step 2: Run migration** ```bash php artisan migrate ``` Expected: `Migrating: 2026_05_31_000001_create_api_keys_table` then `Migrated`. --- ### Task 2: `ApiKey` model **Files:** - Create: `app/Models/ApiKey.php` - [ ] **Step 1: Create model** ```php belongsTo(User::class); } public static function generate(User $user, string $name): self { return self::create([ 'user_id' => $user->id, 'name' => $name, 'key' => bin2hex(random_bytes(32)), // 64 hex chars ]); } } ``` --- ### Task 3: `AuthenticateApiKey` middleware **Files:** - Create: `app/Http/Middleware/AuthenticateApiKey.php` - [ ] **Step 1: Create middleware** ```php bearerToken() ?? $request->get('api_key'); if (! $key) { return response()->json(['error' => 'Invalid API key'], 401); } $apiKey = ApiKey::where('key', $key)->with('user')->first(); if (! $apiKey) { return response()->json(['error' => 'Invalid API key'], 401); } auth()->login($apiKey->user); $apiKey->update(['last_used_at' => now()]); return $next($request); } } ``` --- ### Task 4: Register middleware alias and load API routes in `bootstrap/app.php` **Files:** - Modify: `bootstrap/app.php` - [ ] **Step 1: Update `bootstrap/app.php`** Replace the entire file with: ```php withRouting( web: __DIR__.'/../routes/web.php', api: __DIR__.'/../routes/api.php', commands: __DIR__.'/../routes/console.php', health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { $middleware->alias([ 'api.key' => \App\Http\Middleware\AuthenticateApiKey::class, ]); }) ->withExceptions(function (Exceptions $exceptions): void { $exceptions->shouldRenderJsonWhen( fn (Request $request) => $request->is('api/*'), ); })->create(); ``` Note: Laravel 11's `withRouting(api:)` automatically prefixes `/api` and applies the `api` middleware group. Since we want `/api/v1/` paths, we'll use explicit Route::prefix in `routes/api.php` WITHOUT the automatic `/api` prefix — so we define routes in `web.php` or use a custom file. Actually, Laravel 11's `api:` param in `withRouting` wraps routes in `Route::middleware('api')->prefix('api')`. That would make our paths `/api/api/v1/...`. Instead, add the API routes file directly in `web.php` via `require`. See Task 8 for the approach. **Revised Step 1: Update `bootstrap/app.php` (without `api:` param)** ```php withRouting( web: __DIR__.'/../routes/web.php', commands: __DIR__.'/../routes/console.php', health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { $middleware->alias([ 'api.key' => \App\Http\Middleware\AuthenticateApiKey::class, ]); }) ->withExceptions(function (Exceptions $exceptions): void { $exceptions->shouldRenderJsonWhen( fn (Request $request) => $request->is('api/*'), ); })->create(); ``` --- ### Task 5: Rate limiter in `AppServiceProvider` **Files:** - Modify: `app/Providers/AppServiceProvider.php` - [ ] **Step 1: Add rate limiter to `boot()`** ```php bearerToken() ?? $request->get('api_key') ?? $request->ip(); return Limit::perMinute(60)->by($key)->response(function () { return response()->json(['error' => 'Too many requests. Limit: 60/min.'], 429); }); }); } } ``` --- ### Task 6: API routes file **Files:** - Create: `routes/api.php` - Modify: `routes/web.php` (add `require` at bottom) - [ ] **Step 1: Create `routes/api.php`** ```php middleware(['api.key', 'throttle:api'])->group(function () { // Games Route::get('/games', [GameApiController::class, 'index']); Route::get('/games/search', [GameApiController::class, 'search']); Route::get('/games/top-rated', [GameApiController::class, 'topRated']); Route::get('/games/new-releases', [GameApiController::class, 'newReleases']); Route::get('/games/{game:slug}', [GameApiController::class, 'show']); // Meta Route::get('/genres', [MetaApiController::class, 'genres']); Route::get('/platforms', [MetaApiController::class, 'platforms']); // Users (public profiles) Route::get('/users/{user:slug}', [UserApiController::class, 'show']); Route::get('/users/{user:slug}/library', [UserApiController::class, 'library']); }); ``` Note: The spec uses `{user:username}` but the User model's route key is `slug` (renamed from username in migration `2026_05_30_000002`). Using `{user:slug}` is correct. - [ ] **Step 2: Include `api.php` from `web.php`** Add at the very bottom of `routes/web.php`: ```php require __DIR__.'/api.php'; ``` --- ### Task 7: `ApiKeyController` **Files:** - Create: `app/Http/Controllers/ApiKeyController.php` - [ ] **Step 1: Create controller** ```php user()->apiKeys()->latest()->get(); return view('settings.api-keys', compact('keys')); } public function store(Request $request) { $request->validate(['name' => 'required|string|max:50']); $apiKey = ApiKey::generate(auth()->user(), $request->name); return redirect()->route('settings.api-keys')->with('new_api_key', $apiKey->key); } public function destroy(ApiKey $apiKey) { Gate::authorize('delete', $apiKey); $apiKey->delete(); return redirect()->route('settings.api-keys')->with('success', 'API key deleted.'); } } ``` - [ ] **Step 2: Add `apiKeys` relation to `User` model** In `app/Models/User.php`, add: ```php public function apiKeys(): HasMany { return $this->hasMany(\App\Models\ApiKey::class); } ``` - [ ] **Step 3: Add policy for `ApiKey`** Create `app/Policies/ApiKeyPolicy.php`: ```php id === $apiKey->user_id; } } ``` - [ ] **Step 4: Register policy in `AppServiceProvider`** Add to `boot()` in `AppServiceProvider.php`: ```php Gate::policy(\App\Models\ApiKey::class, \App\Policies\ApiKeyPolicy::class); ``` --- ### Task 8: Web routes for API key management **Files:** - Modify: `routes/web.php` - [ ] **Step 1: Add API key management routes inside `auth` middleware group** Add before the `require __DIR__.'/auth.php'` line: ```php // API Key Management Route::middleware('auth')->group(function () { Route::get('/settings/api-keys', [\App\Http\Controllers\ApiKeyController::class, 'index'])->name('settings.api-keys'); Route::post('/settings/api-keys', [\App\Http\Controllers\ApiKeyController::class, 'store'])->name('settings.api-keys.store'); Route::delete('/settings/api-keys/{apiKey}', [\App\Http\Controllers\ApiKeyController::class, 'destroy'])->name('settings.api-keys.destroy'); }); ``` --- ### Task 9: `settings/api-keys.blade.php` view **Files:** - Create: `resources/views/settings/api-keys.blade.php` - [ ] **Step 1: Create view** ```blade @extends('layouts.app') @section('title', 'API Keys — LoreKeepers') @section('content')
Use API keys to access the LoreKeepers API. Keep them secret — treat them like passwords.
{{-- New key revealed --}} @if(session('new_api_key')){{ session('new_api_key') }}
Programmatic access to the LoreKeepers game database and user libraries.
All API requests require an API key. Generate one in Settings → API Keys.
Pass the key via the Authorization header:
Authorization: Bearer YOUR_API_KEY
Alternatively, append ?api_key=YOUR_API_KEY as a query parameter.
https://gamelog.ro/api/v1
Returns HTTP 429 when exceeded.
/api/v1{{ $ep[1] }}
All list endpoints return paginated responses wrapped in data + meta:
{
"data": [ ... ],
"meta": {
"current_page": 1,
"last_page": 5,
"per_page": 20,
"total": 93
}
}
curl https://gamelog.ro/api/v1/games/top-rated \ -H "Authorization: Bearer YOUR_API_KEY"
| Status | Meaning |
|---|---|
| 401 | Missing or invalid API key |
| 404 | Resource not found |
| 422 | Validation error (e.g. q too short) |
| 429 | Rate limit exceeded (60 req/min) |