Access module
Namespace Hilms\Access. Owns identity, roles, permissions and every authentication entry point. It sits at the bottom of the module direction, so it imports nothing above it — which is why the modules above register what they need with it instead.
Users and roles
Section titled “Users and roles”User (src/Models/User.php) is the auth model for the web guard. It implements MustVerifyEmail, Filament’s FilamentUser and HasAvatar, Passport’s OAuthenticatable, the passkey contract, spatie’s ExportsPersonalData and HasLocalePreference, and it carries media (the single-file avatar collection with one unqueued 256×256 WebP crop).
Roles are the Role enum (admin, editor, instructor, student) backed by spatie/laravel-permission; RoleSeeder creates them idempotently and Role::panelRoles() lists those allowed into /admin. $guard_name is pinned to web, so roles and permissions resolve there whichever guard authenticated the request — a request carrying an API token still finds the roles it has.
To add a role: add the enum case and its labels in access::roles, decide whether it belongs in panelRoles(), and give it permissions in the relevant module permission seeders. User::factory()->withRole(Role::Editor) is available in tests.
The language a person reads in
Section titled “The language a person reads in”users.locale holds it, empty until they say. preferredLocale() answers it, or the installation’s default, and because User implements HasLocalePreference that is the language every queued notification renders in, wherever it was sent from. The column carries a model default of null: a strict model throws on an attribute the insert never wrote, and everything that writes a person asks for it.
It is written on /account, on the user editor, from the panel’s own user menu, by hilms:user:create --language= and by Hilms\Languages\Http\Middleware\PersistUserLocale when somebody opens a prefixed address.
access keeps the column but sits below the module that keeps the list of languages, so it asks for that list through Hilms\Access\Contracts\SpokenLanguages: languages binds InstallationLanguages over the table, and ConfiguredLanguage is the floor underneath, the configured locale alone. Nothing in access imports languages (see Languages).
The clock a person keeps
Section titled “The clock a person keeps”users.timezone holds the zone a person said they are in, empty until they say, and every date they are shown or type follows it. Hilms\Access\Support\ViewerTimezone::current() is the one door: that person’s zone when they have one PHP still knows, the installation’s otherwise — which is what a guest, a console command and a queued worker get. Hilms\Access\Support\Timezones is the list both halves of the choice are validated against, and it lives here rather than beside the settings because access is the module below.
It is set on /account, in a select among the real identifiers whose empty option means “the same clock as the site” (validated in UpdateUserProfileInformation against Timezones::all()), and on the user editor in the panel, which offers the same list with the same empty option. Nothing is ever free text.
There is deliberately no timezone() method on User. A model method named exactly like one of its columns is read as a relation the moment that attribute goes missing — the same family as languageCode() in Languages — so the question is answered by ViewerTimezone instead. The column carries a model default of null for the same reason locale does.
AccessPlugin::boot() is what hangs that clock on the whole panel. The rest of the story — the contract access asks the module above it through, the theme’s <x-ui.date>, the wire format and the traps — is Time and clocks.
Profile fields
Section titled “Profile fields”An installation defines its own profile fields rather than getting a fixed set. ProfileSettings holds them (group profile): a key matching ^[a-z][a-z0-9_]*$, a ProfileFieldType (one line, a web address, several lines), a label per locale, and the roles the field belongs to. The settings migration seeds the six an instructor gets — job, bio, website, linkedin, x, youtube — and the “Profile fields” section of Settings (ManageProfileFields) edits them in a reorderable repeater that refuses a duplicate or malformed key. The label gets one input per installation language, and only the default language’s is required: a field with no English label still reads as its Polish one.
Values live in the single users.profile JSON column, because a field an installation invents cannot have a migration of its own. Nothing trusts the stored array: Hilms\Access\Support\ProfileFields turns it into ProfileField value objects and drops a row that is not one. Writes are allowlisted by key and merged onto what the person had, both in the panel and at /account, so hiding a field for an afternoon never empties somebody’s profile.
The shape is written down under @phpstan-var, not @var: spatie’s settings resolver reads a plain @var, cannot parse an array shape and refuses to boot the class when it finds one.
Fortify
Section titled “Fortify”Providers/FortifyServiceProvider.php (registered by AccessServiceProvider) binds the actions in src/Actions/Fortify and the views the theme owns. Fortify registers no routes itself: access-routes.php loads its route file inside Route::localize(), so every authentication address answers under a language prefix as well. Enabled features: registration, password reset, email verification, profile update, password update, two-factor authentication and passkeys.
- Passwords must be at least ten characters; the breached-password check runs only in production, so the suite stays offline.
- Rate limiters are named, one per sensitive route, and defined in
FortifyServiceProvider::rateLimiters():login5/minute by email and IP,two-factor5/minute by the pending login id,passkeys10/minute by IP,registration10/hour by IP,password-reset3/minute by email and IP,password-confirm5/minute by user — one budget for every guess at a person’s current password, so it is shared by confirming the password, changing it (PUT /user/password) and deleting the account with it (DELETE /account), each of which was an unlimited oracle for whoever held a stolen session until the security review —oauth10/minute by IP,enrol10/minute by user,personal-data1/hour by user,media600/minute by user or IP (on the file route, Security), andmedia-fetch20/minute by user, which the panel’s “Add from a web address” action reads itself rather than hanging it on a route. Fortify hangs a limiter only on login, the two-factor challenge and the passkey routes, soHilms\Access\Support\FortifyRoutesappends the rest from anapp()->booted()callback, which also reaches routes restored from the route cache. Append with$route->middleware()and never readgatherMiddleware()first: it memoises what it computed. - A refusal says how long to wait, in the reader’s words. Every limiter on a route but
loginanswers throughrefused(): JSON{"message": …}withRetry-Afterto a request that expects JSON — the passkey library shows thatmessage, and a bare line of text reached it as “Request failed with status 429” — and to everyone else aThrottleRequestsExceptionthe handler renders as the theme’s 429 page, which reads the seconds fromRetry-After(“Spróbuj ponownie za 58 sekund”, in Polish’s three forms). - The password flow never tells whether an address has an account.
PasswordResetLinkRequestedanswers Fortify’s successful and failed link responses the same way — a link sent, an address nobody has, a second request inside the broker’s minute — with a redirect toGET /forgot-password/sent(password.sent,PasswordResetLinkSentController), which says a link is on its way if an account exists for the address typed;PasswordNotResetanswers a reset whose address has no account with the invalid-link message, because given any token at all the difference would list the addresses that do. The broker already holds every request to 200 ms, so the timing says nothing either. Login answers one message for every failure, and the passkey sign-in names nobody. Registration still says “the email has already been taken” behind its own limit — the owner’s choice, because closing it would mean a new account could not be signed in before confirming its address. AuthenticateSessionis on thewebgroup as well as the panel, so changing a password ends every other session. Changing the email address goes throughpassword.confirm.PanelAwareLoginResponseanswers both Fortify’sLoginResponseand itsTwoFactorLoginResponsethroughHilms\Access\Support\LandingUrl: panel users land on/admin, everyone else on the page the installation chose for its students, and on the front door when it chose none. An intended URL still wins.accessasks for that page throughHilms\Access\Contracts\Destinations, because it sits below the module that keeps the pages (see Pages).
Two-factor authentication and passkeys
Section titled “Two-factor authentication and passkeys”Two-factor is the Fortify flow: enable writes the secret, the account page then shows the QR code and the key until a code confirms it, and the recovery codes appear once. Every management route sits behind password.confirm. An enrolled account’s login stops at /two-factor-challenge.
Passkeys use Fortify’s bundled laravel/passkeys. The passkeys table is an access migration copied from the package rather than published, because publishing Fortify’s migrations would repeat the two-factor columns. PASSKEYS_USER_HANDLE_SECRET derives the WebAuthn user handle and falls back to APP_KEY, so rotating the key invalidates every passkey.
A passkey login skips the two-factor challenge. The passkey controller signs the user in through the guard instead of the login pipeline, so RedirectIfTwoFactorAuthenticatable never runs. That is deliberate — a passkey is a possession factor bound to the origin and usually gated by a biometric — and PasskeyTest asserts it on an account that has two-factor enabled.
Provisioning and setup links
Section titled “Provisioning and setup links”Trusted paths never invent a password. Actions/ProvisionUser returns the existing account for a known email (lower-cased and trimmed) or creates an unverified student with a random password, and notifies nobody. Support\AccountSetupLink::for() then builds a signed seven-day link to GET|POST /account/setup/{user} carrying a fingerprint of the current password hash, so the link dies the moment the password changes. CompleteAccountSetup validates the password with Fortify’s rules, sets it, verifies the email and lands the visitor through LandingUrl.
users.password_set_at records that somebody actually chose the password. Accounts created by OAuth or ProvisionUser carry a random one and no stamp, which is what User::hasPassword() reports — and what decides whether the panel’s create form mails a setup link and whether an account may disconnect its last provider.
Registration
Section titled “Registration”AccessSettings::$registration_enabled opens or closes self-registration; an administrator switches it on the “Sign-up” section of Settings (ManageAccessSettings). When it is closed, /register redirects with a notice, POST /register answers 403 (RegistrationClosed), OAuth refuses to create a new account — existing ones still sign in and link — and every sign-up link disappears through the @registrationOpen Blade directive over Support\Registration. The panel, the API and the console ignore the setting.
OAuthProvider enumerates the providers (google, facebook). Routes /auth/{provider}/redirect and /auth/{provider}/callback bind the enum, so unknown providers are 404. Credentials live in config/services.php; a provider button appears only where a client id is configured (OAuthProvider::configured()).
Actions/AuthenticateWithProvider resolves the local user:
- a known
SocialAccountsigns in, even if the provider email changed; - an unknown identity is attached to an account with the same address only when the provider vouches for it (Google’s
email_verified, every Facebook address), otherwise the visitor is told to sign in with their password and connect the account from/account; - otherwise a verified student with a random password is created — again only for an address the provider vouches for (
ProviderEmailUnverifiedasks the visitor to register with the address instead), because an identity holding somebody else’s unverified address would otherwise keep a way into the account that address’s real owner later took over with a password reset. Registration being closed is answered first.
A signed-in visitor reaching the callback connects the identity to their own account. Disconnecting (DELETE /account/connections/{socialAccount}) is refused while it is the only way in: no chosen password, no other identity and no passkey (User::hasSignInMethodBesides()).
Adding a built-in Socialite provider means an OAuthProvider case with a label, the services.php entry and the two environment variables. Community providers additionally need the socialiteproviders event listener.
The account page
Section titled “The account page”/account (auth + verified) is rendered by AccountController@show from one section component per topic (access::components.account.*): Profile, Password, Two-factor authentication, Passkeys, Connected accounts, Your data and Delete.
Two details bite if you touch it:
- Field ids are prefixed by section (
profile-field-<key>and friends), because the page carries anemailand apasswordfield twice and a shared id binds a label to the wrong input. A test fails when any id on the page repeats. - The Profile card posts as multipart: the avatar file input and the “remove my photo” checkbox ride the same
user-profile-information.updateform as the name, the email, the language the person reads in — drawn only where there is more than one — the clock they read their dates on, and the profile fields. The bytes are sniffed withFileContentTypeexactly as a panel upload is.
The panel grows no profile page of its own; its user menu links to /account and to the site.
Personal data and deletion
Section titled “Personal data and deletion”Every module contributes one file to a personal data export through Hilms\Access\Contracts\PersonalDataSource (name(), collect(User)), registered on the PersonalDataSources registry in its own provider — which is how access stays below the modules that know what they store. The sources are profile, social-accounts and passkeys (access), entitlements, enrollments and quiz-attempts (learning), and activity (ops).
POST /account/data builds the archive on the queue, once an hour per account, and mails a link that works only for the signed-in owner. Archives live on the private personal-data-exports disk and are cleaned nightly.
Account deletion happens only through Actions/DeleteAccount, the one writer of a tombstone:
- it refuses the last administrator itself (
LastAdministrator), not in any one caller, so the account page, the console and the panel all get the same answer; - it mails
AccountDeletedto the old address before anonymising, so the queue cannot deliver it to the tombstone; - social accounts, passkeys, sessions, reset tokens, roles, pending exports, the avatar and every OAuth token the account held go;
- activity entries about the account go (they hold its old name and address) while the entries it caused stay (they name an id and a role);
- the
usersrow survives as “Deleted user” atdeleted-{id}@hilms.invalidwith an unusable password anddeleted_atstamped, because every foreign key touserscascades and a hard delete would take the entitlement trail with it.
users deliberately has no SoftDeletes trait: trashed handling would have to reach Fortify, Socialite, provisioning, Filament and the permission tables, and the unique email would block re-registration.
Panel access and the Users resource
Section titled “Panel access and the Users resource”User::canAccessPanel() returns true for admin, editor and instructor. Filament’s Authenticate middleware sends guests to Fortify’s /login and answers 403 for anyone else.
Filament/Resources/Users is the one place staff act on somebody else’s account:
- the list carries the photo (falling back to drawn initials), name, email, role badges, verification date and a “Deleted” ternary filter that hides tombstones by default; the row actions are impersonate, delete and export, and there is no delete bulk action;
- the form has an account section (password required on create and kept when blank, a roles multi-select shown to administrators alone — whoever else may update people could otherwise hand any role out, their own included; a hidden field is neither drawn nor saved, and an account such a person creates is a student — the language this person reads in where there is more than one, the clock they read their dates on, a verified-at picker), a profile section with the avatar and every profile field whose roles the form has ticked, and the editor sidebar;
- the sidebar’s middle section (
AccountActions) holds the seven controls that do for somebody what they could do for themselves at/account: send a setup link, resend the verification email, reset two-factor, remove every passkey, disconnect a provider, export the person’s data, and view the site as them. Each asks first, says what happened, is offered only while it would do something, asksUserPolicy::update()about the record, and writes its own event into the audit trail; - only an administrator acts on an administrator.
UserPolicy::update()anddelete()refuse anyone else whatever their role holds: a role an installation builds withUpdate:User— support staff, say — could otherwise change an administrator’s address and password and sign in as them. The roles field already kept such a role from handing outadmin; this keeps it from taking an administrator over. The account controls and “Delete account” ask the policy with the record —->authorize('update')and->authorize('delete')— never a bare permission, so the rule reaches them too. - leaving the password blank on the create page is deliberate: the account gets an unusable random one and a setup link, exactly as
ProvisionUserandhilms:user:createdo.
Roles and nobody locked out
Section titled “Roles and nobody locked out”The Roles page under Settings is Shield’s resource, and it is an administrator’s alone: App\Policies\RolePolicy closes every ability, because whoever could edit a role could give their own every permission there is, and the super administrator passes before any policy is asked. The Role permissions are seeded to nobody, and filament-shield.resources.exclude keeps them out of the permission grid.
Nobody locks the installation out of itself. The four roles the code names can be neither renamed nor deleted: AccessServiceProvider refuses it on the role model’s updating and deleting events — keyed data.name, so the panel shows the refusal under the field — and AccessPlugin does not offer their Delete action, because renamed or gone, the super administrator, the panel and sign-up would stop finding them. An administrator cannot take the administrator role off their own account; another administrator can, so the last one can never lose it. hilms:user:role refuses to demote the last administrator, as DeleteAccount refuses to delete one.
Impersonation
Section titled “Impersonation”stechstudio/filament-impersonate on the web guard. User::canImpersonate() is admin alone and User::canBeImpersonated() refuses another administrator and every tombstone, so it is a way down and never across. An administrator lands where that installation’s students land and leaves through filament-impersonate.leave, which returns them to the users list.
Whatever is done while impersonating is recorded as the person being impersonated, in the audit trail and everywhere else: the session is theirs. That is the price of seeing exactly what they see, and it is why the action is offered to administrators alone. The banner above the header is the theme’s own partial — the package’s writes inline styles, which the front-end policy refuses.
Console toolkit
Section titled “Console toolkit”Eight hilms:user:* commands stand behind the panel, on Laravel Prompts; five of them live here. A user is named by id or email; a missing required argument is asked for when the terminal is interactive, and destructive steps confirm unless --force is given.
| Command | Does |
|---|---|
hilms:user:create {email} {name?} {--role=} {--password=} {--language=} |
Provisions an account, assigns one role, sets the language it reads in and mails a setup link unless a password is given |
hilms:user:find {query?} |
Lists up to 20 matching accounts with roles and verification state |
hilms:user:role {user} {role} {--force} |
Gives an account exactly one role |
hilms:user:password {user} {--link} {--force} |
Sets a new password or mails a setup link |
hilms:user:delete {user} {--force} |
Deletes an account through DeleteAccount |
The other three (hilms:user:entitle, hilms:user:revoke, hilms:user:entitlements) belong to the learning module and are described in Learning.
app-modules/access/tests/Feature: registration open and closed, login and logout, password reset — the same answer for an address with an account and without, and a reset form that names none — email verification, two-factor enrolment and challenge, passkeys and the password-confirmation window behind them, OAuth scenarios with Socialite::fake(), a new identity refused for an address its provider does not vouch for, connected accounts, account setup links, profile fields, avatars, the clock a person keeps (PersonClockTest) and the one the panel is read in (PanelClockTest), personal data export, deletion and the last-administrator refusal, panel access per role, the account controls, impersonation, rate limiters and what a refusal says to a form and to a script, and the Users resource including a query-count invariance test and an administrator kept out of reach of a role that is not one.
HiLMS is MIT-licensed. No replicants were harmed in the writing of these books.