Catalog module
Namespace Hilms\Catalog. Owns the course structure editors manage and the pages students browse. Lesson content belongs to blocks, and access and progress to learning.
Models
Section titled “Models”| Model | Notes |
|---|---|
Category |
name and description translatable (one value per language), slug from the default language’s name (kept on rename), ordered by position, route key slug; courses() and publishedCourses() |
Course |
Slug from title (kept on rename), route key slug, soft deletes, CourseStatus and CourseAccess enums, purchase_url, published_at, language and translation_group (Translatable through HasTranslationGroup); category, sections, lessons, instructors, enrollments, translations, coverAsset; tags; implements UsesAssets for its cover |
Section |
Belongs to a course, ordered by position within it; lessons (ordered) |
Lesson |
Belongs to a section and denormalises course_id; slug unique per course; ordered within its section; is_free_preview, duration_minutes; implements Blockable and UsesAssets, and is the one record that may show course material |
CourseStatus is draft, published or archived; Course::published() is the only definition of public. CourseAccess is free or paid: only a free course can be self-enrolled, and a paid one carries an optional purchase_url the course page links to — HiLMS sells nothing itself.
Useful methods on Course: isPublished(), allowsSelfEnrolment(), canBePreviewedBy(?User), isVisibleTo(?User), firstLesson() and lessonsInReadingOrder() (by section, then position). Course::SUMMARY_LIMIT is the one place the summary length lives.
isPublished() asks the same question of one row that the published() scope asks of a query, and the two have to agree on the boundary instant as well: it is written ! $this->published_at->isFuture(), so the very tick a course was typed to go live on counts as published. isPast() is strictly before now, and the two would disagree for exactly that tick — the scope letting the row through while the row said it was not published yet.
Instructors are users with the instructor role attached through course_instructor; the course form limits the select to that role, and the public course page draws each of them as an <x-ui.person> card built from their avatar and profile fields.
The cover is a public picture of the media library, named in courses.cover_asset_id (restrict on delete) and chosen with the asset picker. Course records it as a use of the asset in slot cover (Course::COVER_SLOT) whenever the column changes, whoever changed it, and forgets it — with every use of its lessons — when the course is force-deleted; a soft-deleted course keeps its cover in use, since it may come back. coverUrl($conversion) mints the address through AssetUrl: thumb (480) on a card and in the panel’s table, content (1600) on the course page, the asset’s own conversions. A section’s deleting hook forgets what its lessons showed, because the database removes them with no model event. Neither a course nor a lesson holds a file of its own (Media).
Course, section, lesson and category are audited on their publishing fields; getActivitylogOptions() sits on each model, never in ops.
One course per language
Section titled “One course per language”A course written in two languages is two courses sharing a translation_group, each with its own sections, lessons, blocks and files. Course implements Hilms\Languages\Contracts\Translatable, so it answers languageCode(), siblings(), translationIn(), link() and unlink() (see Languages) — and courses.language is also the tag the theme puts as lang on the words the course itself wrote.
Actions/TranslateCourse writes it again in another language: the category, the instructors, the tags, the cover (the same asset id) and every section, lesson and block, through Hilms\Blocks\Actions\CopyBlocks, so the translation shows the original’s library files by reference. The copy is always a draft and gets a slug of its own (the original’s with the language appended); nothing is published in a language nobody has read yet.
Thin actions
Section titled “Thin actions”Three actions create catalog records outside the panel form, so an agent or a script goes through the same door as the panel:
CreateCourse::handle(string $title, ?string $summary = null, ?Category $category = null): CourseCreateSection::handle(Course $course, string $title): SectionCreateLesson::handle(Section $section, string $title, ?string $summary = null): LessonCreateCourse always produces a draft: nothing written by a machine reaches a student until a person publishes it. CreateSection and CreateLesson append to the end. The MCP authoring tools write through these and nothing else.
Filament/Resources/Categories/CategoryResource: single-page resource with modal forms, drag-and-drop ordering and a courses count. On an installation that speaks more than one language it carries a locale switcher in its header (lara-zeus/spatie-translatable), so the same row is edited in one language at a time;getTranslatableLocales()answers with the installation’s languages as the page draws, not as the panel was built.Filament/Resources/Courses/CourseResource: the list (cover thumbnail, title with its category beneath, the language as a badge, status badge, lesson count, publication date; filters status, language, category, trashed; a preview link to the public page) and a three-column form (Schemas/CourseForm) — details spanning two columns and holding the required language select, and a sidebar holdingSidebarActions, the publishing section (status, access, publication date, category, instructors, tags and the cover, anAssetPickerthat offers the library’s public pictures), theCourseTranslationsblock andDangerActions. The edit page mountsSectionsRelationManagerand learning’s “Students” relation manager. The language column, the language filter and the select’s options all come fromHilms\Languages\Support\Languages, and the column and the filter hide themselves where there is only one language. The language field is disabled while a course has siblings: moving it would move what they are translations of.Courses/Resources/Sections/SectionResource: nested under courses; its edit page carries the lessons relation manager.Courses/Resources/Sections/Resources/Lessons/LessonResource: nested under sections. Three columns again: details and, on the edit page only, a “Content” section holdingBlocksBuilder::make(BlockContext::Lesson), with the sidebar carrying the actions, the settings (duration, free preview) and the danger section.
Two slots let modules above catalog add to these editors without being imported (see Conventions):
| Page | Slot | Rendered by |
|---|---|---|
EditCourse |
summary |
the summary field’s afterLabel() |
EditCourse |
description |
the description field’s afterLabel() |
EditLesson |
blocks |
the Content section’s afterHeader() |
The lesson slot sits on the section heading rather than on the builder’s own label, because the builder hides that label and an action hung off it draws at the top left of the content.
Permissions for Category, Course, Section and Lesson are seeded by CatalogPermissionSeeder: sortable sets for three of them, the soft-deleted set for Course. Editors get all of them, instructors the View* ones.
An instructor reads the courses they teach and nothing hanging from any other. Course::readsEveryCourse() is Update:Course; anybody else reads through the readableBy() scope, the courses listed for them in course_instructor. CourseResource::getEloquentQuery() narrows the list with it — and so the section and lesson pages, which find their parent through it — and view on a course, a section or a lesson asks Course::isReadBy(), which reads the loaded instructors relation so a listing loads it once; an MCP agent acting as an instructor therefore lists and reads only those courses too. The library narrowed instructors this way first.
A course somebody was ever granted is never purged. The soft-deleted set seeds no ForceDelete: purging is an administrator’s alone (CoursePolicy::forceDelete() closes it to everyone else), and even an administrator cannot purge a course that holds a history — Course::holdsHistory(), asked through CourseHistory — because the database would take every grant, with the reference a shop granted it under, every seat and every attempt with its row. The course’s forceDeleting hook throws CourseHoldsHistory; “Delete permanently” is offered only on a course without history; the bulk action purges the rest and says how many stayed in the bin.
Public pages
Section titled “Public pages”The module keeps one address:
| Route | Handler | Contract view |
|---|---|---|
/courses/{course} (courses.show) |
Http/Controllers/CourseController@show |
catalog::courses.show |
The course player at /courses/{course}/lessons/{lesson} belongs to learning, because only that module may decide whether a lesson opens.
A course listing is a page an editor writes, not an address the application keeps. The courses-list block mounts the whole catalogue, and SitePagesSettings is where an installation says which page that is (Pages). Category::url() therefore answers the Courses destination with ?category=<slug>, or nothing at all when the installation named no listing page, and every caller is written to expect null.
Two blocks live here:
latest-coursesrenders<x-catalog::latest-courses>with an optional heading and 3, 6 or 9 courses, and draws its “all courses” link only where the Courses destination exists.courses-listmountsLivewire/CourseCatalogwith its options as locked properties: whether the search is offered, which of the filters, 6, 12 or 24 per page and two or three columns. It answersonce(), because its search, its filters and its page number live in the address, and two of them would read the same state and move together.
Nothing arrives trusted: the block reads every stored option back through its own allowlist (showSearchIn, filterKeysIn, perPageIn, columnsIn) before the component is mounted, and #[Locked] keeps it that way for the rest of the component’s life. The property is called filterKeys and not filters, because the view’s filters are the options behind them.
In the editor both blocks draw a compact placeholder rather than querying the catalogue on every preview.
CourseCatalog keeps search (URL q), category and language (any number of them) in sync with the address bar. The search goes through laravel/scout’s database engine over the course title, summary and slug (the slug by prefix), while the published scope, the filters, the eager loads and the ordering stay in the Eloquent query Scout hydrates with — so the query count does not move with the number of results. There is no full-text index: InnoDB’s only sees committed rows, which a transactional test suite never has.
The catalogue lists every language at once and is narrowed by a column of filters. Catalogue\Filters\Filter is the shape (key(), label(), options(), apply()), FilterOption one choice with its count, FilterSet the composition, and LanguageFilter and CategoryFilter the two it offers; each counts with one grouped query over the whole published listing, so an option says how many courses it would leave and an option nothing is behind is not drawn. The counts are built on toBase() and never getQuery(): only the former applies Eloquent’s global scopes, and a course in the bin counted beside the filters while they used the latter. An empty listing says which kind of empty it is — “There are no courses here yet.” when nothing was asked, “No courses match your search.” when a search or a filter left nothing, with the results line’s clear control beside it. The search field is the first item of the filter aside, and the aside lives inside the Livewire component, because Livewire binds wire:click only inside its own root element (Languages).
The course page is drawn in the shell with a rail (Theming): the arena introduces the course — cover, category, title, summary, description, then the people who teach it and its tags — while the rail, “About this course”, holds everything a visitor acts on: the course head with the enrolment call to action, then the programme.
CourseController guards with $course->isVisibleTo($request->user()), eager-loads the category, the instructors with their media and roles, the tags, coverAsset.media, the sections with their lessons and the translations, counts the lessons and sums their minutes, and passes three things: the course, canPreview, and reading.
Where the visitor stands. reading is a Hilms\Catalog\Support\Reading — the lesson they would resume at, whether every lesson opens for them, and the ids of the lessons they finished, or null where nobody’s progress is known — answered by Hilms\Catalog\Contracts\ReadingPosition. Catalog sits below the module that keeps the seats, so it binds NoReadingPosition (nobody is anywhere) as the floor and learning binds EnrollmentReadingPosition, which reads the seat and its completions in two queries for a student, one for somebody signed in without a seat and none for a guest (Learning). That is what lets the course page draw a student’s own programme the way the player does: open where they would resume, every lesson a link when the seat grants access, the finished ones ticked. An editor’s canPreview opens every lesson as well. The stored description is rendered through RichText::html(), so nothing in that column reaches the page unsanitised. Somebody reading in another language is redirected to the sibling course written in it when there is one they may see, and the siblings they may see are announced through Hilms\Languages\Support\Alternates, so the language switch and the hreflang links point at the sibling’s own address.
Components the theme renders: <x-catalog::course-card :course> (which says in words, after the lesson count, when a course is not in the language being read), <x-catalog::catalogue-filters>, <x-catalog::course-head :course :link>, <x-catalog::curriculum :course :current :open :can-open-all :completed> and <x-catalog::latest-courses :limit :heading> (a class component that runs the query, which the latest-courses block renders).
The course head opens both rails, the course page’s and the player’s: the course title as the rail’s h2 in the display face — a link back to the course page on the player (link), plain on the course page itself — then the people who teach it as up to three overlapping small avatars beside every name, then a quiet line of the lesson count and the total minutes, then its slot: the enrol button on the course page, the student’s progress summary on the player.
The programme (<x-catalog::curriculum>) is a <nav> named “Curriculum” — or, while no section holds a lesson, the sentence “This course has no lessons yet.”, because a landmark holding nothing is announced all the same — so the rail draws no heading over it, holding one native <details> per section and no script at all. A section’s summary is its title, a quiet count — 2/5 with a sentence for a screen reader when progress is known, the lesson count otherwise — and a chevron. Exactly one section opens: the one holding open (the resume lesson), else the one holding current (the lesson being read), else the first, because a programme folded shut tells nobody where they are.
Its lessons hang from a 1px tree line. Each row is a link, or a <span> in faint ink when the visitor cannot open it, and is a two-column grid: the title wraps in the first column, and the marks stay on its first line at the right edge in a fixed order so they align down the list — the free mark (<x-ui.free-mark>, a struck-out dollar with its word for a screen reader), the padlock with its word, the completion tick with its word, then the minutes in a column of one width. The row’s start border sits over the tree line: the strong border under the pointer, the accent beside the lesson being read (aria-current="page"). The absence of a link is not a signal and an icon is not a name, which is why every mark carries a word of its own.
Extending
Section titled “Extending”- A new course field: migration with
--module=catalog, add it to#[Fillable], toCourseForm, and where it is shown; updateCourseFactory; add translations tocourses.phpin both languages; and decide whetherTranslateCoursecopies it into a translation. If it appears on a student page, declare the change insrc/Theme/ContractViews.phpas well. - A new lesson flag or metadata goes on
Lessonthe same way. Content itself is blocks. - A new panel resource: generate it with both namespaces, run
shield:generate, trim the policy, add the entity toCatalogPermissionSeeder. - An action on an existing editor from another module: register it on
EditorActionsfrom that module’s provider. Catalog never imports the module above it. - A new catalogue filter: a class implementing
Catalogue\Filters\Filter, listed inFilterSet, counting in one grouped query. The component decides whether a click on it means one choice or several, and thecourses-listblock offers it as a checkbox so an editor may leave it out of a listing. - A new link source:
CourseandCategoryare already registered withLinkSourcesfrom this module’s provider, so a menu may point at either (Navigation).
app-modules/catalog/tests/Feature: slug and ordering rules, the publishing scope and the instant a course goes live on (CourseClockTest), preview rights per role, the two blocks and the options they lock, the catalogue’s search and filter column (including the counts and their query-count invariance from two options to ten), the course page and its redirect to a sibling, translation groups and TranslateCourse, the panel resources including the nested pages and the sidebar actions, the editor slots, the thin actions, and query-count invariance for the catalogue, the course page and the panel list. The programme a student sees on the course page is tested in learning, which binds the answer (EnrolmentTest).
HiLMS is MIT-licensed. No replicants were harmed in the writing of these books.