where('key', $key)->value('value'); } catch (QueryException $e) { // The gate reads a setting on every request, so an unreachable // or not-yet-migrated table would take the whole site down — // including during a deploy where new code runs before migrate. // Fall back to the caller's default, but do NOT cache that: // caching a failure would keep a hidden site publicly visible // long after the database came back. Log::warning('app_settings unavailable, using default', [ 'key' => $key, 'error' => $e->getMessage(), ]); return $default; } // Sentinel: distinguishes "stored null" from "never stored", so a // missing row does not get re-queried on every request. $raw = $row ?? '__missing__'; Cache::forever($cacheKey, $raw); } return $raw === '__missing__' ? $default : json_decode($raw, true); } public static function set(string $key, mixed $value): void { DB::table('app_settings')->updateOrInsert( ['key' => $key], ['value' => json_encode($value), 'updated_at' => now(), 'created_at' => now()], ); Cache::forget(self::CACHE_PREFIX.$key); } public static function bool(string $key, bool $default = false): bool { return (bool) self::get($key, $default); } /** * Remove a setting entirely, falling back to the caller's own default * from then on. * * Not `set($key, null)`: that would still leave a row (and a cache * entry) behind — indistinguishable to `get()`, but a migration's * `down()` undoing its own `up()` should leave no row, not a null one. * Deletes the row before dropping the cache, the same order `set()` * writes-then-invalidates in — a reader between the two statements sees * the OLD value from a cache that is about to be dropped, never a stale * one that outlives the row it described. */ public static function forget(string $key): void { DB::table('app_settings')->where('key', $key)->delete(); Cache::forget(self::CACHE_PREFIX.$key); } }