# 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')

API Keys

Use API keys to access the LoreKeepers API. Keep them secret — treat them like passwords.

{{-- New key revealed --}} @if(session('new_api_key'))
This key will only be shown once. Copy it now.
{{ session('new_api_key') }}
@endif {{-- Existing keys --}}

Your API Keys

@forelse($keys as $key)
{{ $key->name }}
Created {{ $key->created_at->format('M j, Y') }} @if($key->last_used_at) · Last used {{ $key->last_used_at->diffForHumans() }} @else · Never used @endif
@csrf @method('DELETE')
@empty
No API keys yet. Create one below.
@endforelse
{{-- Create new key --}}

Create New Key

@csrf
@error('name')

{{ $message }}

@enderror
@endsection ``` --- ### Task 10: `GameApiController` **Files:** - Create: `app/Http/Controllers/Api/V1/GameApiController.php` - [ ] **Step 1: Create directory and controller** ```php $game->id, 'title' => $game->title, 'slug' => $game->slug, 'cover_url' => $game->cover_url, 'release_year' => $game->release_year, 'release_date' => $game->release_date?->toDateString(), 'rating_avg' => $game->rating_avg ? (float) $game->rating_avg : null, 'rating_count' => $game->rating_count, 'library_count' => $game->library_count, 'genres' => $game->genres->pluck('name'), 'platforms' => $game->platforms->pluck('name'), ]; } public function index(Request $request): JsonResponse { $query = Game::with(['genres', 'platforms'])->published(); if ($request->filled('genre')) { $query->whereHas('genres', fn ($q) => $q->where('genres.id', $request->genre)); } if ($request->filled('platform')) { $query->whereHas('platforms', fn ($q) => $q->where('platforms.id', $request->platform)); } if ($request->filled('year')) { $query->where('release_year', $request->year); } match ($request->get('sort', 'popular')) { 'rating' => $query->orderByDesc('rating_avg'), 'release' => $query->orderByDesc('release_date'), 'title' => $query->orderBy('title'), default => $query->orderByDesc('library_count'), }; $paginator = $query->paginate(20); return response()->json([ 'data' => $paginator->map(fn ($game) => $this->formatGame($game))->values(), 'meta' => [ 'current_page' => $paginator->currentPage(), 'last_page' => $paginator->lastPage(), 'per_page' => $paginator->perPage(), 'total' => $paginator->total(), ], ]); } public function show(Game $game): JsonResponse { $game->load(['genres', 'platforms', 'companies', 'screenshots', 'reviews.user']); return response()->json([ 'data' => array_merge($this->formatGame($game), [ 'description' => $game->summary, 'companies' => $game->companies->map(fn ($c) => [ 'name' => $c->name, 'role' => $c->pivot->role, ]), 'screenshots' => $game->screenshots->pluck('url'), 'reviews' => $game->reviews->take(5)->map(fn ($r) => [ 'user' => $r->user->slug, 'body' => $r->body, 'rating' => $r->rating, 'created_at' => $r->created_at->toIso8601String(), ]), ]), ]); } public function search(Request $request): JsonResponse { $q = trim((string) $request->get('q', '')); if (mb_strlen($q) < 2) { return response()->json(['error' => 'Query must be at least 2 characters.'], 422); } $games = Game::with(['genres', 'platforms']) ->where('title', 'like', '%' . $q . '%') ->published() ->orderBy('title') ->limit(20) ->get(); return response()->json([ 'data' => $games->map(fn ($game) => $this->formatGame($game))->values(), 'meta' => ['total' => $games->count()], ]); } public function topRated(Request $request): JsonResponse { $paginator = Game::with(['genres', 'platforms']) ->published() ->where('rating_count', '>=', 1) ->orderByDesc('rating_avg') ->orderByDesc('rating_count') ->paginate(20); return response()->json([ 'data' => $paginator->map(fn ($game) => $this->formatGame($game))->values(), 'meta' => [ 'current_page' => $paginator->currentPage(), 'last_page' => $paginator->lastPage(), 'per_page' => $paginator->perPage(), 'total' => $paginator->total(), ], ]); } public function newReleases(Request $request): JsonResponse { $paginator = Game::with(['genres', 'platforms']) ->published() ->where('release_date', '<=', now()) ->orderByDesc('release_date') ->paginate(20); return response()->json([ 'data' => $paginator->map(fn ($game) => $this->formatGame($game))->values(), 'meta' => [ 'current_page' => $paginator->currentPage(), 'last_page' => $paginator->lastPage(), 'per_page' => $paginator->perPage(), 'total' => $paginator->total(), ], ]); } } ``` --- ### Task 11: `MetaApiController` **Files:** - Create: `app/Http/Controllers/Api/V1/MetaApiController.php` - [ ] **Step 1: Create controller** ```php json([ 'data' => Genre::orderBy('name')->get(['id', 'name', 'slug']), ]); } public function platforms(): JsonResponse { return response()->json([ 'data' => Platform::orderBy('name')->get(['id', 'name', 'slug']), ]); } } ``` --- ### Task 12: `UserApiController` **Files:** - Create: `app/Http/Controllers/Api/V1/UserApiController.php` - [ ] **Step 1: Create controller** ```php library(); return response()->json([ 'data' => [ 'username' => $user->slug, 'name' => $user->name, 'avatar_url' => $user->avatar, 'joined_at' => $user->created_at->toIso8601String(), 'stats' => [ 'played' => (clone $library)->where('status', 'played')->count(), 'playing' => (clone $library)->where('status', 'playing')->count(), 'want_to_play' => (clone $library)->where('status', 'want_to_play')->count(), 'total' => $user->library()->count(), ], ], ]); } public function library(User $user, Request $request): JsonResponse { $paginator = $user->library() ->with('game') ->orderByDesc('updated_at') ->paginate(20); return response()->json([ 'data' => $paginator->map(fn ($ug) => [ 'game' => [ 'title' => $ug->game->title, 'slug' => $ug->game->slug, 'cover_url' => $ug->game->cover_url, ], 'status' => $ug->status, 'rating' => $ug->rating, 'updated_at' => $ug->updated_at->toIso8601String(), ])->values(), 'meta' => [ 'current_page' => $paginator->currentPage(), 'last_page' => $paginator->lastPage(), 'per_page' => $paginator->perPage(), 'total' => $paginator->total(), ], ]); } } ``` --- ### Task 13: API docs page **Files:** - Create: `resources/views/api/docs.blade.php` - [ ] **Step 1: Add route to `web.php` (public, no auth)** Add near the top of `routes/web.php`: ```php Route::get('/api/docs', function () { return view('api.docs'); })->name('api.docs'); ``` - [ ] **Step 2: Create the docs view** ```blade @extends('layouts.app') @section('title', 'API Documentation — LoreKeepers') @section('content')

API Documentation

Programmatic access to the LoreKeepers game database and user libraries.

{{-- Auth --}}

Authentication

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.

{{-- Base URL + Rate Limit --}}

Base URL & Rate Limiting

Base URL
https://gamelog.ro/api/v1
Rate Limit
60 requests / minute

Returns HTTP 429 when exceeded.

{{-- Endpoints --}}

Endpoints

@php $endpoints = [ ['GET', '/games', 'List games', 'Filter: genre (id), platform (id), year, sort (rating|release|title|popular). Paginated 20/page.'], ['GET', '/games/search', 'Search games', 'Required: q (min 2 chars). Returns up to 20 matches.'], ['GET', '/games/top-rated', 'Top rated games', 'Games with ≥1 rating, sorted by avg rating. Paginated 20/page.'], ['GET', '/games/new-releases', 'New releases', 'Games released ≤ today, sorted newest first. Paginated 20/page.'], ['GET', '/games/{slug}', 'Game detail', 'Full game info: companies, screenshots, latest 5 reviews.'], ['GET', '/genres', 'All genres', 'Returns id, name, slug for all genres.'], ['GET', '/platforms', 'All platforms', 'Returns id, name, slug for all platforms.'], ['GET', '/users/{slug}', 'User profile', 'Public profile: username, name, avatar, stats, joined date.'], ['GET', '/users/{slug}/library', 'User library', 'User\'s game library. Paginated 20/page. Returns game + status + rating.'], ]; @endphp
@foreach($endpoints as $ep)
{{ $ep[0] }}
/api/v1{{ $ep[1] }}
{{ $ep[2] }}
{{ $ep[3] }}
@endforeach
{{-- Response format --}}

Response Format

All list endpoints return paginated responses wrapped in data + meta:

{
  "data": [ ... ],
  "meta": {
    "current_page": 1,
    "last_page": 5,
    "per_page": 20,
    "total": 93
  }
}
{{-- Example --}}

Example Request

curl https://gamelog.ro/api/v1/games/top-rated \
  -H "Authorization: Bearer YOUR_API_KEY"
{{-- Errors --}}

Error Codes

Status Meaning
401Missing or invalid API key
404Resource not found
422Validation error (e.g. q too short)
429Rate limit exceeded (60 req/min)
@endsection ``` --- ### Task 14: Add "API" link to nav and footer **Files:** - Modify: `resources/views/layouts/app.blade.php` - [ ] **Step 1: Add to footer links** Find this block in `resources/views/layouts/app.blade.php` (around line 185): ```blade
Browse Top Rated New Releases
``` Replace with: ```blade
Browse Top Rated New Releases Developers
``` - [ ] **Step 2: Add "API Keys" link to user dropdown (after Settings)** Find in `resources/views/layouts/app.blade.php` (around line 140): ```blade Settings ``` Replace with: ```blade Settings API Keys ``` --- ### Task 15: Verify and commit - [ ] **Step 1: Run syntax check** ```bash php artisan route:list --path=api ``` Expected: list of `/api/v1/*` routes showing `api.key` and `throttle:api` middleware. - [ ] **Step 2: Run migrations** ```bash php artisan migrate:status | grep api_keys ``` Expected: `Yes` in the `Ran?` column for `2026_05_31_000001_create_api_keys_table`. - [ ] **Step 3: Commit** ```bash git add -A git commit -m "Add public API v1 with API key auth, rate limiting, and docs page" ``` - [ ] **Step 4: Push** ```bash git -c http.extraHeader="Authorization: token cc6bd9ac81340acb043c5f630586b2d5f1cb8280" push origin master ```