Skip to content

Conventions

  • 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 (and HasColor or HasIcon where 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/Actions classes with a single handle().
  • Rector keeps the codebase on the newest language and framework idioms; run bin/composer refactor before committing larger changes.
  • Host UI strings are JSON keys in English: __('Log in'), translated in lang/pl.json. There is no lang/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') from app-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 in lang/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.php fails when a module’s pl and en files hold different keys, when an English file has no Polish one beside it, or when lang/pl.json restates 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_overrides row over one line of a file. Nothing in lang/ or vendor/ 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.

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) in tests/Pest.php returns 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.php lists 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.

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.

  • Resources live inside modules; labels come from module translations; navigationSort orders 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 use counts() for relation counts.
  • Editors put their actions in the sidebar. An edit or create page implements App\Filament\Contracts\HasSidebarActions and uses App\Filament\Concerns\ActionsInSidebar, whose empty getFormActions() is what makes Filament hide the footer. The form then places SidebarActions::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 — and DangerActions::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. From lg up 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 by SidebarActions.
  • 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\FailedSaveNotification sets BasePage::$reportValidationErrorUsing for 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\EditorActions takes 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>/, declares protected static ?string $parentResource, and has only create and edit pages. The parent registers a relation manager with protected static ?string $relatedResource; the manager reuses the child’s form() and table(). Route parameters are the inverse relationship names: /admin/courses/{course}/sections/{section}/lessons/{record}/edit.
  • Sortable models implement Spatie\EloquentSortable\Sortable with buildSortQuery() scoped to the parent; Filament’s reorderable('position') calls setNewOrder.
  • 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 Get returns 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.

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.

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 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 in tests/Feature.
  • Tests run with RefreshDatabase against hilms_testing on both engines: bin/pest and bin/pest-mariadb, and bin/gates runs both before any push, since GitHub tests nothing. MariaDB’s native uuid column reformats values and rejects anything that is not canonical, so an identifier compared as a string is char(36).
  • Use factories and the module seeders: $this->seed([RoleSeeder::class, CatalogPermissionSeeder::class]).
  • Filament pages: livewire(Page::class) for form and table behaviour, HTTP get() 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 through livewire() 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 in Panel::boot(), which a request reaches through the persistent SetUpPanel middleware, so a mounted component has none of it: FilamentTimezone answers config('app.timezone') and a DateTimePicker silently works in UTC. Call Filament::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.