Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Lib Lti1P3 Core Laravel Package

oat-sa/lib-lti1p3-core

A PHP library for implementing LTI 1.3 core in applications and tools. It provides message validation, security and JWT handling, registration, service endpoints, and support for launches, deep linking, and names and roles provisioning.

View on GitHub
Deep Wiki
Context7

Getting Started

This is a protocol library, not a Laravel package — there is no service provider, no publishable config, no facade. Your first hour is spent reading src/ and deciding whether you're the tool (the app Canvas/Moodle launches into) or the platform (issuing launches). Most Laravel teams are the tool.

composer require oat-sa/lib-lti1p3-core
composer require nyholm/psr7 guzzlehttp/psr7 php-http/guzzle7-adapter

The PSR-7/17/18 dependencies are not optional: JwksFetcher, LtiServiceClient, and the message factories all take PSR interfaces. Without an adapter you can't construct them.

Look at these first, in order:

  1. Registration\Registration, Platform\Platform, Tool\Tool — the immutable config objects describing one integration.
  2. Security\Message\LtiMessageLaunch — parses and verifies an inbound launch.
  3. Security\Oidc\OidcLogin — builds the third-party-initiated redirect to the platform.
  4. Message\LtiResourceLinkRequest / LtiDeepLinkingRequest — what you actually branch on.

Your first use case is the three-endpoint tool flow:

Route::get('/lti/login',  [LtiController::class, 'login']);   // OIDC initiation
Route::post('/lti/launch', [LtiController::class, 'launch']); // JWT launch receiver
Route::get('/lti/jwks',   [LtiController::class, 'jwks']);    // public key set

Register those three URLs in Canvas's developer key, then wire a single Registration built from config('lti') values (issuer, client_id, deployment_id, platform_jwks_url, your own key). Hardcode one for the spike; move to a repository keyed by iss+client_id before your second tenant.

Implementation Patterns

Bind PSR-7 to Laravel once. Do not hand-roll conversion. Install symfony/psr-http-message-bridge and register the two factories in a service provider; every controller then does $psr = app(ServerRequestInterface::class) via a request-scoped binding, or converts explicitly with HttpFoundationFactory::createRequest($request). Keep the reverse (HttpFoundationFactory::createResponse) for the JWKS endpoint.

One controller per message type, dispatched from one launch endpoint. The launch endpoint verifies the JWT, then maps $launch->getMessageType() to an action class. This keeps the verification path identical for every message and stops you from re-implementing validation.

State and nonce live in the cache, not the session. The OIDC login step generates state + nonce; the launch arrives as a cross-origin POST that may not carry your session cookie. Store Cache::put("lti:state:{$state}", ['nonce' => $nonce, ...], 300) and delete on use. Treat a missing state as a replay, not a retry.

Tenant resolution via middleware. Read iss and client_id (login) or the unverified aud (launch), look up the Registration from your own table, and bind it into the container. Everything downstream — grading, roster, deep linking — is tenant-scoped, and getting this into one place early saves you from client_id checks scattered through controllers.

AGS and NRPS are optional services with their own scopes. When the launch JWT carries https://purl.imsglobal.org/spec/lti-ags/claim/endpoint, construct LtiServiceClient with that endpoint and call postScore() against the lineitems URL. These are outbound calls that will happen during a student's grade submission — queue them, and make the score POST idempotent on your side using a submission_id you generate.

Deep linking needs two extra endpoints. The LtiDeepLinkingRequest tells you the deep_link_return_url and accepted content types; you render a picker, then POST a signed LtiDeepLinkingResponse JWT back to that exact URL. Don't substitute your own return path.

Key management: sign with RS256 using a private key you control; publish the matching public key at /lti/jwks with a stable kid. Keep the previous key in the set for one rotation cycle. Store keys in storage/ or a secrets manager, never in the repo, and never regenerate on deploy — platforms cache your JWKS and a new kid mid-session breaks in-flight launches.

Gotchas and Tips

GPL-2.0 is the biggest gotcha here. This is a copyleft license. For a SaaS tool you're arguably fine (you're not distributing), but for an on-prem or commercially distributed Laravel app, get legal review before you build a product on it. Many teams reimplement the JWT validation (~200 lines) rather than take the license.

CSRF will silently kill every launch. LTI launches are cross-site form POSTs. Exclude /lti/launch (and the deep-linking return if you have one) from VerifyCsrfToken, or in Laravel 11 ->withMiddleware(fn ($m) => $m->validateCsrfTokens(except: ['lti/*'])). The failure mode is a 419 page in an iframe, which looks like a platform bug.

SameSite=Strict breaks the OIDC redirect chain. Your session cookie must be lax or none; Canvas loads your tool in an iframe, so if you need session continuity inside the frame you likely need SameSite=None; Secure plus a third-party-cookie fallback plan (Safari blocks them — carry everything you need in the launch JWT instead of the session).

Read the launch from $request->post(), not query(). The payload is id_token + state in a POST body. Similarly, the OIDC login params (login_hint, target_link_uri, lti_message_hint, iss) arrive as GET query params on your login endpoint and are trivially misread.

deployment_id mismatch is the single most common support ticket. Canvas lets admins deploy a key more than once, producing distinct deployment IDs against one client ID. Validate it, log the received value on mismatch, and return a clear error page — the generic exception from the library tells the integrator nothing.

JWKS fetching is an outbound HTTP call per JwksFetcher instance. Wrap it: Cache::remember("lti:jwks:{$issuer}", 3600, ...), and key the cache by kid so rotation doesn't leave you validating against a stale set. In tests, inject a fake PSR-18 client rather than hitting the platform.

The library assumes accurate clocks. exp/nbf/iat validation fails with even small skew on containers without NTP. If you see intermittent "invalid token" errors that reproduce only on one node, check time drift before you check your code.

Errors are deliberately vague for security reasons. Pass a PSR-3 logger into the validation path and log the decoded (unverified) claims plus the exception class — never the raw token at info level in production, since it's a bearer credential for the launch.

Nothing is persisted for you. Registration storage, nonce state, JWKS cache, AGS line-item mapping, and grade-passback idempotency keys are all yours. Budget for a lti_registrations, lti_launches, and lti_line_items table on day one; retrofitting tenancy after the first customer is painful.

Extension points if you need them: implement RegistrationRepositoryInterface to back registrations from Eloquent, and JwksFetcherInterface to add caching or key pinning. The message objects are readable (getResourceLink(), getClaims()) but not designed for subclassing — parse into your own DTO immediately rather than passing library objects into your domain layer.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
uni-method/symfony-lib
tacticmedia/scim-server-bundle
neika-ai/symfony-bundle
mattleads/agent-publishing-bundle
glavweb/framework-bundle
glavweb/extra-bundle
glavweb/api-bundle
glavweb/admin-bundle
adachsoft/api-version-bundle
elsayed85/laravel-api-exceptions
ouhssini/laravel-demo-mode
daworks/ncloud-outbound-mailer-driver
gp247/cms
ignibyte/aic-gates
nexa-merchant/blog
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
shitoudev/permissionmanager