Settings
Everything an administrator configures lives in one place: Settings, a Filament cluster at /admin/settings. The main navigation carries one item for it, and inside it a grouped sub-navigation on the left lists the sections: Branding, Styles, Site pages, Languages and Region and time under Site; Sign-up, Profile fields and Roles and permissions under People; Media and storage, Mail, AI assistants and API clients under Integrations. It opens on an overview with a search field.
The point of this chapter is the last section of it: adding a section, or an option to one, is meant to take minutes, and the guard tests say when something was forgotten.
Where the pieces are
Section titled “Where the pieces are”The kernel is host code, because every module that configures anything uses it and no module should own the others’ navigation:
| Piece | Where |
|---|---|
| The cluster | App\Filament\Settings\SettingsCluster, registered in AdminPanelProvider |
| The groups | App\Filament\Settings\SettingsGroup: Site, People, Integrations, in case order |
| Joining the cluster | App\Filament\Settings\InSettings, a trait for any page or resource |
| A section over a spatie settings class | App\Filament\Settings\SettingsSection |
| The overview | App\Filament\Settings\SettingsOverview, its view resources/views/filament/settings/overview.blade.php, styled in resources/css/panel.css |
| One section as the overview sees it | App\Filament\Settings\SettingsEntry |
| The generator | App\Console\Commands\MakeSettingsCommand (hilms:make-settings), stubs in stubs/settings |
| The registry of settings classes | config/settings.php, listed by hand |
| Write-only secrets | App\Filament\Components\SecretInput, App\Filament\Concerns\KeepsStoredSecrets, App\Filament\Contracts\HoldsSecrets |
The sections themselves stay in their modules: ManageBranding and StyleResource in theme, ManageSitePages in pages, LanguageResource and ManageRegion in languages, ManageAccessSettings and ManageProfileFields in access, ManageMediaSettings and ManageMailSettings in ops, ManageAiSettings in ai, ApiClientResource in api, and Shield’s own RoleResource.
The cluster
Section titled “The cluster”SettingsCluster is Filament’s own Cluster, slug settings, with SubNavigationPosition::Start (Filament folds the sub-navigation into a dropdown on a phone). Its navigation sort is 1000, and Filament always lists ungrouped items above groups, so Settings is the last ungrouped item of the main navigation and the Monitoring group sits below it.
Two things are not Filament’s defaults:
- The item and the address both answer for who may use them.
canAccess()iscanAccessClusteredComponents(): the item hides itself from somebody who may open no section, and/admin/settingsanswers such a person with 403. Filament would otherwise hide the item and still draw an empty page at the address. - It opens on the overview.
Cluster::mount()redirects to the first page it lists, andSettingsOverviewis ungrouped with sort 0, so that is the overview at/admin/settings/overview.
SettingsCluster::sections() is every page and resource in the cluster except the overview, and accessibleSections() those the signed-in person may open. The overview and the guard tests both read them, so nothing keeps a second list of sections.
Groups and order
Section titled “Groups and order”A section names its group through Filament’s own $navigationGroup, set to a SettingsGroup case, and its place within the group through $navigationSort. InSettings::getNavigationSort() turns the two into one number, SettingsGroup::sort(): the group’s position times 100, plus the place within it. The order of the cases is therefore the order of the groups, and a section’s sort only has to be unique inside its own group. The existing sections count in tens (10, 20, 30, 40), which leaves room between them; Styles sits at 15, between Branding and Site pages.
The group labels are host strings (__('Site'), __('People'), __('Integrations'), translated in lang/pl.json). A new group is a new case with a label and a line in lang/pl.json, plus its entry in the sub-navigation SettingsStructureTest spells out. The generator accepts any case of the enum through --group.
A section: InSettings and SettingsSection
Section titled “A section: InSettings and SettingsSection”InSettings is the whole contract for joining the cluster:
getCluster()answersSettingsCluster;getNavigationSort()computes the sort described above;getDescription()is abstract: the one line under the section’s title and on its card in the overview;getKeywords()answers an empty list unless the section adds search terms of its own.
SettingsSection is the base of every section over a spatie settings class. It extends Filament’s SettingsPage, uses InSettings and KeepsStoredSecrets, implements HoldsSecrets, and adds what every such section needs:
canAccess()lets administrators in and nobody else;- the navigation label is the title and the description is the subheading;
- the form actions are sticky, so Save stays in view on a long section;
- the content width is
7xl.
A section therefore declares only what is its own: $settings, its icon, group, sort and $slug, getNavigationLabel(), getDescription() and form(). ManageAccessSettings is the smallest complete example: one toggle, under fifty lines.
Every section has an address that names what it holds: /admin/settings/mail, /branding, /sign-up, /site-pages, /region, /profile-fields, /media, /ai. A page sets $slug; a resource gets its slug from the model (languages, api-clients); Shield’s resource gets roles from its configuration. Filament would otherwise derive a page’s slug from its class name, and a test refuses anything starting with manage-.
Adding a section
Section titled “Adding a section”Scaffold it. The generator writes every file a section needs and registers the settings class:
bin/artisan hilms:make-settings Certificates --module=learning --group=people| File | Holds |
|---|---|
src/Settings/CertificatesSettings.php |
The spatie settings class, group certificates, with one example switch, enabled |
database/migrations/<next>_create_certificates_settings.php |
A SettingsMigration adding certificates.enabled with its default, numbered right after the module’s latest migration |
src/Filament/Pages/ManageCertificates.php |
A SettingsSection in SettingsGroup::People, sort 90, slug certificates, with a toggle for the switch |
resources/lang/{en,pl}/certificates.php |
title, description and the field’s label and help, in both languages |
tests/Feature/CertificatesSettingsTest.php |
An administrator opens the section and saves it; an editor and an instructor are refused |
and adds CertificatesSettings to config/settings.php, both the use line (kept in alphabetical order) and the entry in the list. The name may be given with or without Settings at the end.
The command checks everything before it writes anything: the module and the group must exist, the class must not be registered already, the settings group (certificates) must not be taken by another class, none of the files may exist, and config/settings.php must still have the shape it knows — one use line per class and one 'settings' => [ … ] list. A registry of any other shape is refused with a message saying to add the class by hand.
Then finish the section:
- Replace the example switch with the section’s real options (next section).
- Write the description in both language files. It is what the overview shows and searches, so make it say what the section decides, not what it is.
- Pick the section’s place within its group (
$navigationSort) and check its icon. - Add its label to the expected sub-navigation in
tests/Feature/SettingsStructureTest.php. The generator’s last line says so, and the test fails until it is done. - Run the generated test and
SettingsStructureTest.
The module’s provider needs nothing: the module’s Filament plugin already discovers src/Filament/Pages, and getCluster() puts the page into Settings.
Adding an option
Section titled “Adding an option”An option is four small changes, and the tests catch each one that is missed.
1. A property on the settings class.
public int $valid_for_days;An array property writes its shape under @phpstan-var, never a plain @var: spatie’s settings resolver reads the @var line, cannot parse an array shape or a mixed value type, and refuses to boot the class when it finds one. ProfileSettings::$fields is the example.
2. Its default in a settings migration. A new migration in the module, rather than a line added to the one that created the group, because an installation that already ran the first migration never runs it again:
return new class extends SettingsMigration{ public function up(): void { $this->migrator->add('certificates.valid_for_days', 365); }
public function down(): void { $this->migrator->delete('certificates.valid_for_days'); }};A default read from the environment is the pattern for anything an operator could already configure: the mail and media migrations seed every value from what config() reads, so the first save changes nothing, and a blank value keeps falling back to the environment where the bridge that applies the settings says so (MailConfiguration, MediaConfiguration, AiConfiguration). A property with no default does not load: SettingsStructureTest builds every registered class on a freshly migrated database and names the one that fails.
3. A field in the section’s form(), named exactly like the property, with explicit validation (required(), integer(), minValue(), an allowlist for a choice). Nothing typed into Settings is trusted any more than anything else a person sends.
4. Its words in both languages: the label and, where it helps, a helper text under the section’s own file (learning::certificates.fields.valid_for_days). TranslationParityTest fails when the Polish and English files hold different keys.
Code reads the option by resolving the settings class, app(CertificatesSettings::class)->valid_for_days, and a value read on every request belongs in a cached snapshot of scalars forgotten when the section is saved, the way Region is (Time and clocks). The settings themselves are cached by spatie when SETTINGS_CACHE_ENABLED is on, and MigrationsEnded clears that cache, so a migration that adds a property never meets an object read before it.
Secrets
Section titled “Secrets”A secret needs nothing from the section beyond the field:
-
List the property in the settings class’s
encrypted():public static function encrypted(): array{return ['api_key'];} -
Add it with
addEncrypted()in the migration. -
Draw it with
SecretInput::make('api_key'), and addSecretInput::remover('api_key')if the stored value may be cleared.
SettingsSection already carries KeepsStoredSecrets, which reads encrypted() and nothing else — the settings class is the one list of secrets. It blanks every secret as the form fills, keeps what is stored when the field is left blank, replaces it when something is typed, clears it when the remover is on, and empties the field again after a save, so the save’s own response never carries the secret back to the browser. For a class that encrypts nothing it does nothing, which is why no section has to remember to add it.
SecretInput is a password field that never reveals, says under itself when a secret is stored, and carries autocomplete="new-password": Chrome ignores off on a password field and fills in the administrator’s own panel login, which Save would then store as the SMTP password. Security and privacy has the rest.
A page or a resource that is not a spatie section
Section titled “A page or a resource that is not a spatie section”Branding, Styles, Languages and API clients are not settings classes: Branding is one form over the one branding row, and the other three are resources over tables. They join with the trait alone:
class ManageBranding extends Page{ use InSettings;
protected static string|UnitEnum|null $navigationGroup = SettingsGroup::Site;
protected static ?int $navigationSort = 10;
protected static ?string $slug = 'branding';
public static function getDescription(): string { return __('theme::branding.description'); }}Such a page or resource keeps its own gate: InSettings decides where it is listed, never who may open it. ManageBranding answers canAccess() for administrators itself, and the three resources answer through their policies, whose permissions nobody but the administrator holds. A guard an administrator must meet too — a built-in style is never edited, the active style never deleted — is the resource’s own canEdit()/canDelete(), because Shield’s super admin never consults a policy (Styles).
A package’s resource cannot use a trait of ours. Shield’s RoleResource joins through its own configuration — filament-shield.shield_resource.cluster set to SettingsCluster, slug roles — and its group, sort and the label “Roles and permissions” are set on FilamentShieldPlugin in AdminPanelProvider. It cannot describe itself either, so its one line is supplied by SettingsEntry::describe(). A second package resource would join the same way.
The overview and its search
Section titled “The overview and its search”SettingsOverview draws an h2 per group and a card per section — its icon, an h3 holding the one link, stretched over the whole card, and its description — built from accessibleSections() through SettingsEntry::of(). A new section appears on it without anybody touching the overview.
The search field is labelled, live and debounced, and the filtering happens on the server: SettingsEntry::matches() requires every word typed to appear in the section’s label, description, group label or keywords, compared case- and accent-insensitively (Str::ascii()), so “poczta” and “Poczta” find the same section, “jezyki” finds “Języki”, and “bucket” finds “Media and storage” through its keywords. The number found is announced in a status live region, and nothing matching shows an empty state saying so.
Keywords are for words a person would search with that appear nowhere in the label or the description. ManageMediaSettings is the one section that needs them — nobody calls a bucket “media and storage” — and reads them from its language file as a comma-separated line (ops::media.keywords), so they are translated like every other string:
public static function getKeywords(): array{ return array_map(trim(...), explode(',', (string) __('ops::media.keywords')));}The overview opens to anybody who may open at least one section, and to nobody else.
Guard tests
Section titled “Guard tests”| Test | Catches |
|---|---|
tests/Feature/SettingsStructureTest.php |
A settings page the panel registers that extends SettingsPage instead of SettingsSection; a class in config/settings.php edited on no section, or on two, and a section editing a class the registry does not list; a registered class that does not load on a freshly migrated database — a property with no migration default, an unparsable @var; a section with no SettingsGroup or no description; a section’s label appearing in the main navigation, or Settings not being its last ungrouped item; a sub-navigation other than the exact list it spells out, group by group; the Settings item or address reaching an editor or an instructor; an address that does not name its section (manage-…) |
tests/Feature/SettingsAccessTest.php |
A change in who may open any of the eleven sections: administrators are let in everywhere, editors and instructors refused everywhere |
tests/Feature/SettingsOverviewTest.php |
The overview’s own behaviour: it is where Settings opens, it lists every section a person may open under its group, the search finds by label, description and keyword whatever the case or the accents, and it says so when nothing matches |
tests/Feature/MakeSettingsCommandTest.php |
The generator: every file it writes, the registry kept in order, and its refusals — an unknown group, a settings group already taken, a registry of another shape — leaving nothing behind. It scaffolds into a directory and a copy of the registry of its own, never into the checkout, because a parallel run boots every worker from the same files |
The generated test of each new section covers that section’s gate and a save; the sub-navigation list in SettingsStructureTest is the one line a person has to extend by hand, and it is deliberate: it makes the order of Settings something a reviewer sees change.
HiLMS is MIT-licensed. No replicants were harmed in the writing of these books.