Files
gamelog/docs/superpowers/plans/2026-05-31-public-api-v1.md

34 KiB

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

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

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;

class ApiKey extends Model
{
    protected $fillable = ['user_id', 'name', 'key'];

    public function user(): BelongsTo
    {
        return $this->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

namespace App\Http\Middleware;

use App\Models\ApiKey;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class AuthenticateApiKey
{
    public function handle(Request $request, Closure $next): Response
    {
        $key = $request->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

use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;

return Application::configure(basePath: dirname(__DIR__))
    ->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

use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;

return Application::configure(basePath: dirname(__DIR__))
    ->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

namespace App\Providers;

use App\Models\UserGame;
use App\Policies\UserGamePolicy;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        //
    }

    public function boot(): void
    {
        Gate::policy(UserGame::class, UserGamePolicy::class);
        Gate::policy(\App\Models\Review::class, \App\Policies\ReviewPolicy::class);
        Gate::policy(\App\Models\UserList::class, \App\Policies\ListPolicy::class);
        Gate::policy(\App\Models\GameComment::class, \App\Policies\GameCommentPolicy::class);

        RateLimiter::for('api', function (Request $request) {
            $key = $request->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

use App\Http\Controllers\Api\V1\GameApiController;
use App\Http\Controllers\Api\V1\MetaApiController;
use App\Http\Controllers\Api\V1\UserApiController;
use Illuminate\Support\Facades\Route;

Route::prefix('api/v1')->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:

require __DIR__.'/api.php';

Task 7: ApiKeyController

Files:

  • Create: app/Http/Controllers/ApiKeyController.php

  • Step 1: Create controller

<?php

namespace App\Http\Controllers;

use App\Models\ApiKey;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;

class ApiKeyController extends Controller
{
    public function index()
    {
        $keys = auth()->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:

public function apiKeys(): HasMany { return $this->hasMany(\App\Models\ApiKey::class); }
  • Step 3: Add policy for ApiKey

Create app/Policies/ApiKeyPolicy.php:

<?php

namespace App\Policies;

use App\Models\ApiKey;
use App\Models\User;

class ApiKeyPolicy
{
    public function delete(User $user, ApiKey $apiKey): bool
    {
        return $user->id === $apiKey->user_id;
    }
}
  • Step 4: Register policy in AppServiceProvider

Add to boot() in AppServiceProvider.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:

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

@extends('layouts.app')

@section('title', 'API Keys — LoreKeepers')

@section('content')
<div class="max-w-3xl mx-auto px-4 py-10">

    <h1 class="text-2xl font-bold text-white mb-1">API Keys</h1>
    <p class="text-slate-400 text-sm mb-8">Use API keys to access the <a href="{{ route('api.docs') }}" class="text-purple-400 hover:text-purple-300 underline">LoreKeepers API</a>. Keep them secret — treat them like passwords.</p>

    {{-- New key revealed --}}
    @if(session('new_api_key'))
    <div class="mb-8 p-4 bg-purple-900/30 border border-purple-500/40 rounded-xl" x-data="{ copied: false }">
        <div class="flex items-center gap-2 mb-2">
            <svg class="w-4 h-4 text-yellow-400 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
                <path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"/>
            </svg>
            <span class="text-yellow-300 text-sm font-semibold">This key will only be shown once. Copy it now.</span>
        </div>
        <div class="flex items-center gap-2 mt-3">
            <code class="flex-1 bg-[#0f0f13] border border-white/10 rounded-lg px-4 py-2.5 text-sm text-green-300 font-mono break-all select-all">{{ session('new_api_key') }}</code>
            <button @click="navigator.clipboard.writeText('{{ session('new_api_key') }}'); copied = true; setTimeout(() => copied = false, 2000)"
                    class="flex-shrink-0 px-3 py-2.5 bg-purple-600 hover:bg-purple-700 text-white text-sm rounded-lg transition-colors font-medium">
                <span x-show="!copied">Copy</span>
                <span x-show="copied">Copied!</span>
            </button>
        </div>
    </div>
    @endif

    {{-- Existing keys --}}
    <div class="bg-[#16161d] border border-white/5 rounded-xl mb-8 overflow-hidden">
        <div class="px-5 py-4 border-b border-white/5">
            <h2 class="text-sm font-semibold text-slate-200">Your API Keys</h2>
        </div>
        @forelse($keys as $key)
        <div class="px-5 py-4 flex items-center justify-between gap-4 @if(!$loop->last) border-b border-white/5 @endif">
            <div>
                <div class="text-sm font-medium text-white">{{ $key->name }}</div>
                <div class="text-xs text-slate-500 mt-0.5">
                    Created {{ $key->created_at->format('M j, Y') }}
                    @if($key->last_used_at)
                     · Last used {{ $key->last_used_at->diffForHumans() }}
                    @else
                     · Never used
                    @endif
                </div>
            </div>
            <form method="POST" action="{{ route('settings.api-keys.destroy', $key) }}" onsubmit="return confirm('Delete this API key? This cannot be undone.')">
                @csrf
                @method('DELETE')
                <button type="submit" class="text-xs px-3 py-1.5 bg-red-900/40 hover:bg-red-800/60 border border-red-700/40 text-red-400 hover:text-red-300 rounded-lg transition-colors">
                    Revoke
                </button>
            </form>
        </div>
        @empty
        <div class="px-5 py-8 text-center text-slate-500 text-sm">
            No API keys yet. Create one below.
        </div>
        @endforelse
    </div>

    {{-- Create new key --}}
    <div class="bg-[#16161d] border border-white/5 rounded-xl overflow-hidden">
        <div class="px-5 py-4 border-b border-white/5">
            <h2 class="text-sm font-semibold text-slate-200">Create New Key</h2>
        </div>
        <form method="POST" action="{{ route('settings.api-keys.store') }}" class="px-5 py-4">
            @csrf
            <div class="flex items-end gap-3">
                <div class="flex-1">
                    <label for="name" class="block text-xs text-slate-400 mb-1.5">Key Label</label>
                    <input type="text" id="name" name="name" value="{{ old('name') }}"
                           placeholder="e.g. My App, Personal Script"
                           class="w-full bg-[#0f0f13] border border-white/10 rounded-lg px-3 py-2 text-sm text-slate-200 placeholder-slate-500 focus:outline-none focus:border-purple-500 focus:ring-1 focus:ring-purple-500 transition"
                           maxlength="50">
                    @error('name')
                    <p class="text-red-400 text-xs mt-1">{{ $message }}</p>
                    @enderror
                </div>
                <button type="submit" class="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white text-sm rounded-lg transition-colors font-medium whitespace-nowrap">
                    Generate Key
                </button>
            </div>
        </form>
    </div>

</div>
@endsection

Task 10: GameApiController

Files:

  • Create: app/Http/Controllers/Api/V1/GameApiController.php

  • Step 1: Create directory and controller

<?php

namespace App\Http\Controllers\Api\V1;

use App\Http\Controllers\Controller;
use App\Models\Game;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class GameApiController extends Controller
{
    private function formatGame(Game $game): array
    {
        return [
            'id'            => $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

namespace App\Http\Controllers\Api\V1;

use App\Http\Controllers\Controller;
use App\Models\Genre;
use App\Models\Platform;
use Illuminate\Http\JsonResponse;

class MetaApiController extends Controller
{
    public function genres(): JsonResponse
    {
        return response()->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

namespace App\Http\Controllers\Api\V1;

use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class UserApiController extends Controller
{
    public function show(User $user): JsonResponse
    {
        $library = $user->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:

Route::get('/api/docs', function () {
    return view('api.docs');
})->name('api.docs');
  • Step 2: Create the docs view
@extends('layouts.app')

@section('title', 'API Documentation — LoreKeepers')

@section('content')
<div class="max-w-4xl mx-auto px-4 py-10">

    <div class="mb-10">
        <h1 class="text-3xl font-bold text-white mb-3">API Documentation</h1>
        <p class="text-slate-400">Programmatic access to the LoreKeepers game database and user libraries.</p>
    </div>

    {{-- Auth --}}
    <section class="mb-10">
        <h2 class="text-xl font-semibold text-white mb-4 pb-2 border-b border-white/10">Authentication</h2>
        <p class="text-slate-400 text-sm mb-3">All API requests require an API key. Generate one in <a href="{{ route('settings.api-keys') }}" class="text-purple-400 hover:text-purple-300 underline">Settings → API Keys</a>.</p>
        <p class="text-slate-400 text-sm mb-3">Pass the key via the <code class="text-purple-300 bg-white/5 px-1.5 py-0.5 rounded text-xs">Authorization</code> header:</p>
        <pre class="bg-[#16161d] border border-white/10 rounded-xl p-4 text-sm text-green-300 overflow-x-auto font-mono">Authorization: Bearer YOUR_API_KEY</pre>
        <p class="text-slate-500 text-sm mt-3">Alternatively, append <code class="text-purple-300 bg-white/5 px-1.5 py-0.5 rounded text-xs">?api_key=YOUR_API_KEY</code> as a query parameter.</p>
    </section>

    {{-- Base URL + Rate Limit --}}
    <section class="mb-10">
        <h2 class="text-xl font-semibold text-white mb-4 pb-2 border-b border-white/10">Base URL &amp; Rate Limiting</h2>
        <div class="grid md:grid-cols-2 gap-4">
            <div class="bg-[#16161d] border border-white/5 rounded-xl p-4">
                <div class="text-xs text-slate-500 mb-1 uppercase tracking-wide">Base URL</div>
                <code class="text-purple-300 font-mono text-sm">https://gamelog.ro/api/v1</code>
            </div>
            <div class="bg-[#16161d] border border-white/5 rounded-xl p-4">
                <div class="text-xs text-slate-500 mb-1 uppercase tracking-wide">Rate Limit</div>
                <span class="text-white text-sm font-medium">60 requests / minute</span>
                <p class="text-slate-500 text-xs mt-1">Returns HTTP 429 when exceeded.</p>
            </div>
        </div>
    </section>

    {{-- Endpoints --}}
    <section class="mb-10">
        <h2 class="text-xl font-semibold text-white mb-4 pb-2 border-b border-white/10">Endpoints</h2>

        @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

        <div class="space-y-3">
            @foreach($endpoints as $ep)
            <div class="bg-[#16161d] border border-white/5 rounded-xl p-4 flex gap-4 items-start">
                <span class="flex-shrink-0 text-xs font-mono font-bold px-2 py-1 rounded bg-purple-900/40 text-purple-300 border border-purple-700/30 mt-0.5">{{ $ep[0] }}</span>
                <div class="min-w-0">
                    <code class="text-green-300 font-mono text-sm">/api/v1{{ $ep[1] }}</code>
                    <div class="text-white text-sm font-medium mt-0.5">{{ $ep[2] }}</div>
                    <div class="text-slate-500 text-xs mt-1">{{ $ep[3] }}</div>
                </div>
            </div>
            @endforeach
        </div>
    </section>

    {{-- Response format --}}
    <section class="mb-10">
        <h2 class="text-xl font-semibold text-white mb-4 pb-2 border-b border-white/10">Response Format</h2>
        <p class="text-slate-400 text-sm mb-4">All list endpoints return paginated responses wrapped in <code class="text-purple-300 bg-white/5 px-1.5 py-0.5 rounded text-xs">data</code> + <code class="text-purple-300 bg-white/5 px-1.5 py-0.5 rounded text-xs">meta</code>:</p>
        <pre class="bg-[#16161d] border border-white/10 rounded-xl p-4 text-sm text-green-300 overflow-x-auto font-mono">{
  "data": [ ... ],
  "meta": {
    "current_page": 1,
    "last_page": 5,
    "per_page": 20,
    "total": 93
  }
}</pre>
    </section>

    {{-- Example --}}
    <section class="mb-10">
        <h2 class="text-xl font-semibold text-white mb-4 pb-2 border-b border-white/10">Example Request</h2>
        <pre class="bg-[#16161d] border border-white/10 rounded-xl p-4 text-sm text-green-300 overflow-x-auto font-mono">curl https://gamelog.ro/api/v1/games/top-rated \
  -H "Authorization: Bearer YOUR_API_KEY"</pre>
    </section>

    {{-- Errors --}}
    <section>
        <h2 class="text-xl font-semibold text-white mb-4 pb-2 border-b border-white/10">Error Codes</h2>
        <div class="overflow-x-auto">
            <table class="w-full text-sm">
                <thead>
                    <tr class="text-left text-slate-500 text-xs uppercase tracking-wide border-b border-white/10">
                        <th class="pb-2 pr-6">Status</th>
                        <th class="pb-2">Meaning</th>
                    </tr>
                </thead>
                <tbody class="text-slate-300">
                    <tr class="border-b border-white/5"><td class="py-2.5 pr-6 font-mono text-yellow-400">401</td><td>Missing or invalid API key</td></tr>
                    <tr class="border-b border-white/5"><td class="py-2.5 pr-6 font-mono text-yellow-400">404</td><td>Resource not found</td></tr>
                    <tr class="border-b border-white/5"><td class="py-2.5 pr-6 font-mono text-yellow-400">422</td><td>Validation error (e.g. q too short)</td></tr>
                    <tr><td class="py-2.5 pr-6 font-mono text-yellow-400">429</td><td>Rate limit exceeded (60 req/min)</td></tr>
                </tbody>
            </table>
        </div>
    </section>

</div>
@endsection

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):

                <div class="flex items-center gap-6 text-sm text-slate-500">
                    <a href="{{ route('games.index') }}" class="hover:text-slate-300 transition-colors">Browse</a>
                    <a href="{{ route('games.top-rated') }}" class="hover:text-slate-300 transition-colors">Top Rated</a>
                    <a href="{{ route('games.new-releases') }}" class="hover:text-slate-300 transition-colors">New Releases</a>
                </div>

Replace with:

                <div class="flex items-center gap-6 text-sm text-slate-500">
                    <a href="{{ route('games.index') }}" class="hover:text-slate-300 transition-colors">Browse</a>
                    <a href="{{ route('games.top-rated') }}" class="hover:text-slate-300 transition-colors">Top Rated</a>
                    <a href="{{ route('games.new-releases') }}" class="hover:text-slate-300 transition-colors">New Releases</a>
                    <a href="{{ route('api.docs') }}" class="hover:text-slate-300 transition-colors">Developers</a>
                </div>
  • Step 2: Add "API Keys" link to user dropdown (after Settings)

Find in resources/views/layouts/app.blade.php (around line 140):

                                <a href="{{ route('profile.edit') }}" class="block px-4 py-2.5 text-sm text-slate-300 hover:bg-white/5 hover:text-white transition-colors">Settings</a>

Replace with:

                                <a href="{{ route('profile.edit') }}" class="block px-4 py-2.5 text-sm text-slate-300 hover:bg-white/5 hover:text-white transition-colors">Settings</a>
                                <a href="{{ route('settings.api-keys') }}" class="block px-4 py-2.5 text-sm text-slate-300 hover:bg-white/5 hover:text-white transition-colors">API Keys</a>

Task 15: Verify and commit

  • Step 1: Run syntax check
php artisan route:list --path=api

Expected: list of /api/v1/* routes showing api.key and throttle:api middleware.

  • Step 2: Run migrations
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
git add -A
git commit -m "Add public API v1 with API key auth, rate limiting, and docs page"
  • Step 4: Push
git -c http.extraHeader="Authorization: token cc6bd9ac81340acb043c5f630586b2d5f1cb8280" push origin master