Every screen under Settings is described in PHP and rendered by one generic view. You add a screen (or a tab on an existing one) by describing sections, pages and fields — no controller, route or view needed.
SettingsManager::add([...]) -> one SECTION = one URL /admin/settings/{id}
'pages' => [ [ 'id','name','title','fields'=>[...] ] ] -> PAGES = tabs of that screen
'fields' => [ ['type'=>'text', 'name'=>..., ...] ] -> FIELDS = inputs (storage decided by 'group')
Saved by Admin\SettingsController::update(): validate all rules of the section, then write each field
to the backend chosen by its group, then flush the response cache.Modules/Testimonials/app/Helpers/TestimonialsSettings.php (excerpt)
SettingsManager::add([
'id' => 'testimonials', // URL: /admin/settings/testimonials
'title' => __('testimonials::testimonials.settings.title'),
'link' => [ // sidebar entry
'id' => 'settings', // parent group (the "Settings" entry)
'sub_menu' => [
'id' => 'testimonials-settings',
'label' => __('testimonials::testimonials.settings.title'),
'type' => 'testimonials', // = the section id
'priority' => 40,
],
],
'pages' => fn (): array => [self::page()], // a closure = built lazily
]);| Key | Meaning |
|---|---|
id | Section id and URL segment. Re-using an existing id appends your pages to that section: a plugin can add a tab to General, Mail or Theme options without touching core |
title | Heading of the left tab column |
link | Sidebar entry. With sub_menu it becomes a child of the group link.id (created if missing). Without it use link: ['id' => 'my-group', 'label' => ..., 'icon' => ..., 'type' => 'section-id'] or 'route' + 'params' for a custom target. Links are only added for authenticated users |
pages | Array of page definitions or a closure returning one. The closure form defers building every field (some contain database queries) until the screen is opened — the single most important performance habit (core measured ~30 ms of an ~80 ms admin request) |
| Key | Meaning |
|---|---|
id | DOM id of the tab pane (unique within the screen) |
name | Tab label (a left-hand tab appears when the section has two or more pages) |
title | Card header text |
description | Optional description |
order | Sort position (default: registration order × 10) |
fields | Array of field definitions |
| Key | Type | Meaning |
|---|---|---|
type | string | One of the types below (required, otherwise the field is silently dropped and the problem is logged) |
id | string | HTML id and error key. Required |
name | string | The request input name and the settings key (unless map is set). Use snake_case prefixed with your alias. Must be set (required) |
label | string | Label (passed through trans()) |
helpTooltip / help | string | Tooltip beside the label / small text below the input (help may contain HTML) |
group | string | Storage backend: settings (default, database), env, uploads, theme, static (display only) |
map | string | For env fields, the .env key to write (e.g. MAIL_HOST); for settings fields, a different storage key than name |
value / selected | mixed | Current value shown. Text-like fields use value; select, image-select and font use selected |
validation | array|string | Laravel rules. The rules of all pages of a section are validated together on save |
itemValidation | array|string | (select, multiple) rules applied to each selected value |
condition, conditionValue | string, mixed | Show this field only while another field's value matches (see below) |
conditions | array | [['name' => 'field', 'value' => 'x'], ...] — show only when all match |
required, disabled, readonly, placeholder, class, order | — | Plain HTML attribute helpers |
type | Class | Extra keys | Stored value |
|---|---|---|---|
text | Text | type (HTML input type, e.g. email) | string |
number | Number | min, max, step | string/number |
range | Range | min, max, step | number |
password | Password | — | string (plain! use for env/credentials only when you accept that) |
textarea | Textarea | rows | string |
hidden | Hidden | — | string |
select | Select | options (map, or list of arrays/models with optionKey + optionValue), multiple, selected | scalar, or array when multiple |
image-select | ImageSelect | as select + images (value → image URL) | scalar |
font | Font | as select; options default to the Google Fonts catalogue | font family |
radio | Radio | options (map), default, color | option key |
checkbox | Checkbox | options, selected, default, checked, value | depends on usage |
switch | SwitchBox | value (the "on" value), checked (bool), default (the saved value when absent from the request), color | value or default |
color | Color | — (a colour picker; validate with a hex regex) | string |
spacing | Spacing | value = array top/right/bottom/left | array |
file | File | accepted, preview, delete (bool), deleteName; use group => 'uploads' | public path, e.g. /storage/uploads/logo.png |
heading | Heading | heading, tag, class | (display only) |
alert | Alert | message, color, dismiss | (display only) |
ajax-button | AjaxButton | text, route (URL), method, confirm, color, outline, width | (display only; the endpoint returns {success, message, reload?}) |
Closed list
The registry of types is rebuilt for every field (new Fields), so there is no hook to register a custom type from a plugin. Compose the 18 types, use an ajax-button to trigger custom actions, or put richer UI on your own admin page. (EncryptedPassword and ProviderModelSelect classes exist for the AI settings but are not registered in the list.)
Never use a bare word as a label
Labels go through trans(). A label that equals a language file name ('Pages', 'Settings', 'Admin', 'Widgets'…; matching is case-insensitive on Windows and Mac) makes trans() return the whole file as an array and the screen dies with a TypeError. Always use a namespaced key (__('myplugin::strings.label')) or a multi-word sentence.
group key)group | Written by | Read with | Notes |
|---|---|---|---|
settings (default) | Setting::set($key, $value) then one Setting::save() if anything changed | setting('key', $default) | Only changed values are written. Arrays (multi-selects) are stored and read back as arrays. Unchecked switch/checkbox fields (absent from the request) get their default |
env | DotenvEditor::setKey($map ?: $name, $value), mirrored into settings under name | config() / env() in config files, or setting('name') | Triggers dotartisan:optimize (rebuilds caches). Use for values that must be present at boot (mail driver, app name). Never put per-request data here |
uploads | fileUpload($file, 'uploads'); stores the returned path | setting('name') | Send delete_{name} to clear. Uploaded files live on the configured public disk under uploads/ |
theme | ThemeOptionStore::put() | theme_option('key') | Used by the generated Theme options screen. name is theme[key] |
static | nothing | — | Headings, alerts and buttons; excluded from validation |
['type' => 'select', 'id' => 'pg_mode', 'name' => 'pg_mode', 'selected' => setting('pg_mode', 'a'),
'options' => ['a' => 'Alpha', 'b' => 'Beta'], 'label' => __('pg::s.mode'), 'validation' => ['required', 'in:a,b']],
['type' => 'text', 'id' => 'pg_beta_note', 'name' => 'pg_beta_note', 'label' => __('pg::s.note'),
'value' => setting('pg_beta_note'),
'condition' => 'pg_mode', // the NAME of another field on the same screen
'conditionValue' => 'b', // string, list ('smtp,failover' style or array) or true
'validation' => ['nullable', 'string']],The wrapper renders data-conditional-name/data-conditional-value (or data-conditional-all for conditions) and admin/assets/js/conditional.js shows or hides the row live as the controlling field changes. Conditions are display-only: validation still applies, so use nullable or required_if:pg_mode,b (the Mail settings do exactly that).
setting('testimonials_default_limit', 6); // helper; second argument is the default
\Akaunting\Setting\Facade::get('key', 'default'); // same thing through the facade
// Writing from your own code
Setting::set('testimonials_default_limit', 12);
Setting::save();
ResponseCacheInvalidator::flush(); // if public pages depend on itsetting() can throw. Core code guards with config('dotartisan.installed').setting('key', $default)) and in your config file, so the plugin works before an administrator ever opens the screen.php — inside an AdminBooting listener
SettingsManager::add([
'id' => 'general', // re-use the core section id: pages are appended
'pages' => fn (): array => [[
'id' => 'tab-pg-extra',
'name' => __('pg::s.tab'),
'title' => __('pg::s.tab_title'),
'fields' => [[
'type' => 'text', 'id' => 'pg_extra', 'name' => 'pg_extra',
'label' => __('pg::s.extra'), 'value' => setting('pg_extra'),
'validation' => ['nullable', 'string', 'max:20'],
]],
]],
]);use App\Models\Menu;
use App\Models\Role;
['type' => 'select', 'id' => 'pg_menu', 'name' => 'pg_menu', 'label' => __('pg::s.menu'),
'selected' => setting('pg_menu'), 'options' => Menu::get(), 'optionKey' => 'id', 'optionValue' => 'name',
'validation' => ['nullable', 'exists:menus,id']],
['type' => 'select', 'id' => 'pg_roles', 'name' => 'pg_roles', 'label' => __('pg::s.roles'), 'multiple' => true,
'selected' => (array) setting('pg_roles', []),
'options' => Role::select(['id', 'name'])->get(), 'optionKey' => 'id', 'optionValue' => 'name',
'validation' => ['nullable', 'array'], 'itemValidation' => ['integer', 'exists:roles,id']],For "which CMS page is my Shop page" do not hand-roll a select; use AppPages, which generates the select and keeps the setting key.
Settings are stored in clear text in the database and .env. For API keys: use the password type (it only masks the input in the browser; the saved value is still rendered into the page source), restrict the screen with manage settings, never log values, and prefer environment variables set at the server level for production. The AI provider keys use an encrypting store (PageBuilderAiSecretStore); mimic it if you need encryption at rest.
The Theme options screen is a settings section whose pages are generated from the active theme's theme.json (ThemeOptionsSchema::pages()), with every field in group theme. You never register it by hand; add options to theme.json instead — see Theme options and CSS pipeline.
it('stores the testimonials settings', function (): void {
$user = userWithPermissions(['manage settings']);
$this->actingAs($user)
->get(route('admin.settings', ['type' => 'testimonials']))
->assertOk()
->assertSee('testimonials_default_limit');
$this->actingAs($user)
->post(route('admin.settings.update', ['type' => 'testimonials']), ['testimonials_default_limit' => 2])
->assertRedirect();
expect((int) setting('testimonials_default_limit'))->toBe(2);
});