> */ private static array $loaded = []; /** * Resolved defaults, keyed by module slug. * * @var array> */ private static array $defaults = []; /** * Unresolved default factories, keyed by module slug. * * @var array> */ private static array $factories = []; /** * Read a setting using `module.key` dot notation. * * @param string $path Dotted path, e.g. `otp_auth.code_length`. * @param mixed $fallback Returned when neither stored value nor default exists. * * @return mixed */ public static function get( string $path, mixed $fallback = null ): mixed { [ $module, $key ] = self::splitPath( $path ); if ( '' === $key ) { return self::all( $module ); } $values = self::all( $module ); if ( array_key_exists( $key, $values ) ) { return $values[ $key ]; } return $fallback; } /** * Every setting for a module, with registered defaults merged underneath. * * @param string $module Module slug. * * @return array */ public static function all( string $module ): array { if ( isset( self::$loaded[ $module ] ) ) { return self::$loaded[ $module ]; } $stored = get_option( self::PREFIX . $module ); $stored = is_array( $stored ) ? $stored : []; self::$loaded[ $module ] = array_merge( self::defaultsFor( $module ), $stored ); return self::$loaded[ $module ]; } /** * Write a single setting. * * @param string $path Dotted path, e.g. `otp_auth.code_length`. * @param mixed $value Value to store. */ public static function set( string $path, mixed $value ): void { [ $module, $key ] = self::splitPath( $path ); if ( '' === $key ) { return; } $values = self::all( $module ); $values[ $key ] = $value; self::save( $module, $values ); } /** * Merge values into a module's stored settings. * * Merging rather than replacing, because a caller holds only the keys it knows * about. The settings screen builds its payload from the fields present in the * POST, so under replace semantics any key that did not round-trip through the * browser was silently dropped and fell back to its default on the next read. * That is harmless only while every field is rendered on every request, and * three planned changes each break that assumption on their own: rendering * only the active vertical tab, submitting only fields that changed, and * conditional fields that render in some states and not others. * * The merge is one level deep, which is what the callers need: a caller that * owns a nested payload — the notification event map, the custom-city lists — * reads it, edits it and passes the whole key back, so removing an entry from * inside it still works. * * When the intent really is to discard unlisted keys, call `replace()` so the * intent is visible at the call site rather than implied by the method name. * * @param string $module Module slug. * @param array $values Values to merge in. */ public static function save( string $module, array $values ): void { $stored = get_option( self::PREFIX . $module ); $stored = is_array( $stored ) ? $stored : []; self::persist( $module, array_merge( $stored, $values ) ); } /** * Replace a module's entire settings payload, dropping keys not listed. * * The counterpart to `save()`, for the one case that genuinely wants the old * behaviour: resetting a module to its shipped defaults. * * @param string $module Module slug. * @param array $values Full settings payload. */ public static function replace( string $module, array $values ): void { self::persist( $module, $values ); } /** * Write a resolved payload and keep the derived state in step. * * Stored with `autoload = false` without exception — a module's settings must * never enter the `alloptions` cache. * * @param string $module Module slug. * @param array $values Full settings payload. */ private static function persist( string $module, array $values ): void { update_option( self::PREFIX . $module, $values, false ); self::$loaded[ $module ] = array_merge( self::defaultsFor( $module ), $values ); // Some of what was just saved may be a boot switch, which the manifest // holds a copy of so the boot path never has to read this option. Without // this line, switching the login panel off would save correctly and then do // nothing until the next time something else happened to rebuild. if ( isset( ModuleManager::bootSwitches()[ $module ] ) ) { Manifest::rebuild(); } } /** * Register a module's defaults, as a factory rather than an array. * * Defaults live in code rather than being written to the database on * activation. That keeps the stored option to only what an admin actually * changed, and lets a default be corrected in a release without a migration. * * The factory is what makes this free at boot. `registerEnabled()` runs for * every enabled module on every request, and a default array is not the inert * thing it looks like: `'sms_driver' => IPPanel::ID` loads the SMS driver to * read the constant, `'login_mode' => LoginPanel::MODE_PASSWORD_FIRST` loads * four hundred lines of login panel. Passing the array meant paying for all of * it on page views that never opened a setting. Passing a closure means the * module's `defaults()` body — and whatever it names — is reached only when * something actually reads a setting, by which point those classes are wanted * anyway. * * @param string $module Module slug. * @param callable():array $factory Produces the default values. */ public static function registerDefaults( string $module, callable $factory ): void { self::$factories[ $module ] = $factory; unset( self::$defaults[ $module ] ); // A module can register defaults after its settings were already read. if ( isset( self::$loaded[ $module ] ) ) { self::$loaded[ $module ] = array_merge( self::defaultsFor( $module ), self::$loaded[ $module ] ); } } /** * Resolve a module's defaults, running its factory at most once. * * @param string $module Module slug. * * @return array */ private static function defaultsFor( string $module ): array { if ( isset( self::$defaults[ $module ] ) ) { return self::$defaults[ $module ]; } $factory = self::$factories[ $module ] ?? null; self::$defaults[ $module ] = null === $factory ? [] : $factory(); return self::$defaults[ $module ]; } /** * Delete a module's stored settings. * * @param string $module Module slug. */ public static function delete( string $module ): void { delete_option( self::PREFIX . $module ); unset( self::$loaded[ $module ] ); } /** * Forget resolved defaults. Test-suite affordance only. * * @internal */ public static function flushDefaults(): void { self::$defaults = []; } /** * Forget memoised values. Test-suite affordance only. * * @internal */ public static function flush(): void { self::$loaded = []; } /** * Split `module.key` into its two halves. * * @param string $path Dotted path. * * @return array{0:string, 1:string} */ private static function splitPath( string $path ): array { $pos = strpos( $path, '.' ); if ( false === $pos ) { return [ $path, '' ]; } return [ substr( $path, 0, $pos ), substr( $path, $pos + 1 ) ]; } }