# Laravel: LMS integration

A step-by-step guide to connecting a **Laravel** LMS (Laravel 10, 11 or 12, PHP 8.1+) to the AI Tutor chat.

This guide implements the contract in the [LMS integration guide](https://aichat.tutorpro.in/developers/lms). Read its Introduction first, and check it for the exact rules: manifest fields, display shapes and error codes. The code here is a working starting point. Wherever it says **Adapt**, replace the example queries with your own models.

> **Using Claude Code?** Give it `https://aichat.tutorpro.in/developers/lms/laravel.md` together with the prompt at the end of this guide.

## What you'll build

| File | Purpose |
| --- | --- |
| `config/services.php` | The chat's URL, your project API key and the signing secret |
| `app/Http/Middleware/VerifyAiChatSignature.php` | Rejects any request that isn't signed by the chat |
| `app/AiChat/AiTool.php`, `AiToolException.php`, `ToolRegistry.php` | The base class for tools, and the list of tools |
| `app/AiChat/Tools/*.php` | One class per tool: progress, exams, packages |
| `app/Http/Controllers/AiChatToolController.php` | Serves `GET /ai/manifest` and `POST /ai/tools/{name}` |
| `routes/api.php` | The routes, behind the middleware |
| `app/Services/AiChatClient.php` | Opens chat sessions and signs learners out |
| `app/Http/Controllers/AiTutorController.php` | The "AI Tutor" link learners tap |
| `app/Listeners/RevokeAiChatSessions.php` | Signs learners out of the chat when they log out |
| `tests/Feature/AiChatToolsTest.php` | Signature and tool tests |

## 1. Configuration

Add these to `.env`, using the values the AI chat admin gives you:

```bash
AI_CHAT_URL=https://aichat.tutorpro.in
AI_CHAT_API_KEY=ak_...            # Project chat key: opens chat sessions
AI_CHAT_SIGNING_SECRET=lms_...    # Verifies requests from the chat
```

Add this to the array in `config/services.php`:

```php
'ai_chat' => [
    'url' => env('AI_CHAT_URL'),
    'api_key' => env('AI_CHAT_API_KEY'),
    'signing_secret' => env('AI_CHAT_SIGNING_SECRET'),
],
```

Run `php artisan config:clear`, or `php artisan config:cache` in production.

## 2. Verify every request

The chat signs each request with HMAC-SHA256 over `"<timestamp>.<raw body>"`. This middleware rejects anything unsigned, signed with the wrong secret, or older than 5 minutes.

`app/Http/Middleware/VerifyAiChatSignature.php`:

```php
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class VerifyAiChatSignature
{
    public function handle(Request $request, Closure $next): Response
    {
        $secret = (string) config('services.ai_chat.signing_secret');
        $timestamp = (string) $request->header('X-AI-Chat-Timestamp', '');
        $signature = (string) $request->header('X-AI-Chat-Signature', '');
        // Sign the raw bytes: never re-encode the parsed JSON.
        $expected = 'v1=' . hash_hmac('sha256', $timestamp . '.' . $request->getContent(), $secret);

        if ($secret === ''
            || !ctype_digit($timestamp)
            || abs(time() - (int) $timestamp) > 300
            || !hash_equals($expected, $signature)) {
            return response()->json(['ok' => false, 'error' => ['code' => 'forbidden']], 401);
        }

        return $next($request);
    }
}
```

## 3. Tools

Each tool is a small class. Its properties form its manifest entry, and `handle()` returns the `data` for the response.

`app/AiChat/AiToolException.php`:

```php
<?php

namespace App\AiChat;

use RuntimeException;

/** An expected failure, sent to the chat as {"ok": false, "error": {"code": ...}}. */
class AiToolException extends RuntimeException
{
    public function __construct(
        public readonly string $errorCode,   // not_found, forbidden, invalid_arguments or unavailable
        string $message = '',
        public readonly int $status = 400,
    ) {
        parent::__construct($message);
    }
}
```

`app/AiChat/AiTool.php`:

```php
<?php

namespace App\AiChat;

use DateTimeInterface;

abstract class AiTool
{
    /** Lowercase snake_case; the URL is /ai/tools/{name}. */
    public string $name;
    public string $title;
    /** What the chat reads to decide when to call this tool. Say what it returns and when to use it. */
    public string $description;
    /** Arguments, as JSON Schema properties (string, integer, number or boolean). Empty: none. */
    public array $parameters = [];
    public array $required = [];
    /** true: the result depends on the learner, who is given by $userId. */
    public bool $personal = true;
    public int $cacheSeconds = 0;
    /** text, progress, exam_list, package_cards or table. */
    public string $display = 'text';

    /**
     * Return the "data" for the chat. $userId is the learner from the chat session: only ever
     * return this learner's data. Throw AiToolException for expected failures.
     */
    abstract public function handle(?string $userId, ?string $courseId, array $args): array;

    public function definition(): array
    {
        $definition = [
            'name' => $this->name,
            'title' => $this->title,
            'description' => $this->description,
            'personal' => $this->personal,
            'cache_seconds' => $this->cacheSeconds,
            'display' => $this->display,
        ];
        // Leave "parameters" out when empty: PHP would encode [] as a JSON list, not an object.
        if ($this->parameters) {
            $definition['parameters'] = ['type' => 'object', 'properties' => $this->parameters];
            if ($this->required) {
                $definition['parameters']['required'] = $this->required;
            }
        }
        return $definition;
    }

    /** ISO 8601 with offset, as the chat expects. */
    protected function iso(?DateTimeInterface $value): ?string
    {
        return $value?->format(DATE_ATOM);
    }
}
```

`app/AiChat/ToolRegistry.php`:

```php
<?php

namespace App\AiChat;

class ToolRegistry
{
    /** Every tool the chat may see. The admin still chooses which ones it uses. */
    private const TOOLS = [
        Tools\CourseProgress::class,
        Tools\UpcomingExams::class,
        Tools\Packages::class,
    ];

    /** @return AiTool[] */
    public function all(): array
    {
        return array_map(fn (string $class) => app($class), self::TOOLS);
    }

    public function find(string $name): ?AiTool
    {
        foreach ($this->all() as $tool) {
            if ($tool->name === $name) {
                return $tool;
            }
        }
        return null;
    }
}
```

### Example tools

These use example models (`Enrollment`, `Exam`, `Package`). **Adapt** the queries to your schema; keep the returned shapes.

`app/AiChat/Tools/CourseProgress.php` (display `progress`):

```php
<?php

namespace App\AiChat\Tools;

use App\AiChat\AiTool;
use App\Models\Enrollment;

class CourseProgress extends AiTool
{
    public string $name = 'get_course_progress';
    public string $title = 'Your progress';
    public string $description = "The learner's completion percentage, lessons done and next lesson for each enrolled course. Use when they ask about their progress, how much is left or what to study next.";
    public string $display = 'progress';

    public function handle(?string $userId, ?string $courseId, array $args): array
    {
        // Adapt: your enrollment model and progress fields.
        $enrollments = Enrollment::with('course')->where('user_id', $userId)->get();

        return [
            'courses' => $enrollments->map(fn (Enrollment $enrollment) => [
                'name' => $enrollment->course->title,
                'percent' => (int) round($enrollment->progress_percent),
                'completed_lessons' => $enrollment->completed_lessons,
                'total_lessons' => $enrollment->course->lessons_count,
                'next_lesson' => $enrollment->nextLesson()?->title,
                'last_activity' => $this->iso($enrollment->last_activity_at),
                'url' => route('courses.show', $enrollment->course),
            ])->values()->all(),
        ];
    }
}
```

`app/AiChat/Tools/UpcomingExams.php` (display `exam_list`, with an argument):

```php
<?php

namespace App\AiChat\Tools;

use App\AiChat\AiTool;
use App\Models\Exam;

class UpcomingExams extends AiTool
{
    public string $name = 'list_upcoming_exams';
    public string $title = 'Your exams';
    public string $description = 'Upcoming and live exams the learner can take, soonest first, with date, time and duration. Use when they ask about exams, tests, exam dates or their schedule.';
    public array $parameters = [
        'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 10, 'description' => 'How many exams to return'],
    ];
    public int $cacheSeconds = 300;
    public string $display = 'exam_list';

    public function handle(?string $userId, ?string $courseId, array $args): array
    {
        $limit = min(10, max(1, (int) ($args['limit'] ?? 5)));

        // Adapt: exams of courses this learner is enrolled in that haven't ended.
        $exams = Exam::with('course')
            ->whereHas('course.enrollments', fn ($query) => $query->where('user_id', $userId))
            ->where('ends_at', '>', now())
            ->orderBy('starts_at')
            ->limit($limit)
            ->get();

        return [
            'exams' => $exams->map(fn (Exam $exam) => [
                'title' => $exam->title,
                'course' => $exam->course->title,
                'starts_at' => $this->iso($exam->starts_at),
                'duration_minutes' => $exam->duration_minutes,
                'status' => $exam->starts_at->isPast() ? 'live' : 'upcoming',
                'url' => route('exams.show', $exam),
            ])->values()->all(),
        ];
    }
}
```

`app/AiChat/Tools/Packages.php` (display `package_cards`):

```php
<?php

namespace App\AiChat\Tools;

use App\AiChat\AiTool;
use App\Models\Package;

class Packages extends AiTool
{
    public string $name = 'get_packages';
    public string $title = 'Plans for you';
    public string $description = 'Packages the learner can buy, with price, validity, features and checkout link. Use when they ask about prices, plans, upgrading or unlocking content.';
    public int $cacheSeconds = 300;
    public string $display = 'package_cards';

    public function handle(?string $userId, ?string $courseId, array $args): array
    {
        // Adapt: active packages this learner hasn't bought, priced for them.
        $packages = Package::where('is_active', true)
            ->whereDoesntHave('purchases', fn ($query) => $query->where('user_id', $userId))
            ->orderBy('sort_order')
            ->get();

        return [
            'packages' => $packages->map(fn (Package $package) => [
                'name' => $package->name,
                'price' => (float) $package->price,
                'currency' => 'INR',
                'original_price' => $package->mrp ? (float) $package->mrp : null,
                'validity' => $package->validity_months . ' months',
                'features' => $package->features ?? [],   // An array cast on the model
                'highlight' => (bool) $package->is_featured,
                'checkout_url' => route('checkout.show', $package),
            ])->values()->all(),
        ];
    }
}
```

Links must be absolute `https://` URLs. Make sure `APP_URL` is `https://…` so `route()` builds them that way.

## 4. Controller and routes

`app/Http/Controllers/AiChatToolController.php`:

```php
<?php

namespace App\Http\Controllers;

use App\AiChat\AiTool;
use App\AiChat\AiToolException;
use App\AiChat\ToolRegistry;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Throwable;

class AiChatToolController extends Controller
{
    public function manifest(ToolRegistry $tools): JsonResponse
    {
        return response()->json([
            'version' => 1,
            'tools' => array_map(fn (AiTool $tool) => $tool->definition(), $tools->all()),
        ]);
    }

    public function tool(Request $request, ToolRegistry $tools, string $name): JsonResponse
    {
        $tool = $tools->find($name);
        if (!$tool) {
            return $this->error('not_found', 'Unknown tool', 404);
        }

        $input = json_decode($request->getContent(), true);
        if (!is_array($input)) {
            return $this->error('invalid_arguments', 'The body must be a JSON object', 422);
        }
        $userId = isset($input['user_id']) ? (string) $input['user_id'] : null;
        $courseId = isset($input['course_id']) ? (string) $input['course_id'] : null;
        $args = is_array($input['args'] ?? null) ? $input['args'] : [];

        if ($tool->personal && $userId === null) {
            return $this->error('forbidden', 'This tool needs a learner', 403);
        }

        try {
            return response()->json(['ok' => true, 'data' => $tool->handle($userId, $courseId, $args)]);
        } catch (AiToolException $exception) {
            return $this->error($exception->errorCode, $exception->getMessage(), $exception->status);
        } catch (Throwable $exception) {
            report($exception);   // Logged for you; the learner is told to try again later.
            return $this->error('unavailable', 'Temporarily unavailable', 503);
        }
    }

    private function error(string $code, string $message, int $status): JsonResponse
    {
        return response()->json(['ok' => false, 'error' => ['code' => $code, 'message' => $message]], $status);
    }
}
```

`routes/api.php` (on Laravel 11 or 12 without this file, run `php artisan install:api` first):

```php
use App\Http\Controllers\AiChatToolController;
use App\Http\Middleware\VerifyAiChatSignature;
use Illuminate\Support\Facades\Route;

Route::prefix('ai')
    ->middleware([VerifyAiChatSignature::class, 'throttle:120,1'])
    ->group(function () {
        Route::get('manifest', [AiChatToolController::class, 'manifest']);
        Route::post('tools/{name}', [AiChatToolController::class, 'tool'])
            ->where('name', '[a-z][a-z0-9_]*');
    });
```

Routes in `routes/api.php` are served under `/api`. Your **base URL** for the admin is therefore `https://your-lms.example.com/api`.

## 5. Opening the chat

`app/Services/AiChatClient.php`:

```php
<?php

namespace App\Services;

use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;

class AiChatClient
{
    /** Opens a chat session for a learner and returns the URL to send them to. */
    public function chatUrl(string $userId, ?string $courseId = null, array $claims = []): string
    {
        $body = ['user_id' => $userId, 'ttl_seconds' => 43200];
        if ($courseId !== null) {
            $body['course_id'] = $courseId;
        }
        if ($claims) {
            $body['claims'] = $claims;   // Leave out when empty: [] is not a JSON object.
        }

        return $this->http()->post('chat/sessions', $body)->throw()->json('chat_url');
    }

    /** Signs a learner out of every chat session. */
    public function revoke(string $userId): void
    {
        $this->http()->delete('chat/sessions?' . http_build_query(['user_id' => $userId]))->throw();
    }

    private function http(): PendingRequest
    {
        return Http::baseUrl(rtrim((string) config('services.ai_chat.url'), '/'))
            ->withHeaders(['X-API-Key' => config('services.ai_chat.api_key')])
            ->acceptJson()
            ->timeout(10);
    }
}
```

`app/Http/Controllers/AiTutorController.php`:

```php
<?php

namespace App\Http\Controllers;

use App\Services\AiChatClient;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;

class AiTutorController extends Controller
{
    public function open(Request $request, AiChatClient $chat, ?string $course = null): RedirectResponse
    {
        $user = $request->user();

        $url = $chat->chatUrl((string) $user->getAuthIdentifier(), $course, [
            // Adapt: how your LMS knows a learner has paid.
            'plan' => $user->hasActivePurchase() ? 'paid' : 'free',
        ]);

        return redirect()->away($url);
    }
}
```

In `routes/web.php`:

```php
use App\Http\Controllers\AiTutorController;

Route::get('ai-tutor/{course?}', [AiTutorController::class, 'open'])
    ->middleware('auth')
    ->name('ai-tutor');
```

Link to it from lessons, for example `route('ai-tutor', $course->ai_chat_course_id)`. The course must exist in the AI chat project under that ID; ask the admin to create courses with your IDs.

**Mobile apps:** add an authenticated API route (for example with Sanctum) that returns `['chat_url' => $chat->chatUrl(...)]` as JSON. The app then opens that URL in a webview. The chat page can't be embedded in an iframe.

## 6. Sign learners out with the LMS

`app/Listeners/RevokeAiChatSessions.php`:

```php
<?php

namespace App\Listeners;

use App\Services\AiChatClient;
use Illuminate\Auth\Events\Logout;
use Throwable;

class RevokeAiChatSessions
{
    public function __construct(private AiChatClient $chat)
    {
    }

    public function handle(Logout $event): void
    {
        if (!$event->user) {
            return;
        }
        try {
            $this->chat->revoke((string) $event->user->getAuthIdentifier());
        } catch (Throwable $exception) {
            report($exception);   // Never block logging out.
        }
    }
}
```

Laravel 11 and 12 discover this listener automatically. On Laravel 10, register it in `EventServiceProvider::$listen` under `Illuminate\Auth\Events\Logout::class`.

## 7. Tests

`tests/Feature/AiChatToolsTest.php`:

```php
<?php

namespace Tests\Feature;

use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Testing\TestResponse;
use Tests\TestCase;

class AiChatToolsTest extends TestCase
{
    use RefreshDatabase;

    protected function setUp(): void
    {
        parent::setUp();
        config(['services.ai_chat.signing_secret' => 'test-secret']);
    }

    /** Sends a request signed exactly as the chat signs it. */
    private function signed(string $method, string $uri, ?array $payload = null, ?int $timestamp = null): TestResponse
    {
        $body = $payload === null ? '' : json_encode($payload);
        $timestamp = (string) ($timestamp ?? time());
        $signature = 'v1=' . hash_hmac('sha256', $timestamp . '.' . $body, 'test-secret');

        return $this->call($method, $uri, [], [], [], [
            'HTTP_X_AI_CHAT_TIMESTAMP' => $timestamp,
            'HTTP_X_AI_CHAT_SIGNATURE' => $signature,
            'CONTENT_TYPE' => 'application/json',
            'HTTP_ACCEPT' => 'application/json',
        ], $body);
    }

    public function test_manifest_lists_the_tools(): void
    {
        $this->signed('GET', '/api/ai/manifest')
            ->assertOk()
            ->assertJsonPath('version', 1)
            ->assertJsonFragment(['name' => 'get_course_progress']);
    }

    public function test_unsigned_and_stale_requests_are_rejected(): void
    {
        $this->getJson('/api/ai/manifest')->assertStatus(401);
        $this->signed('GET', '/api/ai/manifest', null, time() - 600)->assertStatus(401);
    }

    public function test_progress_is_returned_for_the_requested_learner(): void
    {
        $user = User::factory()->create();

        $this->signed('POST', '/api/ai/tools/get_course_progress', [
            'user_id' => (string) $user->id,
            'course_id' => null,
            'args' => [],
        ])->assertOk()->assertJsonPath('ok', true)->assertJsonStructure(['data' => ['courses']]);
    }

    public function test_unknown_tools_return_not_found(): void
    {
        $this->signed('POST', '/api/ai/tools/nope', ['user_id' => '1', 'course_id' => null, 'args' => []])
            ->assertStatus(404)
            ->assertJsonPath('error.code', 'not_found');
    }
}
```

Add a test per tool proving that one learner never sees another learner's records.

## 8. Go live

1. Deploy, and set the three `.env` values on the server.
2. Give the AI chat admin your base URL: `https://your-lms.example.com/api`.
3. The admin presses *Sync tools*, enables the tools, and uses *Test* with a real learner ID.
4. Open the chat through `/ai-tutor` as a test learner, and ask "How am I doing?" and "When is my next exam?".

If something fails, the admin panel's call log shows the error code. The [Troubleshooting](https://aichat.tutorpro.in/developers/lms#9-troubleshooting) table explains each one.

## Prompt for Claude Code

```text
Read https://aichat.tutorpro.in/developers/lms/laravel.md (and https://aichat.tutorpro.in/developers/lms.md for the full
contract) and implement the AI Tutor chat integration in this Laravel app:
1. config/services.php "ai_chat" settings from AI_CHAT_URL, AI_CHAT_API_KEY, AI_CHAT_SIGNING_SECRET.
2. VerifyAiChatSignature middleware, exactly as in the guide.
3. AiTool, AiToolException and ToolRegistry, and tools get_course_progress, list_upcoming_exams,
   get_exam_results (display "table") and get_packages, using OUR models and relationships.
   Inspect the codebase to find them; only ever return data for the given user_id.
4. AiChatToolController and the routes in routes/api.php.
5. AiChatClient, an "AI Tutor" route that opens the chat with claims {"plan": "free"|"paid"},
   and the Logout listener that revokes sessions.
6. Feature tests from the guide, plus one per tool proving learners can't see each other's data.
Keep every tool read-only. Ask me before guessing business rules, such as who counts as "paid".
```
