Skip to content

Architecture

Layer Choice
Runtime PHP 8.5, Laravel 13
Admin and editor panel Filament 5 on Livewire 4, Filament Shield for permissions
Student front-end Blade and Livewire 4 in a theme, Tailwind 4 on semantic tokens
Authentication Laravel Fortify with our own views, two-factor and passkeys, Laravel Socialite (Google, Facebook)
Authorisation spatie/laravel-permission roles and Action:Entity permissions
Data Eloquent on MySQL 8.0+ or MariaDB 10.11+, Redis as the cache’s and the queue’s first choice — never the site’s only one: sessions live in the database, the cache fails over to it and the queue to running a job after the response (Operations)
Files A media library of our own over spatie/laravel-medialibrary: every file an asset shown by reference, public or course material, on this server or in an S3-compatible bucket (Media)
Machine access laravel/passport for /api/v1 and for agents connecting over MCP (laravel/mcp)
Assistants laravel/ai behind the ai module
Operations Horizon, Pulse, Telescope, spatie health, backup and activitylog

DB_CONNECTION chooses mysql or mariadb; both read the same DB_* keys, and the suite runs on both before every push (bin/gates, Quality).

app/ is deliberately thin: AppServiceProvider (the Eloquent guard rails, the morph map and the relocation of two package caches for the host), the Filament panel provider, the shared upload and action components, the kernel of the Settings cluster (app/Filament/Settings, Settings), the hilms:make-module and hilms:make-settings commands and Shield’s RolePolicy. Everything with a domain lives in a module under app-modules/, each a Composer path package with the namespace Hilms\<Module>:

Module Owns
access Users, roles and permissions, Fortify, OAuth, passkeys, profile fields, the clock a person keeps, account deletion, the Users resource, the content security policy presets
languages The languages an installation speaks and the clock it keeps, locale-prefixed addresses, translation groups, the phrase editor, the Languages, Phrases and Region pages
theme The theme kernel: manifests, chain resolution, per-theme assets, tokens, styles (what an administrator lays over the tokens), the view contract, the page-template registry, Branding, the design catalogue
navigation Menus: their models, the places they fill, the link sources other modules register, the cached snapshot, the builder
library The media library: assets and where they are used, who sees what, the picker, the addresses of files, moving files between disks
blocks The block kernel, the built-in blocks, the canvas and its settings panel, the patterns, the renderer, the Markdown readers, hilms:make-block
catalog Categories, courses, sections, lessons, their panel resources, the course page and its rail
pages Informational pages built from blocks, the front-door and fallback routes, the destinations an installation points at them
learning Entitlements, enrolments, progress, the course player, the graded quiz, serving course material
ai Editor assistants: settings, providers, agents, queued generations and their review page
api /api/v1 on Passport, the OAuth flow for agents, the MCP authoring server, API clients
ops Horizon, Pulse, Telescope, health, backups, the audit trail, mail and media settings, the installer

Modules are discovered by internachi/modular. A module’s service provider, routes, views, translations, migrations, factories, policies, listeners, Blade components, Livewire components and console commands register themselves by convention (see Modules).

Dependencies run one way, and a change that reverses an arrow is a design error, not a shortcut:

ops may import everything; nothing imports it
ai, api build on learning; ai also reaches catalog and blocks
learning builds on catalog and blocks
pages builds on blocks, navigation and access
catalog builds on blocks and navigation
blocks builds on theme and library
library builds on languages and access
navigation builds on theme
theme builds on languages
languages builds on access
access the floor everything stands on

Every arrow points down that list, and no module imports one above it.

The deliberate exceptions, each documented where it happens:

  • catalog reaches into learning in exactly two places: the course page renders <x-learning::enrol-button>, and CourseResource::getRelations() mounts learning’s “Students” relation manager.
  • A module above catalog adds an action to the course, lesson or page editor through Hilms\Catalog\Filament\EditorActions, registering from its own provider instead of being imported.
  • access sits below theme, so theme declares access’s contract views itself (Hilms\Theme\Contract\AccessViews) and Hilms\Access\Csp\HotOrigins globs themes/*/public/hot rather than importing theme.
  • access also sits below languages while keeping users.locale and users.timezone, so it asks for the list of languages through Hilms\Access\Contracts\SpokenLanguages and for the installation’s clock through Hilms\Access\Contracts\InstallationTimezone, each with a floor of its own underneath (see Languages and Time and clocks); and a module that writes in a language tells languages what it holds through Hilms\Languages\Contracts\LanguageUsage, registered from its own provider.
  • access sends people somewhere after they sign in but sits below the module that keeps the pages, so it asks through Hilms\Access\Contracts\Destinations, which pages binds and the site’s root answers for until it does (see Pages).
  • catalog draws the course page’s programme the way the player does — open where a student would resume, every lesson a link when the seat grants access, finished lessons ticked — but sits below the module that keeps the seats, so it asks through Hilms\Catalog\Contracts\ReadingPosition: of($course, $user) answers a Hilms\Catalog\Support\Reading, catalog binds NoReadingPosition (nobody is anywhere) as the floor, and learning binds EnrollmentReadingPosition (Catalog).
  • catalog asks, the same way, whether a course holds a history it may never purge: Hilms\Catalog\Contracts\CourseHistory, which learning binds (GrantHistory: an entitlement exists, revoked or not) over the NoCourseHistory floor.
  • library knows no course, so who teaches what reaches it through Hilms\Library\Contracts\TeachingScope, which catalog binds (CourseTeaching) over the NothingTaught floor; and a record that shows assets answers for itself through Hilms\Library\Contracts\UsesAssets, while a model that chooses the disk its files live on answers Hilms\Library\Contracts\ChoosesMediaDisk (Media).

navigation knows none of the modules above it: a model that may be linked to registers itself with LinkSources from its own provider (Navigation). A page template is registered with the registry in theme, which is how a module draws a page without importing pages.

Nothing imports pages but ops, which is where the one action that needs both the menus and the pages they point at lives (SeedDefaultMenus).

  • Student pages are routes in app-modules/<module>/routes/*.php inside the web group, handled by small controllers or full-page Livewire components. Each such file wraps its routes in Route::localize(), so every public address exists behind a language prefix as well as bare (see Languages). They render views the theme owns: the core ships no Blade a student sees (see Theming).
  • There are few such routes, because most of what a visitor reads is a page of blocks: / and the one-segment fallback belong to pages, and the course page and the player are the only addresses the catalogue and the learning side keep. A course listing and a student’s own courses are blocks an editor put on a page.
  • The panel is one Filament panel (admin) declared in app/Providers/Filament/AdminPanelProvider.php. It discovers nothing itself; each module ships a Filament\<Module>Plugin that discovers its own resources, pages and widgets, attached by the module provider through Panel::configureUsing.
  • Authentication for both worlds is Fortify: /login is the single login page and Filament’s own is switched off. User::canAccessPanel() decides who enters /admin, and Hilms\Access\Support\LandingUrl decides where a login lands.
  • Authorisation in the panel goes through Shield-generated policies checking Action:Entity permissions. The panel runs with strictAuthorization(), so a missing policy method denies. The admin role bypasses every check through Gate::before.
  • Machines reach /api/v1 with a Passport client-credentials token; agents reach /mcp/authoring with a token that names the person who approved it (see API and agents).
users ──< course_instructor >── courses ──< sections ──< lessons
│ │ │ │
├──< social_accounts │ ├── category ├──< content_blocks
├──< passkeys │ ├── tags └──< lesson_completions
├──< entitlements >────────────┤ └── cover_asset ─┐ │
├──< enrollments >────────────┤ │ │
│ └──────────────────────┼───────────────────┼──────────────┘
└──< quiz_attempts >────────────┘ │
▼
media_assets ──< media (spatie) media_asset_uses: an asset shown by
└──< media_asset_uses >── a lesson, a page or a course, in a slot
pages ──< content_blocks branding settings
menus ──< menu_items >── a page, a course or a category activity_log
└──< menu_locations oauth_clients
ai_generations ──> a lesson or a course
languages translation_overrides
styles: per theme, one active by a unique active_theme

courses and pages each carry a language and a translation_group: the same course written in two languages is two rows of one group, each with its own sections, lessons and blocks, showing the same library files by reference (see Languages).

A lesson, a page and a course hold no file of their own. A block names a library asset by id and a course names its cover in cover_asset_id; media_asset_uses records where each asset is shown, and that record is both what the renderer draws and what the access check reads (Media).

Five denormalisations worth knowing:

  • lessons.course_id sits beside section_id, so a lesson is found inside its course without joining sections and its slug can be unique per course.
  • enrollments is a derived seat: entitlements is the source of truth, and only SyncEnrollmentAccess writes the seat’s status and expiry (see Learning).
  • users.profile is one JSON column holding the values of whatever profile fields the installation defined, because a field an installation invents cannot have a migration of its own.
  • categories.name and description, tags.name and slug, and the two branding lines are JSON columns with one value per language, because they are labels rather than content.
  • menu_locations.location is one string carrying both the area and the language (header.pl), because the package keys a location by one unique string (see Navigation).

Nothing on a pages row says what the application links to: three settings do (see Pages).

Polymorphic types are pinned by a morph map in AppServiceProvider: user, social_account, category, course, section, lesson, page, language, translation_override, content_block, entitlement, enrollment, api_client, branding, style, ai_generation, menu, menu_item, menu_location, media_asset. A new model in a morph relation is added there.

  • SPECS.md is the current specification and is updated in the same change that alters the system.
  • PLAN.md is the roadmap: batches, decisions, what comes next.
  • CLAUDE.md holds the working rules for the AI collaborator; AGENTS.md is generated by Laravel Boost.
  • docs/install/ is the operator’s documentation: requirements, Docker, manual install, upgrades, media storage, theming, languages, MCP, retention, social login.
  • This book and the user book explain; none of the three keeps history.

HiLMS is MIT-licensed. No replicants were harmed in the writing of these books.