structure changes

This commit is contained in:
Mark Gregory
2026-07-27 14:17:30 +01:00
parent bdb5e7825d
commit df5d6077cb
13 changed files with 468 additions and 277 deletions
@@ -3,25 +3,79 @@
namespace Modules\Settings\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Modules\Settings\Models\Setting;
use Illuminate\View\View;
use Modules\Settings\Services\SettingsRepository;
final class SettingController extends Controller
{
public function index()
public function index(SettingsRepository $settings): View
{
$settings = Setting::query()->orderBy('group')->orderBy('key')->get();
return view('settings::index', compact('settings'));
return view('settings::index', [
'values' => $settings->site(),
'sections' => $this->sections(),
]);
}
public function update(Request $request)
public function update(Request $request, SettingsRepository $settings): RedirectResponse
{
$data = $request->validate(['settings' => ['array'], 'settings.*' => ['nullable', 'string', 'max:5000']]);
foreach ($data['settings'] ?? [] as $id => $value) {
Setting::whereKey($id)->update(['value' => $value]);
$rules = [];
foreach ($this->sections() as $fields) {
foreach ($fields as $key => $definition) {
$rules["settings.$key"] = $definition['rules'];
}
}
return back()->with('status', 'Settings saved.');
$validated = $request->validate($rules);
foreach ($validated['settings'] ?? [] as $key => $value) {
$settings->put($key, $value, 'site', 'string', true);
}
return back()->with('status', 'Site settings saved. The frontend has been updated everywhere.');
}
/** @return array<string, array<string, array<string, mixed>>> */
private function sections(): array
{
$text = ['type' => 'text', 'rules' => ['nullable', 'string', 'max:255']];
$url = ['type' => 'text', 'rules' => ['nullable', 'string', 'max:2048'], 'placeholder' => '/path or https://example.com'];
$textarea = ['type' => 'textarea', 'rules' => ['nullable', 'string', 'max:5000']];
return [
'General' => [
'site_name' => $text + ['label' => 'Site name', 'help' => 'Used in the header, footer, browser titles and metadata.'],
'tagline' => $text + ['label' => 'Tagline'],
'site_description' => $textarea + ['label' => 'Site description'],
'company_name' => $text + ['label' => 'Legal company name'],
'site_url' => $url + ['label' => 'Canonical site URL'],
'copyright' => $text + ['label' => 'Copyright text', 'help' => 'Leave blank to generate it automatically.'],
],
'Branding' => [
'logo_url' => $url + ['label' => 'Primary logo'],
'logo_dark_url' => $url + ['label' => 'Dark-mode logo'],
'favicon_url' => $url + ['label' => 'Favicon'],
],
'Contact' => [
'contact_email' => ['type' => 'email', 'rules' => ['nullable', 'email', 'max:255'], 'label' => 'Public email'],
'contact_phone' => $text + ['label' => 'Public telephone'],
'address' => $textarea + ['label' => 'Address'],
],
'Social' => [
'facebook_url' => $url + ['label' => 'Facebook'],
'x_url' => $url + ['label' => 'X / Twitter'],
'linkedin_url' => $url + ['label' => 'LinkedIn'],
'instagram_url' => $url + ['label' => 'Instagram'],
],
'Account navigation' => [
'dashboard_label' => $text + ['label' => 'Dashboard label'],
'login_label' => $text + ['label' => 'Login label'],
'registration_label' => $text + ['label' => 'Registration label'],
],
'SEO defaults' => [
'default_meta_title' => $text + ['label' => 'Default meta title', 'help' => 'Leave blank to use the site name.'],
'default_meta_description' => $textarea + ['label' => 'Default meta description', 'help' => 'Leave blank to use the site description.'],
],
];
}
}
@@ -1 +1,49 @@
<x-layouts.app :title="__('Settings')"><div class="max-w-4xl space-y-6"><div><h1 class="text-2xl font-semibold">Platform settings</h1><p class="mt-1 text-sm text-zinc-500">Shared configuration stored by the Settings module.</p></div>@if(session('status'))<div class="rounded-xl bg-emerald-50 p-3 text-sm text-emerald-800">{{ session('status') }}</div>@endif<form method="POST" action="{{ route('administration.settings.update') }}" class="space-y-4">@csrf @method('PUT')@forelse($settings->groupBy('group') as $group=>$items)<section class="rounded-xl border border-zinc-200 bg-white p-5 dark:border-zinc-700 dark:bg-zinc-900"><h2 class="mb-4 font-semibold capitalize">{{ $group }}</h2><div class="space-y-4">@foreach($items as $setting)<flux:input name="settings[{{ $setting->id }}]" :label="str($setting->key)->replace('_',' ')->title()" :value="$setting->value"/>@endforeach</div></section>@empty<div class="rounded-xl border border-dashed border-zinc-300 p-10 text-center text-sm text-zinc-500">No settings have been registered yet. SEO defaults can be configured from the SEO screen.</div>@endforelse<div class="flex justify-end"><flux:button type="submit" variant="primary">Save settings</flux:button></div></form></div></x-layouts.app>
<x-layouts.app :title="__('Site settings')">
<div class="mx-auto max-w-6xl space-y-6">
<div class="flex flex-wrap items-end justify-between gap-4">
<div>
<p class="text-sm font-medium text-orange-600">Single source of truth</p>
<h1 class="text-3xl font-semibold">Site settings</h1>
<p class="mt-2 max-w-3xl text-sm text-zinc-500">
Global identity, branding, contact details and SEO are entered once here and reused by every theme, page, header and footer.
</p>
</div>
<a href="{{ route('administration.menus.index') }}" class="rounded-lg border border-zinc-300 px-4 py-2 text-sm font-medium dark:border-zinc-700">Manage menus</a>
</div>
@if (session('status'))
<div class="rounded-xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-200">{{ session('status') }}</div>
@endif
<form method="POST" action="{{ route('administration.settings.update') }}" class="space-y-6">
@csrf
@method('PUT')
@foreach ($sections as $section => $fields)
<section class="overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<div class="border-b border-zinc-200 px-6 py-4 dark:border-zinc-700">
<h2 class="text-lg font-semibold">{{ $section }}</h2>
</div>
<div class="grid gap-5 p-6 md:grid-cols-2">
@foreach ($fields as $key => $field)
<label class="block {{ ($field['type'] ?? '') === 'textarea' ? 'md:col-span-2' : '' }}">
<span class="text-sm font-medium">{{ $field['label'] }}</span>
@if (! empty($field['help']))<span class="mt-1 block text-xs text-zinc-500">{{ $field['help'] }}</span>@endif
@if (($field['type'] ?? 'text') === 'textarea')
<textarea name="settings[{{ $key }}]" rows="4" class="mt-2 w-full rounded-xl border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950">{{ old("settings.$key", $values[$key] ?? '') }}</textarea>
@else
<input type="{{ $field['type'] ?? 'text' }}" name="settings[{{ $key }}]" value="{{ old("settings.$key", $values[$key] ?? '') }}" placeholder="{{ $field['placeholder'] ?? '' }}" class="mt-2 w-full rounded-xl border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950">
@endif
@error("settings.$key")<span class="mt-1 block text-sm text-red-600">{{ $message }}</span>@enderror
</label>
@endforeach
</div>
</section>
@endforeach
<div class="sticky bottom-4 flex justify-end rounded-2xl border border-zinc-200 bg-white/95 p-4 shadow-xl backdrop-blur dark:border-zinc-700 dark:bg-zinc-900/95">
<flux:button type="submit" variant="primary">Save site settings</flux:button>
</div>
</form>
</div>
</x-layouts.app>
@@ -3,20 +3,127 @@
namespace Modules\Settings\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Schema;
use Modules\Settings\Models\Setting;
final class SettingsRepository
{
public function get(string $key, mixed $default = null, string $group = 'general'): mixed
{
return Cache::rememberForever("settings.$group.$key", fn () => Setting::query()->where(compact('group', 'key'))->value('value') ?? $default);
if (! $this->tableExists()) {
return $default;
}
$setting = Cache::rememberForever(
"settings.$group.$key",
fn (): ?Setting => Setting::query()
->where([
'group' => $group,
'key' => $key,
])
->first()
);
return $setting ? $this->decode($setting->value, $setting->type) : $default;
}
/** @return array<string, mixed> */
public function group(string $group): array
{
if (! $this->tableExists()) {
return [];
}
return Cache::rememberForever("settings.group.$group", function () use ($group): array {
return Setting::query()
->where('group', $group)
->get()
->mapWithKeys(fn (Setting $setting): array => [
$setting->key => $this->decode($setting->value, $setting->type),
])
->all();
});
}
/** @return array<string, mixed> */
public function site(): array
{
$defaults = [
'site_name' => config('app.name', 'Content Manager'),
'tagline' => '',
'site_description' => 'Modern software for modern companies.',
'company_name' => config('app.name', 'Content Manager'),
'site_url' => url('/'),
'logo_url' => '',
'logo_dark_url' => '',
'favicon_url' => '/favicon.ico',
'contact_email' => '',
'contact_phone' => '',
'address' => '',
'facebook_url' => '',
'x_url' => '',
'linkedin_url' => '',
'instagram_url' => '',
'copyright' => '',
'dashboard_label' => 'Dashboard',
'login_label' => 'Log in',
'registration_label' => 'Get started',
'default_meta_title' => '',
'default_meta_description' => '',
];
return array_replace($defaults, $this->group('site'));
}
public function put(string $key, mixed $value, string $group = 'general', string $type = 'string', bool $public = false): Setting
{
$setting = Setting::query()->updateOrCreate(compact('group', 'key'), ['value' => is_scalar($value) || $value === null ? (string) $value : json_encode($value, JSON_THROW_ON_ERROR), 'type' => $type, 'is_public' => $public]);
$setting = Setting::query()->updateOrCreate(
compact('group', 'key'),
[
'value' => $this->encode($value, $type),
'type' => $type,
'is_public' => $public,
]
);
Cache::forget("settings.$group.$key");
Cache::forget("settings.group.$group");
return $setting;
}
private function tableExists(): bool
{
try {
return Schema::hasTable((new Setting())->getTable());
} catch (\Throwable) {
return false;
}
}
private function encode(mixed $value, string $type): ?string
{
if ($value === null) {
return null;
}
return match ($type) {
'boolean' => filter_var($value, FILTER_VALIDATE_BOOL) ? '1' : '0',
'integer' => (string) (int) $value,
'float' => (string) (float) $value,
'json', 'array' => json_encode($value, JSON_THROW_ON_ERROR),
default => (string) $value,
};
}
private function decode(?string $value, string $type): mixed
{
return match ($type) {
'boolean' => filter_var($value, FILTER_VALIDATE_BOOL),
'integer' => (int) $value,
'float' => (float) $value,
'json', 'array' => filled($value) ? json_decode($value, true, 512, JSON_THROW_ON_ERROR) : [],
default => $value,
};
}
}
@@ -0,0 +1,9 @@
<?php
use Modules\Settings\Services\SettingsRepository;
it('stores and reads typed site settings from one repository', function (): void {
$repository = app(SettingsRepository::class);
$repository->put('site_name', 'Unified CMS', 'site', 'string', true);
expect($repository->site()['site_name'])->toBe('Unified CMS');
});