Laravel Implementation Guide - packbackbooks/lti-1-3-php-library GitHub Wiki

Laravel Implementation Guide for LTI 1.3 PHP Library

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.

Installation

  1. Install the library via Composer:
composer require packbackbooks/lti-1p3-tool
  1. 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'));
        });
    }
}

Interface Implementations

Cache Interface (Lti13Cache)

<?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}");
    }
}

Cookie Interface (Lti13Cookie)

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

Database Interface (Lti13Database)

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);
    }
}

Optional: LTI 1.1 to 1.3 Migration Support

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

Required Database Models

Issuer Model

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']);
});

Deployment Model

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']);
});

Creating an LTI Service

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();
    }
}

Controller Examples

OIDC Login Controller

<?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')
        );
    }
}

Launch Controller

<?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()
            ]);
        }
    }
}

Deep Linking Controller

<?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()
            ]);
        }
    }
}

Auto-Submit Form View

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>

JWKS Endpoint

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());
    }
}

Routes

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

Working with Available Services

Names and Roles Service (NRPS)

public function getMembers(LtiMessageLaunch $launch)
{
    if (!$launch->hasNrps()) {
        return []; // Service not available
    }
    
    $nrps = $launch->getNrps();
    return $nrps->getMembers();
}

Assignments and Grades Service (AGS)

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);
}

Course Groups Service (GS)

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();
}

Debugging LTI Services

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.

⚠️ **GitHub.com Fallback** ⚠️