Learning
Namespace Hilms\Learning. Owns who may open a course, the seat that tracks their way through it, the player, the graded quiz, the block a student reads their own courses from, and the one route that hands out course material. It builds on catalog and blocks.
Entitlements versus enrolments
Section titled “Entitlements versus enrolments”Two tables, two jobs, and confusing them is the one mistake that matters here.
entitlements |
enrollments |
|
|---|---|---|
| Answers | May this person open this course? | Where is this person in this course? |
| Written by | GrantAccess and RevokeAccess only |
EnrolUser (creation) and SyncEnrollmentAccess (status, expiry) only |
| Lifetime | Many per student and course over time; never deleted | Exactly one per student and course (unique) |
| Holds | Source, grantor, external reference, note, expiry, revocation | Status, resume point, enrolment date, expiry, completion |
An entitlement is the audit trail behind a purchase: who was granted what, by whom, why, until when, and whether it was taken back. It covers one course, and a course is written in one language, so access to a course in two languages is two entitlements — which is what the API’s include_translations grants in a single call (see API and agents). The enrolment is a derived seat, kept in step so that the player, the student’s own listing and the access check each read one row rather than folding a history on every request.
Four sources (EntitlementSource): self (a student joining a free course), panel, api (an external store) and command. A grantor is a panel user or an API client — pinned by a database CHECK constraint that allows only the morph aliases user and api_client and requires both grantor columns or neither — or nobody at all for the console.
The actions that alone may write
Section titled “The actions that alone may write”Nothing outside these writes entitlements, enrollments or lesson_completions. Not a controller, not a Filament action, not a seeder except the load dataset, which says so in its own docblock.
GrantAccess::handle(User $user, Course $course, Grant $grant): EntitlementRevokeAccess::handle(Entitlement $entitlement, ?string $reason = null): EntitlementRevokeCourseAccess::handle(User $user, Course $course, ?string $reason = null): intSyncEnrollmentAccess::handle(Enrollment $enrollment): EnrollmentEnrolUser::handle(User $user, Course $course): EnrollmentResolveLessonAccess::handle(Course $course, Lesson $lesson, ?User $user, ?Enrollment $enrollment): LessonAccessCompleteLesson::handle(Enrollment $enrollment, Lesson $lesson, ?Lesson $next = null): voidGrantAccess is the only way an entitlement comes into being. The Grant value object carries the source, an optional expiry, an optional external reference, an optional grantor and an optional note; with a grantor and a reference the grant is idempotent, so the same order line replayed by a store neither grants nor mails twice. Grant normalises the expiry to UTC as it is constructed, because Eloquent writes a datetime’s wall clock rather than the instant it names — so every caller of the one door an entitlement comes through is safe without having to remember (Time and clocks). In one transaction it records the entitlement, ensures the seat through EnrolUser and syncs it. Afterwards — and only when the entitlement is new and the student had no access yet — it sends EnrolledInCourse, with an account setup link when the email is unverified, so a new buyer receives a single mail.
RevokeAccess is the only way one ends early. It is idempotent and keeps the first date and reason. RevokeCourseAccess revokes every live entitlement of a student and course at once, which is what the panel’s row action and hilms:user:revoke do.
SyncEnrollmentAccess is the only writer of enrollments.status and enrollments.expires_at. With a live entitlement the seat is active — or completed, if it already was — and expires at the furthest date, unless one entitlement is unlimited, in which case the seat is. Without one it is expired when a non-revoked entitlement merely ran out, and cancelled when all of them were revoked. It writes only when something changed.
EnrolUser creates or reuses the seat and nothing else: enrolled_at, the course’s first lesson as the resume point, no notification. GrantAccess is its only caller.
ResolveLessonAccess is the one place that decides whether a lesson opens. It is pure rules and runs no queries:
| Answer | When |
|---|---|
Open |
The visitor may preview the course; or, in a published course, the lesson is a free preview or the seat grants access |
Expired |
There is a seat, and it is past its expiry or already marked expired |
Locked |
Anything else — and every lesson of a course that is not published (a draft, an archived course, one scheduled for later), whatever a seat or a free preview says |
The last row matters to the file route rather than to the player: the player 404s an unpublished course before it asks, but the signed media route asks the resolver alone, so until the security review a free-preview lesson’s files stayed open to a guest, and a seat’s to a former student, for as long as a link signed while the course was public lived (Security).
CompleteLesson writes a lesson_completions row, moves last_lesson_id to the next lesson, and calls CompleteCourse when every lesson is done; CompleteCourse stamps completed_at and sends CourseCompleted.
hilms:entitlements:expire closes the active and completed seats whose expiry has passed, in chunks, and is scheduled hourly by LearningServiceProvider.
Self-enrolment
Section titled “Self-enrolment”POST /courses/{course}/enrolments (auth, verified, throttle:enrol) joins a published free course through GrantAccess with a self grant, then redirects into the player at the resume point. Three cases it handles rather than hides:
- a seat that already grants access is simply resumed;
- an expired seat is renewed with a fresh
selfentitlement; - a revoked seat stays closed until staff grant access again.
The course page mirrors all three through <x-learning::enrol-button>, which also draws “Buy access” pointing at purchase_url for a paid course, the sentence “Access to this course is granted individually” for a paid course that names no shop, and a log-in link for a guest. Its “Continue learning” goes to the lesson last read, else the course’s first lesson — a seat taken before the course had a lesson, or whose last lesson was deleted, has no resume point, and the button used to lead back to the page it sat on — and while the course has no lesson at all it says the first lessons are on their way and offers nothing to press.
The player
Section titled “The player”GET /courses/{course}/lessons/{lesson} (lessons.show, scoped binding) is a learning route, not a catalog one, because only this module may decide whether a lesson opens.
It is drawn in the shell with a rail (Theming), with the arena at the reading width (width="prose", because a lesson is reading material) and the rail, “Course navigation”, beside it. The arena holds a breadcrumb — whose link back to the course listing is drawn only where the Courses destination exists — the lesson content through <x-blocks::content>, previous and next links, and the “Complete and continue” button for an enrolled student. The rail holds the course head — its title leading back to the course page — with <x-learning::progress-summary> in its slot when there is a seat (the share large in the display face, “N of M lessons” beside it, then the bar), then the programme with its completion ticks and per-section counts, folded open at the section of the lesson being read (Catalog). Opening a lesson moves the resume point. A locked lesson shows the enrolment call to action and an expired one a notice.
LessonPlayerController loads the lesson’s blocks and assetUses.asset.media before drawing it, so <x-blocks::content> draws only the library files the lesson records as used, each address minted for this lesson, and the page costs the same queries however many files it shows.
Where a student stands
Section titled “Where a student stands”The course page, in catalog, draws a student’s programme the way the player does — open where they would resume, every lesson a link when the seat grants access, the finished lessons ticked — but catalog sits below the seats, so it asks Hilms\Catalog\Contracts\ReadingPosition. This module binds Hilms\Learning\Support\EnrollmentReadingPosition, which answers a Reading of the seat’s last_lesson_id, whether the seat grantsAccess(), and the ids of its completions: two queries for a student, one for somebody signed in without a seat, none for a guest.
Course material
Section titled “Course material”GET /media/{media}/{conversion?} (media.show, throttle:media, signed) is Hilms\Learning\Http\Controllers\MediaController, and it lives here for the same reason as the player: only this module may decide whether a lesson opens. It answers a file of a library asset that is course material and nothing else, and it asks four questions of a link that names a lesson:
- Is the key a lesson? (404 otherwise)
- Does that lesson show this asset — is there a
media_asset_usesrow for it? (403) - Does
ResolveLessonAccessopen the lesson to the visitor, with their seat? (403) - For a recording, is the browser asking for it as a player (
Sec-Fetch-Destofvideo,audio,trackorimage, or no header at all), unless the visitor may update the lesson? (403)
A link that names no lesson is the panel’s and opens only for somebody who may view the asset in the library, which a student never may. A file on a local disk is then streamed with byte ranges (or handed to nginx with MEDIA_SENDFILE=nginx), and a file in a bucket is answered with a five-minute presigned redirect. Media has the whole walk, including where those links are minted.
learning::complete-lesson-button carries a class-level #[On('quiz-passed')], so passing a quiz refreshes it. Whether it is enabled is derived from the recorded attempts, not from an event, so it survives a reload, and it refuses to act for anyone but the enrolled student.
The graded quiz
Section titled “The graded quiz”Hilms\Learning\Blocks\QuizBlock is a lesson-only block: title, introduction, pass mark (default 70), an optional attempt limit, and questions with a type (one or several correct answers), two to six options and an explanation. The editor refuses a question with fewer than two options, without a correct option, or with several correct options on a single-choice question.
Hilms\Learning\Support\QuizData states the same contract outside the editor: rules() for a questions array and normalise(), which returns quiz block data or throws. It enforces the same three rules and normalises what it is handed — an unknown type falls back to single choice, options are capped at six, the pass mark and the attempt limit are kept inside their bounds, a blank introduction becomes null. The AI quiz assistant and the MCP set-lesson-quiz tool both go through it, so a machine-written quiz is exactly as valid as a typed one.
On the site, Hilms\Learning\Livewire\Blocks\Quiz:
- sends only prompts and option texts to the browser — the answer key never leaves the server;
- grades on the server and stores one
quiz_attemptsrow per submission for an enrolled student; - shows the score, pass or fail, per-question correctness, the explanations, the attempts left and the best score;
- allows a retry while attempts remain, and dispatches
quiz-passedon a pass; - runs in practice mode, storing nothing, for somebody who may preview the course but holds no enrolment, and asks anyone else to enrol.
The panel preview marks the correct options, because an editor is checking their own work.
A student’s own courses
Section titled “A student’s own courses”There is no address for this: it is a page an editor writes, with the my-courses block on it, for members only if they say so (Pages).
The block renders Hilms\Learning\View\Components\EnrolledCourses — the student’s enrolments newest first, twelve or twenty-four to a page, each with a progress bar, the lessons done out of the total, a resume link and the badges that say where the seat stands. A seat that ran out says only that; one that is still live says when it was completed and, where it has one, the date the access ends — an expiry nobody is shown is an expiry nobody can act on. Both dates go through <x-ui.date>, so they are drawn on the clock that student keeps (Time and clocks). A guest is shown a card asking them to sign in, and the “browse the courses” button is drawn only where the Courses destination exists.
Two details:
- It pages under a name of its own (
my-courses), so a course listing on the same page keepspageto itself. That is also why the block answersonce(): two of them would turn the page together. Enrollment::progress(),completedLessons()andcourseLessons()readcompletions_countandlessons_countwhen the query selected them and count otherwise, which is what keeps the listing’s query count flat.
SitePagesSettings is what makes one such page the one students land on after signing in, through Hilms\Access\Support\LandingUrl.
| Resource | Shape |
|---|---|
| Enrolments | An index page only — a seat is derived, not authored. Student, course, progress, status, dates; filters by course and status; a “Grant access” header action over the shared GrantAccessForm; a row action that revokes every live entitlement of that student and course |
| Entitlements | An index page only and nothing edited or deleted, because the table is the trail. Student, course, source, computed status, grantor, external reference, dates; the same grant action; row actions “Extend” (a new expiry, which re-syncs the seat) and “Revoke access” (with a reason) |
| Quiz attempts | Read-only: canCreate() and canEdit() return false and no create or edit page exists |
GrantAccessForm is the one form behind every grant: a student select limited to the student role, a course select, an expiry and a note. On the course editor the same Enrolments resource is reused as the “Students” relation manager, where the course select is hidden and the owner course is used instead — one of the two documented places catalog reaches into learning.
All three lists, and their course filters, are narrowed through Hilms\Learning\Filament\ReadableCourses to the courses the person reads (Catalog): an instructor sees the seats, grants and attempts of the courses they teach and of no other, where until the owner’s permissions scan every instructor read every course’s students and scores.
Permissions come from LearningPermissionSeeder: Enrollment and Entitlement append-only, QuizAttempt read-only. The custom actions authorise themselves — “Grant access” needs Create:Entitlement, “Revoke access” and “Extend” need Update:Entitlement.
Console
Section titled “Console”| Command | Does |
|---|---|
hilms:user:entitle {user} {course} {--expires=} {--note=} {--force} |
Grants access with source command and no grantor. --expires is read on the installation’s clock unless the value names an offset, and the confirmation prints the stored expiry back on that clock |
hilms:user:revoke {user} {course} {--reason=} {--force} |
Revokes every live entitlement of that student and course |
hilms:user:entitlements {user} |
Lists every entitlement with source, status, dates and reference |
hilms:entitlements:expire |
Closes the seats whose entitlements have run out (scheduled hourly) |
A user is named by id or email, a course by id or slug.
Notifications
Section titled “Notifications”EnrolledInCourse (with or without an account setup link) and CourseCompleted are queued mail notifications on the default queue, in Polish and English through learning::notifications. Both carry #[WithoutRelations] and #[DeleteWhenMissingModels], so a worker restores no stale graph and a job whose model is gone is dropped rather than retried.
app-modules/learning/tests/Feature: the grant and revoke actions including idempotency and the grantor constraint, the instant an expiry ends access on whatever offset it arrived with (EntitlementClockTest), the seat sync in every combination of live, expired and revoked entitlements, self-enrolment and its three cases, lesson access per role and per seat, completion and course completion, the quiz block and QuizData, the player, the reading position (EnrolmentTest), course material through media.show (AssetMediaTest, PrivateMediaTest), the my-courses block and its listing, the panel resources and the relation manager, the console commands, and query-count invariance for that listing, the player and the two panel tables.
HiLMS is MIT-licensed. No replicants were harmed in the writing of these books.