Conventions
Code style
Section titled “Code style”- Every PHP file declares
strict_types=1; Pint enforces it together with the Laravel preset. - Laravel 13 attributes replace properties where they exist:
#[Fillable],#[Hidden],#[RouteKey],#[Scope]on models;#[Signature],#[Description]on commands;#[WithoutRelations]and#[DeleteWhenMissingModels]on queued notifications and jobs. - Enums are backed enums. Those shown in the panel implement
Filament\Support\Contracts\HasLabel(andHasColororHasIconwhere it helps) with translated labels. - Comments are rare. A one-line PHPDoc appears only where it adds type information (collection generics, array shapes) or explains a non-obvious rule. No section dividers, no restating the code.
- Domain logic that several entry points share — Fortify, a controller, a Filament action, a console command, an MCP tool — lives in
src/Actionsclasses with a singlehandle(). - Rector keeps the codebase on the newest language and framework idioms; run
bin/composer refactorbefore committing larger changes.
Translations
Section titled “Translations”- Host UI strings are JSON keys in English:
__('Log in'), translated inlang/pl.json. There is nolang/en.json: a missing JSON translation answers with its own key, and a file mapping every key to itself says nothing. - Module strings are PHP arrays:
__('catalog::courses.fields.title')fromapp-modules/catalog/resources/lang/{pl,en}/courses.php. - Framework strings (validation, passwords, pagination) come from laravel-lang in
lang/pl/. - A theme may add strings in its own
lang/{pl,en}.json, underneath the host’s, so it fills gaps and never overrides. A package that speaks fewer languages than the installation is given the missing ones inlang/vendor/<package>/<locale>/. - English is the code’s language and the fallback; which language a visitor reads is the installation’s own list (see Languages). Add both files for every new key:
tests/Feature/TranslationParityTest.phpfails when a module’splandenfiles hold different keys, when an English file has no Polish one beside it, or whenlang/pl.jsonrestates a key instead of translating it. - An installation may correct any phrase the code says on the panel’s Phrases page, which writes a
translation_overridesrow over one line of a file. Nothing inlang/orvendor/is edited, so this never collides with an upgrade. - Content is not translated per field: a course or a page is written again in the other language as a row of its own. Labels — a category name, the branding lines, a profile field’s label — are translated per field.
Eloquent guard rails
Section titled “Eloquent guard rails”AppServiceProvider enables strict models outside production (no lazy loading, no discarded attributes, no missing attributes), automatic eager loading of relationships, and immutable Carbon dates. It also pins the morph map.
Three consequences worth internalising:
- Livewire re-hydrates a model property as a bare model, so a component or an action reached from one must not touch an unloaded relation: query it, or
loadMissing()it explicitly. - Every listing and every page that renders a collection gets a query-count invariance test.
countQueries(Closure)intests/Pest.phpreturns how many queries a closure ran; warm the page once, then assert that N records and N + 9 records cost the same. A page whose eager loads are polymorphic needs a homogeneous dataset, or the count moves with the number of distinct types on screen. - A panel listing over a table that grows with its users opens sorted through an index on that column, or every page of the list sorts the whole table to show ten rows;
tests/Feature/ListingIndexesTest.phplists each one, and a new growing listing joins it with its index in the same change. - Dates are immutable and stored in UTC. Eloquent writes a datetime’s wall clock rather than the instant it names, so a value carrying an offset lands in the column shifted; normalise where the value enters rather than at every call site. Which clock a date is shown and typed in is a separate question with one answer,
Hilms\Access\Support\ViewerTimezone::current()— see Time and clocks.
Publishing and visibility
Section titled “Publishing and visibility”Course::published() is the only definition of “published”: status published and published_at in the past. Controllers ask $course->isVisibleTo($user). Course::canBePreviewedBy() is the preview rule: Update:Course opens every course, while an instructor holding only View:Course opens the courses listed in course_instructor and nothing else.
Page::isVisibleTo() and Page::canBePreviewedBy() are the same shape for pages.
Filament patterns
Section titled “Filament patterns”- Resources live inside modules; labels come from module translations;
navigationSortorders the sidebar. Something an administrator configures goes into Settings, never into the main navigation (Settings). - Tables eager load what their columns need with
modifyQueryUsing, and usecounts()for relation counts. - Editors put their actions in the sidebar. An edit or create page implements
App\Filament\Contracts\HasSidebarActionsand usesApp\Filament\Concerns\ActionsInSidebar, whose emptygetFormActions()is what makes Filament hide the footer. The form then placesSidebarActions::make()at the top of its sidebar group — Save (or Create and “Create another”), Cancel and, where the record has a public address, a “view on site” action — andDangerActions::make()at the very bottom, holding Delete and, for courses, Restore and — for an administrator, on a course nobody was ever granted — Force delete. Both ask the page while the form renders, so neither draws on a page outside the contract or inside a relation manager. Fromlgup the sidebar’s content is sticky and scrolls by itself, so Save never scrolls away; a section cannot stick by itself, so what sticks is the column’s content, named for the stylesheet bySidebarActions. - The one documented exception is the menu builder, which is the package’s own two-column page: Delete and “Places” are header actions there. Delete still never sits beside Save (Navigation).
- A refused save always says so.
App\Filament\FailedSaveNotificationsetsBasePage::$reportValidationErrorUsingfor the whole panel and sends one persistent “Not saved” toast with how many fields need attention and the first three messages. Filament draws a failed rule under its own field and nowhere else, which is silence whenever that field is not on the screen (The block editor). - Another module’s actions reach an editor through slots.
Hilms\Catalog\Filament\EditorActionstakes either a header action (extend($page, $actions)) or one bound to a named slot beside the field it writes (extend($page, $actions, slot: 'summary')). The form renders a slot with->afterLabel(EditorActions::slot('summary'))or->afterHeader(...). A slot answers nothing on a create page: there is no record to write to yet. - Every custom action carries its own
->authorize(). Filament policy-maps resources, never actions. Where the record matters, authorise with the policy’s ability (->authorize('update'), which hands Filament’s record to the policy) rather than a bare permission (->authorize('Update:User'), which looks at nothing but the permission). A guard that needs a query — “is this the last administrator?” — belongs in the action’s->action(), not in->visible(), or the table runs a query per row and its query-count test starts moving. - Nested resources: the child sits under
<Parent>/Resources/<Children>/, declaresprotected static ?string $parentResource, and has onlycreateandeditpages. The parent registers a relation manager withprotected static ?string $relatedResource; the manager reuses the child’sform()andtable(). Route parameters are the inverse relationship names:/admin/courses/{course}/sections/{section}/lessons/{record}/edit. - Sortable models implement
Spatie\EloquentSortable\SortablewithbuildSortQuery()scoped to the parent; Filament’sreorderable('position')callssetNewOrder. - Slugs come from spatie/laravel-sluggable, generated on create and kept on update. Expose a slug field only where humans should edit it.
- A Filament
Getreturns a component’s cast state, so a field shown conditionally on an enum-backed select compares enums:$get->enum('access', CourseAccess::class, isNullable: true), never a string.
Uploads
Section titled “Uploads”Never use a raw Filament upload field. App\Filament\Components\MediaUpload is the only one: it caps maxSize() at the effective system limit, shows that limit as helper text, sniffs the bytes against the declared acceptedFileTypes() (App\Rules\FileContentType), streams the file from Livewire’s temporary file rather than reading it into memory, turns media-library refusals into field errors, and asks the record which disk it belongs on (a ChoosesMediaDisk names its own, anything else lands on media-library.disk_name). Video and audio additionally pass PlayableVideo / PlayableAudio, and fromUrl() adds “Add from a web address” beside the field. Details in Security and privacy.
A file that content shows is a library asset, never an upload on the content. A block, a course cover or anything new that shows a picture, a recording or a download holds an asset id chosen with Hilms\Library\Filament\AssetPicker, records where it shows it through RecordUses, and mints the address with AssetUrl (Media). MediaUpload itself sits only on the library’s own forms and on what stays outside the library: an avatar, the branding.
Settings
Section titled “Settings”An installation-level setting lives in Hilms\<Module>\Settings\<Name>Settings (spatie/laravel-settings), is listed by hand in config/settings.php — nothing is auto-discovered — gets its defaults from a SettingsMigration in the module’s own database/migrations, and is edited on exactly one section of the Settings cluster: a page in the module extending App\Filament\Settings\SettingsSection, whose $navigationGroup is a SettingsGroup case and whose $navigationSort is its place within that group. SettingsSection brings the administrator-only gate, the title and subheading, sticky actions and write-only secrets; a secret is a SecretInput over a property the settings class lists in its own encrypted(), never a second list. Scaffold a section with hilms:make-settings, and let any other page or resource an administrator configures join with the InSettings trait rather than taking an item in the main navigation. Settings is the whole story, including adding one option.
Permissions
Section titled “Permissions”Permissions are Action:Entity strings. Hilms\Access\Support\EntityPermissions::ensure([...]) creates exactly the actions an entity really has and prunes the rest on every seeding, so the Roles UI never offers a permission no policy consults. Five sets cover everything so far:
| Set | Actions |
|---|---|
RECORD |
ViewAny, View, Create, Update, Delete, DeleteAny |
SORTABLE |
record plus Reorder |
SOFT_DELETED |
record plus Restore, RestoreAny, ForceDelete, ForceDeleteAny |
APPEND_ONLY |
ViewAny, View, Create, Update — a trail that is added to and never deleted |
READ_ONLY |
ViewAny, View |
Every policy still declares all twelve methods; the ones outside its entity’s set return false instead of consulting a permission, and tests/Feature/PermissionsTest.php compares the seeded names with the strings in the source in both directions. EntityPermissions::ensureAbilities() covers permissions that guard something other than an entity (View:Horizon and friends). admin needs nothing: Gate::before lets it through — past every policy, too, so a rule an administrator must meet as well (a built-in style is never edited, the active style and the default language never deleted) is enforced by the resource’s canEdit()/canDelete() or the action itself, and never by the policy alone.
- Pest files live in
app-modules/<module>/tests/Feature; host-level tests intests/Feature. - Tests run with
RefreshDatabaseagainsthilms_testingon both engines:bin/pestandbin/pest-mariadb, andbin/gatesruns both before any push, since GitHub tests nothing. MariaDB’s nativeuuidcolumn reformats values and rejects anything that is not canonical, so an identifier compared as a string ischar(36). - Use factories and the module seeders:
$this->seed([RoleSeeder::class, CatalogPermissionSeeder::class]). - Filament pages:
livewire(Page::class)for form and table behaviour, HTTPget()for routing. When one test requests several Filament pages, make each page its own dataset case — Filament caches the original request per app instance and guesses nested URL parameters from it. A nested page such as the lesson editor cannot be mounted throughlivewire()at all: drive it over HTTP and test the action’s own closure directly. - A
livewire()test does not boot the panel. Panel-wide configuration is hung inPanel::boot(), which a request reaches through the persistentSetUpPanelmiddleware, so a mounted component has none of it:FilamentTimezoneanswersconfig('app.timezone')and aDateTimePickersilently works in UTC. CallFilament::bootCurrentPanel()in the setup, or drive the page over HTTP. - Table actions are called with
TestAction::make('name')->table($record); a plain string only matches page-level actions. - A Pest helper function declared at the top of a test file is global to the whole suite, so give it a name nothing else uses.
HiLMS is MIT-licensed. No replicants were harmed in the writing of these books.