# 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".
```