oat-sa/bundle-lti1p3
A Symfony bundle for adding LTI 1.3 support to PHP applications. It provides platform and tool integration, secure launch flows using OIDC and JWT, and configurable message handling to connect learning tools and platforms.
Decide first: bundle or library. oat-sa/bundle-lti1p3 is a Symfony bundle. The reusable LTI 1.3 / LTI Advantage logic lives in oat-sa/lti1p3-library (lti1p3-core, -nrps, -ags, -deep-linking). In a Laravel app you almost always want the library plus a small service provider of your own; the bundle's Resources/config/*.yaml is the best documentation of how the services wire together, so read it before writing bindings.
Minimum viable setup
composer require oat-sa/lti1p3-library (pin an exact version — see Gotchas).id_token), and JWKS.// AppServiceProvider::register()
$this->app->singleton(RegistrationRepositoryInterface::class, RegistrationRepository::class);
$this->app->singleton(CacheItemPoolInterface::class, fn ($app) =>
new Psr6CacheAdapter($app['cache']->store('lti')));
First use case: validate a resource-link launch and read the claims.
// app/Http/Controllers/LtiLaunchController.php
use Nyholm\Psr7\Factory\Psr17Factory;
use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory;
$f = new Psr17Factory();
$psrRequest = (new PsrHttpFactory($f, $f, $f, $f))->createRequest($request);
$launch = /* LtiMessageLaunch entry point for your installed version */
$launchFactory->fromRequest($psrRequest)
->validateRegistration()
->validate();
$data = $launch->getLaunchData(); // user, roles, context, resource link, custom
The exact entry point (LtiMessageLaunch::newInstance(...), fromRequest(...)) has moved between releases — open the class you actually installed before copying snippets.
Validate-then-store the launch. Never re-validate on every request. Persist the launch data in Laravel's session (or a cache entry keyed by an opaque launch id) and treat it as your authenticated LTI context:
session(["lti.{$launch->getIdentifier()}" => $launch->getLaunchData()]);
Nonce/state handling. The OIDC login step issues a state + nonce; the library expects to consume them from the cache pool between the login-initiation request and the launch POST. Route both endpoints through the same cache store and give entries a TTL larger than your platform's maximum redirect time (5–10 min is typical). Redis, not file.
Deep Linking (content selection). Validate the deep-linking launch, render a picker, then build a signed response and return an auto-submitting form. The deep_link_return_url comes from the JWT claims, not your config:
$response = (new LtiDeepLinkingResponse())
->setRegistration($registration)
->setDeploymentId($data->getDeploymentId())
->setContentItems([$contentItem]);
return view('lti.auto-post', [
'jwt' => $response->toJwtString(),
'url' => $claims->getDeepLinkingSettings()->getDeepLinkReturnUrl(),
]);
Content item keys are spec-named (type, title, url, custom, lineItem, available) — the available block gates the platform's "embed / new window" UI.
AGS and NRPS. Both go through a service connector that uses your PSR-18 client and the registration's platform token URL. Wrap them:
$ags = new AssignmentsGradesService($connectorFactory->create($registration));
$lineItem = $ags->findLineItem($resourceLink);
$ags->putScore($lineItem, $score, $userId);
Cache NRPS membership responses per context for a few minutes; platforms rate-limit /namesroleservice aggressively during roster syncs.
Registration persistence. Store registrations in a table and hydrate them on demand. This is exactly where 8.2.1 matters: Registration built from an array no longer trips over a missing key, so a registration row with e.g. no deployment_id, no platform.jwks_url, or a missing client_id will hydrate into a half-valid object instead of erroring.
// Guard the fields yourself before persisting / hydrating
$validated = Validator::make($payload, [
'client_id' => 'required|string',
'deployment_id' => 'required|string',
'platform.issuer' => 'required|url',
'platform.jwks_url' => 'required|url',
'tool.key_chain_id' => 'required|string',
])->validate();
Registration::fromArray($validated);
Multi-tenancy. One registration per tuple (platform issuer, client_id, deployment_id). Route resolution belongs in your repository: look up by iss + client_id from the unverified JWT claims only to pick a key for verification, then validate.
Testing. Build a launch token with the library's builders, sign it with a fake platform key registered in your test repository, and hit your route with it. That gives you a real end-to-end validation test without a live LMS.
Framework mismatch is the big one. There is no php artisan vendor:publish, no bundle autoloading, no Symfony DI tags in Laravel. Anything you copy from the bundle docs about services.yaml, @required arguments, or ContainerBuilder doesn't apply. The library classes are framework-agnostic; the wiring is on you.
PSR interfaces everywhere. ServerRequestInterface, CacheItemPoolInterface, LoggerInterface, PSR-18 ClientInterface. Budget time for the bridges: symfony/psr-http-message-bridge + nyholm/psr7 for the request (symfony/psr-http-message-bridge alone won't give you a response factory), and a PSR-6 adapter for expiresAfter() TTLs.
What 8.2.1 actually changes.
SONAR_HOST_URL replaced with a secret. Zero runtime impact; ignore.JWT validation failures are usually boring. aud must equal your tool's client_id. azp matters for some platforms. nonce must match the cached value and be single-use. exp/iat/nbf need a small leeway for clock skew. LTI 1.3 iss is a fixed constant (https://canvas.instructure.com, etc.) and sub is not a user id — do not join on it.
Clock skew and key rotation. Keep old kids in your JWKS for at least the platform's token cache lifetime. Rotating the tool private key without publishing the new key first breaks every in-flight launch with an opaque signature error.
HTTPS is mandatory — the platform validates your OIDC/launch/JWKS URLs are https://. Local dev needs a tunnel. APP_URL mismatches are the #1 cause of "invalid redirect_uri" during OIDC login initiation.
Error mapping. The library throws typed exceptions (LtiException, JwtException, RegistrationException). Catch them at the route level and return 400 with a generic message plus a log line — leaking claim details to the LMS iframe helps nobody and confuses instructors.
Extension points worth knowing: implement RegistrationRepositoryInterface for DB-backed registrations, decorate the service connector to add retry/timeout on AGS/NRPS calls, and swap the cache pool for anything PSR-6. Those three cover ~90% of real-world customisation.
Pin the version. Patch releases in the 8.x line (like 8.2.1) are safe to take; minor releases have historically shifted constructor and factory signatures. Lock it in composer.json and read the changelog before every minor bump.
How can I help you explore Laravel packages today?