Laravel Implementation Guide - packbackbooks/lti-1-3-php-library GitHub Wiki
This guide provides detailed implementation instructions for using the Packback LTI 1.3 PHP library with Laravel applications.
Note: This implementation guide was generated using an LLM. It was reviewed by a human but may contain inaccuracies. Please create an issue with specific suggestions for how to improve it.
- Install the library via Composer:
composer require packbackbooks/lti-1p3-tool
- In your
AppServiceProvider.php
, add the JWT leeway and interface bindings:
<?php
namespace App\Providers;
use Firebase\JWT\JWT;
use GuzzleHttp\Client;
use Illuminate\Support\ServiceProvider;
use Packback\Lti1p3\Interfaces\ICache;
use Packback\Lti1p3\Interfaces\ICookie;
use Packback\Lti1p3\Interfaces\IDatabase;
use Packback\Lti1p3\Interfaces\ILtiServiceConnector;
use Packback\Lti1p3\LtiServiceConnector;
class AppServiceProvider extends ServiceProvider
{
public function register()
{
// Other bindings...
}
public function boot()
{
// Set JWT leeway to 5 seconds
JWT::$leeway = 5;
// Register LTI interfaces
$this->app->bind(ICache::class, \App\Services\Lti13Cache::class);
$this->app->bind(ICookie::class, \App\Services\Lti13Cookie::class);
$this->app->bind(IDatabase::class, \App\Services\Lti13Database::class);
$this->app->bind(ILtiServiceConnector::class, function ($app) {
$client = new Client();
$cache = $app->make(ICache::class);
return (new LtiServiceConnector($cache, $client))
->setDebuggingMode(config('app.debug'));
});
}
}
<?php
namespace App\Services;
use Illuminate\Support\Facades\Cache;
use Packback\Lti1p3\Interfaces\ICache;
class Lti13Cache implements ICache
{
// Cache durations
private const NONCE_EXPIRY = 60 * 60; // 1 hour
private const LAUNCH_DATA_EXPIRY = 60 * 60 * 24; // 24 hours
private const ACCESS_TOKEN_EXPIRY = 60 * 60; // 1 hour
public function getLaunchData(string $key): ?array
{
return Cache::get("lti1p3-launch-data-{$key}");
}
public function cacheLaunchData(string $key, array $jwtBody): void
{
Cache::put("lti1p3-launch-data-{$key}", $jwtBody, self::LAUNCH_DATA_EXPIRY);
}
public function cacheNonce(string $nonce, string $state): void
{
Cache::put("lti1p3-nonce-{$nonce}", $state, self::NONCE_EXPIRY);
}
public function checkNonceIsValid(string $nonce, string $state): bool
{
$cachedState = Cache::get("lti1p3-nonce-{$nonce}");
if (!$cachedState) {
return false;
}
// Remove the nonce after validation (one-time use)
Cache::forget("lti1p3-nonce-{$nonce}");
return $cachedState === $state;
}
public function cacheAccessToken(string $key, string $accessToken): void
{
Cache::put("lti1p3-access-token-{$key}", $accessToken, self::ACCESS_TOKEN_EXPIRY);
}
public function getAccessToken(string $key): ?string
{
return Cache::get("lti1p3-access-token-{$key}");
}
public function clearAccessToken(string $key): void
{
Cache::forget("lti1p3-access-token-{$key}");
}
}
<?php
namespace App\Services;
use Illuminate\Support\Facades\Cookie;
use Packback\Lti1p3\Interfaces\ICookie;
class Lti13Cookie implements ICookie
{
public function getCookie(string $name): ?string
{
return Cookie::get($name);
}
public function setCookie(string $name, string $value, int $exp = 3600, array $options = []): void
{
Cookie::queue($name, $value, $exp / 60); // Laravel uses minutes, not seconds
}
}
The database interface needs to implement the methods required to find and validate LTI registrations and deployments:
<?php
namespace App\Services;
use App\Models\Issuer;
use App\Models\Deployment;
use Packback\Lti1p3\Interfaces\IDatabase;
use Packback\Lti1p3\Interfaces\ILtiDeployment;
use Packback\Lti1p3\Interfaces\ILtiRegistration;
use Packback\Lti1p3\LtiDeployment;
use Packback\Lti1p3\LtiRegistration;
class Lti13Database implements IDatabase
{
/**
* Find an LTI registration by issuer and optional client ID
*/
public function findRegistrationByIssuer(string $iss, ?string $clientId = null): ?ILtiRegistration
{
$query = Issuer::where('issuer', $iss);
if ($clientId) {
$query->where('client_id', $clientId);
}
$issuer = $query->first();
if (!$issuer) {
return null;
}
return LtiRegistration::new([
'issuer' => $issuer->issuer,
'clientId' => $issuer->client_id,
'keySetUrl' => $issuer->key_set_url,
'authTokenUrl' => $issuer->auth_token_url,
'authLoginUrl' => $issuer->auth_login_url,
'authServer' => $issuer->auth_server,
'toolPrivateKey' => $issuer->tool_private_key,
'kid' => $issuer->kid,
]);
}
/**
* Find a deployment by issuer, deployment ID, and optional client ID
*/
public function findDeployment(string $iss, string $deploymentId, ?string $clientId = null): ?ILtiDeployment
{
$query = Deployment::where('deployment_id', $deploymentId)
->whereHas('issuer', function ($q) use ($iss) {
$q->where('issuer', $iss);
});
if ($clientId) {
$query->whereHas('issuer', function ($q) use ($clientId) {
$q->where('client_id', $clientId);
});
}
$deployment = $query->first();
if (!$deployment) {
return null;
}
return LtiDeployment::new($deployment->deployment_id);
}
}
If you need to support migration from LTI 1.1 to 1.3, implement the IMigrationDatabase
interface:
<?php
namespace App\Services;
use App\Models\Lti1p1Key;
use Packback\Lti1p3\Interfaces\ILtiDeployment;
use Packback\Lti1p3\Interfaces\IMigrationDatabase;
use Packback\Lti1p3\Lti1p1Key as Lti1p1KeyModel;
use Packback\Lti1p3\LtiDeployment;
use Packback\Lti1p3\LtiMessageLaunch;
class Lti13Database implements IDatabase, IMigrationDatabase
{
// Include all methods from the IDatabase example above, plus these:
/**
* Return LTI 1.1 keys that match this launch
*/
public function findLti1p1Keys(LtiMessageLaunch $launch): array
{
$keys = Lti1p1Key::all();
// Convert to Lti1p1Key objects from the library
return $keys->map(function ($key) {
return new Lti1p1KeyModel($key->key, $key->secret);
})->all();
}
/**
* Determine if this launch should migrate from LTI 1.1 to 1.3
*/
public function shouldMigrate(LtiMessageLaunch $launch): bool
{
$body = $launch->getLaunchData();
// Logic to determine if migration should happen
// For example, check if LTI 1.1 data exists in the launch
return isset($body[LtiConstants::LTI1P1]);
}
/**
* Create a 1.3 deployment based on 1.1 data
*/
public function migrateFromLti1p1(LtiMessageLaunch $launch): ?ILtiDeployment
{
$body = $launch->getLaunchData();
$lti1p1Data = $body[LtiConstants::LTI1P1] ?? null;
if (!$lti1p1Data) {
return null;
}
// Create new deployment record
$deployment = Deployment::create([
'issuer_id' => $this->getOrCreateIssuer($body['iss'], $body['aud'][0] ?? $body['aud']),
'deployment_id' => $body[LtiConstants::DEPLOYMENT_ID],
]);
return LtiDeployment::new($deployment->deployment_id);
}
// Helper methods for migration support
private function getOrCreateIssuer($issuer, $clientId)
{
// Logic to find or create an issuer record
}
}
Create an Issuer
model and migration:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Issuer extends Model
{
protected $fillable = [
'issuer',
'client_id',
'key_set_url',
'auth_token_url',
'auth_login_url',
'auth_server',
'tool_private_key',
'kid',
];
public function deployments(): HasMany
{
return $this->hasMany(Deployment::class);
}
}
Migration:
Schema::create('issuers', function (Blueprint $table) {
$table->id();
$table->string('issuer')->index();
$table->string('client_id')->index();
$table->string('key_set_url');
$table->string('auth_token_url');
$table->string('auth_login_url');
$table->string('auth_server')->nullable();
$table->text('tool_private_key');
$table->string('kid')->nullable();
$table->timestamps();
$table->unique(['issuer', 'client_id']);
});
Create a Deployment
model and migration:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Deployment extends Model
{
protected $fillable = [
'issuer_id',
'deployment_id',
];
public function issuer(): BelongsTo
{
return $this->belongsTo(Issuer::class);
}
}
Migration:
Schema::create('deployments', function (Blueprint $table) {
$table->id();
$table->foreignId('issuer_id')->constrained()->onDelete('cascade');
$table->string('deployment_id');
$table->timestamps();
$table->unique(['issuer_id', 'deployment_id']);
});
It's helpful to create a service class that centralizes all LTI-related operations:
<?php
namespace App\Services;
use GuzzleHttp\Client;
use Packback\Lti1p3\DeepLinkResources\Resource;
use Packback\Lti1p3\Interfaces\ICache;
use Packback\Lti1p3\Interfaces\ICookie;
use Packback\Lti1p3\Interfaces\IDatabase;
use Packback\Lti1p3\Interfaces\ILtiServiceConnector;
use Packback\Lti1p3\JwksEndpoint;
use Packback\Lti1p3\LtiException;
use Packback\Lti1p3\LtiMessageLaunch;
use Packback\Lti1p3\LtiOidcLogin;
class Lti13Service
{
public function __construct(
private IDatabase $database,
private ICache $cache,
private ICookie $cookie,
private ILtiServiceConnector $connector
) {}
/**
* Handle OIDC login request from LMS
*/
public function login(array $request, string $launchUrl)
{
$login = new LtiOidcLogin($this->database, $this->cache, $this->cookie);
try {
$redirectUrl = $login->getRedirectUrl($launchUrl, $request);
return redirect($redirectUrl);
} catch (LtiException $e) {
// Handle error, log it, etc.
throw $e;
}
}
/**
* Handle and validate the LTI message launch
*/
public function validateLaunch(array $request)
{
$launch = LtiMessageLaunch::new(
$this->database,
$this->cache,
$this->cookie,
$this->connector
);
try {
return $launch->initialize($request);
} catch (LtiException $e) {
// Handle error, log it, etc.
throw $e;
}
}
/**
* Retrieve a previously validated launch
*/
public function getLaunchFromCache(string $launchId)
{
return LtiMessageLaunch::fromCache(
$launchId,
$this->database,
$this->cache,
$this->cookie,
$this->connector
);
}
/**
* Generate Deep Linking response
*/
public function createDeepLinkResponse(string $launchId, array $resources = [])
{
$launch = $this->getLaunchFromCache($launchId);
if (!$launch->isDeepLinkLaunch()) {
throw new LtiException('Not a deep linking launch');
}
$dl = $launch->getDeepLink();
// Create resources if none provided
if (empty($resources)) {
$resources = [
Resource::new()
->setUrl(route('lti.launch'))
->setTitle('My Resource')
->setText('Resource description')
];
}
// Get JWT for the response
$jwt = $dl->getResponseJwt($resources);
$returnUrl = $dl->returnUrl();
// Return the necessary data to create an auto-posting form
return [
'jwt' => $jwt,
'return_url' => $returnUrl
];
}
/**
* Provide a JWKS endpoint for platforms to verify our signatures
*/
public function getPublicJwks()
{
return JwksEndpoint::fromIssuer($this->database, url('/'))->getPublicJwks();
}
}
<?php
namespace App\Http\Controllers;
use App\Services\Lti13Service;
use Illuminate\Http\Request;
class LtiLoginController extends Controller
{
public function login(Request $request, Lti13Service $ltiService)
{
return $ltiService->login(
$request->all(),
route('lti.launch')
);
}
}
<?php
namespace App\Http\Controllers;
use App\Services\Lti13Service;
use Illuminate\Http\Request;
use Packback\Lti1p3\LtiException;
class LtiLaunchController extends Controller
{
public function launch(Request $request, Lti13Service $ltiService)
{
try {
$launch = $ltiService->validateLaunch($request->all());
// Store launch ID in session
session(['lti_launch_id' => $launch->getLaunchId()]);
if ($launch->isDeepLinkLaunch()) {
return redirect()->route('lti.deep_link');
}
if ($launch->isResourceLaunch()) {
return redirect()->route('lti.resource');
}
if ($launch->isSubmissionReviewLaunch()) {
return redirect()->route('lti.submission_review');
}
return redirect()->route('lti.error', [
'message' => 'Unknown launch type'
]);
} catch (LtiException $e) {
return redirect()->route('lti.error', [
'message' => $e->getMessage()
]);
}
}
}
<?php
namespace App\Http\Controllers;
use App\Services\Lti13Service;
use Illuminate\Http\Request;
class LtiDeepLinkController extends Controller
{
public function index(Request $request, Lti13Service $ltiService)
{
$launchId = session('lti_launch_id');
try {
$launch = $ltiService->getLaunchFromCache($launchId);
return view('lti.deep_link', [
'launch' => $launch,
'settings' => $launch->getDeepLink()->settings()
]);
} catch (\Exception $e) {
return redirect()->route('lti.error', [
'message' => $e->getMessage()
]);
}
}
public function response(Request $request, Lti13Service $ltiService)
{
$launchId = session('lti_launch_id');
try {
$response = $ltiService->createDeepLinkResponse($launchId);
// Return a view with an auto-posting form
return view('lti.auto_submit', [
'jwt' => $response['jwt'],
'return_url' => $response['return_url']
]);
} catch (\Exception $e) {
return redirect()->route('lti.error', [
'message' => $e->getMessage()
]);
}
}
}
Create a view for the auto-posting form:
<!-- resources/views/lti/auto_submit.blade.php -->
<!DOCTYPE html>
<html>
<head>
<title>Submitting...</title>
</head>
<body onload="document.getElementById('form').submit()">
<form id="form" action="{{ $return_url }}" method="post">
<input type="hidden" name="JWT" value="{{ $jwt }}">
<input type="submit" value="Click here if you are not automatically redirected">
</form>
</body>
</html>
Create a controller for the JWKS endpoint:
<?php
namespace App\Http\Controllers;
use App\Services\Lti13Service;
class JwksController extends Controller
{
public function keys(Lti13Service $ltiService)
{
return response()->json($ltiService->getPublicJwks());
}
}
Add these routes to your routes/web.php
file:
// LTI login, launch, and services
Route::post('/lti/login', [LtiLoginController::class, 'login'])->name('lti.login');
Route::post('/lti/launch', [LtiLaunchController::class, 'launch'])->name('lti.launch');
Route::get('/lti/deep-link', [LtiDeepLinkController::class, 'index'])->name('lti.deep_link');
Route::post('/lti/deep-link/response', [LtiDeepLinkController::class, 'response'])->name('lti.deep_link.response');
Route::get('/lti/error', [LtiErrorController::class, 'index'])->name('lti.error');
// JWKS endpoint
Route::get('/.well-known/jwks.json', [JwksController::class, 'keys'])->name('lti.keys');
public function getMembers(LtiMessageLaunch $launch)
{
if (!$launch->hasNrps()) {
return []; // Service not available
}
$nrps = $launch->getNrps();
return $nrps->getMembers();
}
public function submitGrade(LtiMessageLaunch $launch, float $score, string $userId)
{
if (!$launch->hasAgs()) {
throw new Exception('Assignments and Grades service not available');
}
$ags = $launch->getAgs();
$grade = LtiGrade::new()
->setScoreGiven($score)
->setScoreMaximum(100)
->setUserId($userId)
->setTimestamp(date('c'))
->setActivityProgress('Completed')
->setGradingProgress('FullyGraded');
return $ags->putGrade($grade);
}
public function getGrades(LtiMessageLaunch $launch, string $userId = null)
{
if (!$launch->hasAgs()) {
throw new Exception('Assignments and Grades service not available');
}
$ags = $launch->getAgs();
return $ags->getGrades(null, $userId);
}
public function getGroups(LtiMessageLaunch $launch)
{
if (!$launch->hasGs()) {
return []; // Service not available
}
$gs = $launch->getGs();
return $gs->getGroups();
}
public function getGroupsBySet(LtiMessageLaunch $launch)
{
if (!$launch->hasGs()) {
return []; // Service not available
}
$gs = $launch->getGs();
return $gs->getGroupsBySet();
}
To enable debugging of LTI service requests:
// In AppServiceProvider or a specific controller
$serviceConnector = app(ILtiServiceConnector::class);
$serviceConnector->setDebuggingMode(true);
When debugging is enabled, all requests made through the service connector will be logged using PHP's error_log()
function.