Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.86% covered (success)
92.86%
39 / 42
70.00% covered (warning)
70.00%
7 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
SettingService
92.86% covered (success)
92.86%
39 / 42
70.00% covered (warning)
70.00%
7 / 10
19.13
0.00% covered (danger)
0.00%
0 / 1
 get
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 all
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 set
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 setMany
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
4.07
 has
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 forget
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 split
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 map
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 schemaDefault
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 schemaDefaults
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace App\Services\Setting;
4
5use App\Models\AppSetting;
6use App\Service\CacheService;
7use Illuminate\Support\Arr;
8
9/**
10 * The one way to read and write settings.
11 *
12 *   settings('general.site_name')            // value, or the schema default, or null
13 *   settings('general.site_name', 'Shop')    // ... or this fallback
14 *   settings()->set('general.site_name', 'X')
15 *   settings()->all('general')               // ['site_name' => 'X', ...] incl. defaults
16 *
17 * Values live in the `app_settings` table (one row per key, JSON value).
18 * Field definitions + defaults live in config/settings/*.php (Phase 1 slice 2);
19 * this service works with or without that schema present.
20 *
21 * The whole table is tiny, so it is cached as a single group=>key=>value map
22 * and busted by AppSetting::boot() on every write.
23 */
24class SettingService
25{
26    /** @var array<string, array<string, mixed>>|null in-request memo */
27    private ?array $memo = null;
28
29    /**
30     * Read a setting by "group.key". Resolution order:
31     * stored value → schema default → $default argument.
32     */
33    public function get(string $path, mixed $default = null): mixed
34    {
35        [$group, $key] = $this->split($path);
36
37        $stored = $this->map()[$group][$key] ?? null;
38        if ($stored !== null) {
39            return $stored;
40        }
41
42        $schemaDefault = $this->schemaDefault($group, $key);
43
44        return $schemaDefault ?? $default;
45    }
46
47    /**
48     * All values for a group as key=>value, merged over the schema defaults.
49     */
50    public function all(?string $group = null): array
51    {
52        if ($group === null) {
53            return $this->map();
54        }
55
56        return array_merge($this->schemaDefaults($group), $this->map()[$group] ?? []);
57    }
58
59    /**
60     * Write one setting. `null` clears the stored value (reverts to default).
61     */
62    public function set(string $path, mixed $value): void
63    {
64        [$group, $key] = $this->split($path);
65
66        if ($value === null) {
67            // Query-builder delete() skips model events, so bust the cache below.
68            AppSetting::query()->where(compact('group', 'key'))->delete();
69        } else {
70            AppSetting::query()->updateOrCreate(compact('group', 'key'), ['value' => $value]);
71        }
72
73        $this->forget();
74    }
75
76    /**
77     * Bulk write, typically a whole group from the settings form.
78     * $values is key=>value (keys are bare, not "group.key").
79     */
80    public function setMany(string $group, array $values): void
81    {
82        foreach ($values as $key => $value) {
83            [$g, $k] = $this->split(str_contains($key, '.') ? $key : "{$group}.{$key}");
84            if ($value === null) {
85                AppSetting::query()->where(['group' => $g, 'key' => $k])->delete();
86            } else {
87                AppSetting::query()->updateOrCreate(['group' => $g, 'key' => $k], ['value' => $value]);
88            }
89        }
90
91        $this->forget();
92    }
93
94    public function has(string $path): bool
95    {
96        [$group, $key] = $this->split($path);
97
98        return isset($this->map()[$group][$key]);
99    }
100
101    public function forget(): void
102    {
103        $this->memo = null;
104        CacheService::forgetAppSettings();
105    }
106
107    /* -------------------------------------------------------------------- */
108
109    private function split(string $path): array
110    {
111        if (! str_contains($path, '.')) {
112            return ['general', $path];
113        }
114
115        return [strtok($path, '.'), substr($path, strpos($path, '.') + 1)];
116    }
117
118    /** @return array<string, array<string, mixed>> group => (key => value) */
119    private function map(): array
120    {
121        if ($this->memo !== null) {
122            return $this->memo;
123        }
124
125        $rows = CacheService::remember(CacheService::KEY_APP_SETTINGS, function () {
126            return AppSetting::query()->get(['group', 'key', 'value']);
127        });
128
129        $map = [];
130        foreach ($rows as $row) {
131            $map[$row->group][$row->key] = $row->value;
132        }
133
134        return $this->memo = $map;
135    }
136
137    private function schemaDefault(string $group, string $key): mixed
138    {
139        return Arr::get(config("settings.{$group}.fields.{$key}"), 'default');
140    }
141
142    private function schemaDefaults(string $group): array
143    {
144        $fields = config("settings.{$group}.fields", []);
145
146        return collect($fields)
147            ->map(fn ($def) => $def['default'] ?? null)
148            ->filter(fn ($v) => $v !== null)
149            ->all();
150    }
151}