Modules
The Settings Module
Typed application settings — storage, a cache and a screen from the module, and what is configurable from the application.
On this page
Every application ends up with a handful of things somebody wants to change without a deploy. This is the table, the cache and the screen for them.
composer require nyoncode/wire-module-settingsphp artisan wire-module-settings:installphp artisan migrate
How It Works
The module owns storage and a screen; the application owns what is configurable. A package that shipped its own list of settings would be guessing what an application needs, so a group is a class you write:
use NyonCode\WireModuleSettings\Contracts\SettingsGroup; final class BrandingSettings implements SettingsGroup { public static function group(): string { return 'branding'; } public static function label(): string { return __('Branding'); } public static function schema(): array { return [TextInput::make('company_name'), Toggle::make('dark_default')]; }}
// config/wire-module-settings.php'groups' => [App\Settings\BrandingSettings::class],
Values keep their type. The column is JSON, so a boolean comes back a boolean and an array survives — a settings table that stringifies everything makes every reader cast by hand, and they disagree.
A group is cached as one entry. Settings are read on nearly every request and
written almost never; per key would be one lookup per read, per group is one for
a page that reads six. Every write drops the group's entry, including a write
made through the model — a seeder, a data migration, tinker — because
invalidation lives where the write happens rather than only in Settings.
Reads are safe before the table exists, and the answer is not remembered.
A settings call sits in code that also runs during migrate and in a fresh
install, so a missing table answers the default rather than throwing. It is
deliberately not cached: rememberForever means forever, and a deploy that
touched a setting before migrate would otherwise leave the application
answering "empty" until something wrote a value.
A group saves whole or not at all. Settings::fill() is one transaction, and
that is the same promise the screen is built on: it edits one group at a time so
it never has to say "your branding saved but your mail did not", and a loop of
six writes where the fourth fails says exactly that about one group's own fields.
A group is a URL. The route is settings/{record}, so settings/mail opens
mail settings and a person can bookmark it. That is why the switcher is links
rather than tabs — a tab state living in a Livewire snapshot cannot be
bookmarked. A group this application does not declare is a 404 and one this user
may not see is a 403, because the alternative for both is a form with no fields
in it, and empty reads as "there is nothing to configure here".
Reading Settings
use NyonCode\WireModuleSettings\Support\Settings; Settings::get('company_name', 'Acme', 'branding');Settings::set('dark_default', true, 'branding');Settings::fill(['company_name' => 'Acme', 'dark_default' => true], 'branding');Settings::has('dark_default', 'branding');Settings::remove('dark_default', 'branding');Settings::clear('branding');Settings::all('branding');
get() resolves in one order, and it is worth knowing which: the stored value,
then the group's declared default, then the $default you passed. The last one
is the fallback for a key the group never declared at all.
Defaults Belong To The Group
The alternative is the default living at every call site — the same
'noreply@example.com' written in six places, five of them agreeing. A group
declares them once by implementing one more interface:
use NyonCode\WireModuleSettings\Contracts\ProvidesSettingsDefaults;use NyonCode\WireModuleSettings\Contracts\SettingsGroup; final class MailSettings implements SettingsGroup, ProvidesSettingsDefaults{ public static function group(): string { return 'mail'; } public static function label(): string { return __('Mail'); } public static function schema(): array { return [TextInput::make('from_address')->email(), Toggle::make('queue')]; } public static function defaults(): array { return ['from_address' => 'noreply@example.com', 'queue' => true]; } }
Settings::get('from_address', null, 'mail') now answers the declared value, and
so does the screen — the fields have the right values in them the first time
somebody opens it, rather than being a page of empty inputs.
has() is the question the default does not answer: whether anybody actually
stored a value. A data migration auditing the table wants that one.
Icons, Descriptions And Order
A second optional interface, for a group that wants to look like something:
use NyonCode\WireModuleSettings\Contracts\DescribesSettingsGroup; final class MailSettings implements SettingsGroup, DescribesSettingsGroup{ // … public static function icon(): ?string { return 'outline:envelope'; } public static function description(): ?string { return __('Where transactional mail is sent from.'); } public static function sort(): int { return 20; } }
The icon goes beside the group in the switcher, the description under the
heading, and sort() decides the order. Groups that declare no order keep the
one the config listed them in — the sort is stable, so a group that names a
number does not shuffle the ones that say nothing. A single declared group draws
no switcher at all: one group is not a choice.
The page gives a flat schema its card. Every resource form gets its surface
from the Sections the resource declared, and a settings group — whose whole
contract is a heading and a list of fields — is not obliged to declare any;
rendered bare, its inputs would sit directly on the page background, which no
other screen in a panel does. So the page wraps them, and only where the wrapper
is missing: a group that brings its own Section, Grid or Tabs is rendered
as it stands, because a card around a card is a border inside a border and the
group already said where its own edges are.
A Package Can Ship A Tab
groups is the application's list and stays that way — a panel's owner says
what their panel configures. The other half is a package that ships a feature
and the tab that configures it, which otherwise has to end its README with
"now add this class to your config": the one instruction every other surface in
this framework stopped giving. A module registers itself, and so does its
settings.
use NyonCode\LaravelPackageToolkit\Packager;use NyonCode\WireModuleSettings\Support\SettingsRegistry; public function configure(Packager $packager): void{ $packager ->name('AcmeBilling') ->hasShortName('acme-billing') ->hasConfig() ->bootedPackage(function (): void { SettingsRegistry::instance()->register(BillingSettings::class); });}
Nothing else changes: the group is the same SettingsGroup an application
writes, with the same optional contracts, saving into the same table.
Who wins. The application's list is read first, so a group it declares under
the same storage name replaces the contributed one and sorts ahead of it among
groups that name no sort(). A tab it does not want at all goes in except:
// config/wire-module-settings.php'except' => ['billing'],
That escape hatch is the reason a package may ship a tab at all — a package-shipped screen an application cannot remove is what makes people stop installing them.
Register from boot, and it still works from register. Provider order in
a Laravel application is composer's discovery order, not a contract, so a
contributing package's provider may well run before this module's.
SettingsRegistry::instance() binds itself on first touch, so whichever gets
there first creates the one instance and the other finds it — without that, a
registration would land in a throwaway object and the tab would be missing on
some machines depending on a lockfile.
Who May Change What
Two levels, and both are Gate::allows() — nothing here re-implements an
authorization check, so Laravel's own gates, spatie/laravel-permission and
nyoncode/laravel-permission-extended all answer them the way they answer every
other check in this framework.
The screen is guarded by one line of config. It becomes Laravel's can:
middleware on both routes and hides the menu entry that leads to them:
// config/wire-module-settings.php'permission' => 'settings.manage',
Null by default, because a permission this package invented would lock the screen out of every installation that has no such ability. A settings screen is, after the audit log, the one most worth naming one for: it is where an application's behaviour is changed without a deploy.
One group may ask for more than the screen does:
use NyonCode\WireModuleSettings\Contracts\GuardsSettingsGroup; final class BillingSettings implements SettingsGroup, GuardsSettingsGroup{ // … public static function permission(): ?string { return 'settings.billing'; }}
A group the current user fails is not in the switcher, opening its URL is a 403, and so is saving it — the group name is a public property, so it rides in the Livewire snapshot and comes back from the browser, and a user who may open one group must not be able to save another by editing the value that travels.
A user who fails every declared group gets a 403 for the screen too, rather than the empty state: that one says "declare a SettingsGroup class and list it in config", which is an instruction for the developer and a lie to everybody else. An application that has genuinely declared nothing still sees it.
Reacting To A Change
Settings are the values something else is configured from, so changing one
usually has to reach something. SettingsSaved is dispatched after every write:
use Illuminate\Support\Facades\Event;use NyonCode\WireModuleSettings\Events\SettingsSaved; Event::listen(SettingsSaved::class, function (SettingsSaved $event): void { if ($event->group === 'mail') { Cache::forget('mail-transport'); }});
$event->values is what this write carried, not the whole group; a listener that
wants the rest asks Settings::all(), which by then answers with these in it.
Where It Is Cached
// config/wire-module-settings.php'cache' => [ 'enabled' => env('WIRE_SETTINGS_CACHE', true), 'store' => env('WIRE_SETTINGS_CACHE_STORE'),],
The store matters more than it looks. Leaving it null uses the application's
default one, and on an application whose default is database the read this
cache exists to avoid is simply replaced by a different query. Name the memory
store you already run and a settings read stops touching the database — the
installer says so when it finds the default is database.
Turning caching off is for debugging and for tests that assert against the table directly.
The Rest Of The File
Four keys, each covered by a section above except the two at the bottom:
// config/wire-module-settings.php'groups' => [], // your own group classes — see How It Works'except' => [], // storage names of contributed tabs to drop 'permission' => null, // the ability the screen requires — see Who May Change What'table' => 'wire_settings', 'navigation' => [ 'group' => 'system', 'label' => null, // null uses the module's own group heading 'icon' => 'outline:cog-6-tooth', 'sort' => 97,],
table is there for an application that already had a settings table when this
one arrived; the module reads and writes only the one it is told about.
navigation decides where the entry sits in the menu, not who may click it —
that is permission.
The wording is a published translation file and the markup a published view —
wire-module-settings::translations and …::views, with what each costs in
Theming → Localization and
Overriding Views.
Extended Example
A group that uses all three optional contracts, and something reading it:
namespace App\Settings; use App\Support\Branding;use Illuminate\Support\Facades\Event;use NyonCode\WireForms\Components\TextInput;use NyonCode\WireForms\Components\Toggle;use NyonCode\WireModuleSettings\Contracts\DescribesSettingsGroup;use NyonCode\WireModuleSettings\Contracts\GuardsSettingsGroup;use NyonCode\WireModuleSettings\Contracts\ProvidesSettingsDefaults;use NyonCode\WireModuleSettings\Contracts\SettingsGroup;use NyonCode\WireModuleSettings\Events\SettingsSaved;use NyonCode\WireModuleSettings\Support\Settings; final class BrandingSettings implements DescribesSettingsGroup, GuardsSettingsGroup, ProvidesSettingsDefaults, SettingsGroup{ public static function group(): string { return 'branding'; } public static function label(): string { return __('settings.branding'); } public static function schema(): array { return [ TextInput::make('company_name')->label(__('settings.company_name'))->required(), TextInput::make('support_email')->label(__('settings.support_email'))->email(), Toggle::make('dark_by_default')->label(__('settings.dark_by_default')), ]; } public static function icon(): ?string { return 'outline:swatch'; } public static function description(): ?string { return __('settings.branding_description'); } public static function sort(): int { return 10; } public static function defaults(): array { return ['company_name' => config('app.name'), 'dark_by_default' => false]; } public static function permission(): ?string { return 'settings.branding'; } }
// A view composer, a mailable, a PDF header — anywhere the value is needed.Settings::get('company_name', group: 'branding'); // And the cache something else keeps, dropped when the value moves.Event::listen(SettingsSaved::class, function (SettingsSaved $event): void { if ($event->group === 'branding') { Branding::flush(); }});