# xspeed/1.0.2/includes/class-settings-manager.php

xSpeed Cache: AI-Powered Performance Hub with MCP, Caching &amp; CDN, version 1.0.2. 240 lines.

- Page: https://pluginprobe.com/plugins/xspeed/1.0.2/code/includes/class-settings-manager.php
- Raw: https://pluginprobe.com/plugins/xspeed/1.0.2/raw/includes/class-settings-manager.php
- Modified: 2026-06-01T17:33:22+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/xspeed/1.0.2/code/includes/class-settings-manager.php#L10-L20`.

```php
<?php
/**
 * Settings_Manager — per-module typed settings storage, validation, and
 * versioned migrations.
 *
 * Storage layout: one wp_option per module under the key
 * `xspeed_module_<slug>`. The option value is an associative array that
 * also carries a `_version` field (the module VERSION at the time of last
 * write) so migrations know what schema produced the stored data.
 *
 * The pre-Module v1 settings (the global cache_enabled / minify_* /
 * gzip_enabled / cache_expiry / excluded_urls) keep living in
 * `xspeed_options` under the existing Settings class — Settings_Manager
 * does not touch them. When v1 features are refactored into Modules,
 * they'll migrate from `xspeed_options` to their per-module options as
 * part of that PR.
 *
 * @package XSpeed
 */

namespace XSpeed;

defined( 'ABSPATH' ) || exit;

final class Settings_Manager {

	public const OPTION_PREFIX = 'xspeed_module_';

	/**
	 * Read settings for a module slug. Returns defaults merged with stored
	 * values + the schema applied (unknown keys stripped). Always safe to
	 * call before activation — returns pure defaults if nothing is stored.
	 */
	public static function get( string $slug ): array {
		$module = Module_Registry::get( $slug );
		if ( ! $module ) {
			return array();
		}
		$schema   = $module->settings_schema();
		$defaults = self::defaults_from_schema( $schema );
		$stored   = get_option( self::option_key( $slug ), array() );
		if ( ! is_array( $stored ) ) {
			$stored = array();
		}
		$merged = array_merge( $defaults, $stored );

		// Strip keys not in schema; coerce types to what the schema declares.
		$clean = array();
		foreach ( $schema as $key => $spec ) {
			$clean[ $key ] = array_key_exists( $key, $merged )
				? self::coerce( $merged[ $key ], $spec )
				: ( $spec['default'] ?? null );
		}
		return $clean;
	}

	/**
	 * Validate input against the module's schema, merge over stored values,
	 * and persist. Returns the final clean array. Unknown keys are stripped
	 * silently. Out-of-range / wrong-type values fall back to the previous
	 * stored value (or default).
	 */
	public static function update( string $slug, array $input ): array {
		$module = Module_Registry::get( $slug );
		if ( ! $module ) {
			return array();
		}
		$schema  = $module->settings_schema();
		$current = self::get( $slug );

		$clean = $current;
		foreach ( $schema as $key => $spec ) {
			if ( ! array_key_exists( $key, $input ) ) {
				continue;
			}
			[ $value, $valid ] = self::validate_field( $input[ $key ], $spec );
			if ( $valid ) {
				$clean[ $key ] = $value;
			}
			// Invalid → keep $current[$key]. We do not throw; REST layer can
			// add its own strict-mode validation that 400s on invalid input.
		}

		$clean['_version'] = $module->version();
		update_option( self::option_key( $slug ), $clean );

		// Strip the internal _version key from the returned array.
		unset( $clean['_version'] );
		return $clean;
	}

	/**
	 * Run any pending schema migrations for a module. Called by
	 * Module_Registry before boot(). Idempotent — migrations only run once
	 * per version bump because we persist `_version` after each successful
	 * migration step.
	 */
	public static function run_migrations( Module $module ): void {
		$migrations = $module->migrations();
		if ( empty( $migrations ) ) {
			return;
		}
		$option_key = self::option_key( $module->slug() );
		$stored     = get_option( $option_key, null );
		if ( null === $stored ) {
			return; // fresh install — no data to migrate.
		}
		if ( ! is_array( $stored ) ) {
			$stored = array();
		}
		$from = isset( $stored['_version'] ) ? (string) $stored['_version'] : '0.0.0';

		// Sort migrations by version ascending.
		uksort(
			$migrations,
			static function ( $a, $b ) {
				return version_compare( (string) $a, (string) $b );
			}
		);

		$dirty = false;
		foreach ( $migrations as $target => $callable ) {
			$target = (string) $target;
			if ( version_compare( $from, $target, '>=' ) ) {
				continue;
			}
			$migrated = call_user_func( $callable, $stored );
			if ( is_array( $migrated ) ) {
				$stored             = $migrated;
				$stored['_version'] = $target;
				$from               = $target;
				$dirty              = true;
			}
		}

		if ( $dirty ) {
			update_option( $option_key, $stored );
		}
	}

	/**
	 * Coerce a stored value to the schema's declared type — used on read
	 * to defend against options edited by hand or imported across versions.
	 */
	private static function coerce( $value, array $spec ) {
		$type = $spec['type'] ?? 'string';
		switch ( $type ) {
			case 'bool':
				return (bool) $value;
			case 'int':
				$v = (int) $value;
				if ( isset( $spec['min'] ) ) {
					$v = max( (int) $spec['min'], $v );
				}
				if ( isset( $spec['max'] ) ) {
					$v = min( (int) $spec['max'], $v );
				}
				return $v;
			case 'enum':
				return in_array( $value, $spec['options'] ?? array(), true )
					? $value
					: ( $spec['default'] ?? null );
			case 'list':
				if ( ! is_array( $value ) ) {
					return $spec['default'] ?? array();
				}
				return array_values( array_filter( $value, 'is_scalar' ) );
			case 'url':
				$url = esc_url_raw( (string) $value );
				return $url ?: ( $spec['default'] ?? '' );
			case 'string':
			default:
				return sanitize_text_field( (string) $value );
		}
	}

	/**
	 * Validate one field; returns [ coerced_value, was_valid ]. Distinct
	 * from coerce() because validate is strict (out-of-range int is
	 * INVALID) while coerce is forgiving (clamps to range).
	 */
	private static function validate_field( $value, array $spec ): array {
		$type = $spec['type'] ?? 'string';
		switch ( $type ) {
			case 'bool':
				return array( (bool) $value, true );
			case 'int':
				if ( ! is_numeric( $value ) ) {
					return array( null, false );
				}
				$v = (int) $value;
				if ( isset( $spec['min'] ) && $v < (int) $spec['min'] ) {
					return array( null, false );
				}
				if ( isset( $spec['max'] ) && $v > (int) $spec['max'] ) {
					return array( null, false );
				}
				return array( $v, true );
			case 'enum':
				$ok = in_array( $value, $spec['options'] ?? array(), true );
				return array( $ok ? $value : null, $ok );
			case 'list':
				if ( ! is_array( $value ) ) {
					return array( null, false );
				}
				$item_type = $spec['item_type'] ?? 'string';
				$out       = array();
				foreach ( $value as $item ) {
					if ( 'url' === $item_type ) {
						$u = esc_url_raw( (string) $item );
						if ( $u ) {
							$out[] = $u;
						}
					} else {
						$out[] = sanitize_text_field( (string) $item );
					}
				}
				return array( $out, true );
			case 'url':
				$u = esc_url_raw( (string) $value );
				return array( $u, (bool) $u );
			case 'string':
			default:
				return array( sanitize_text_field( (string) $value ), true );
		}
	}

	private static function defaults_from_schema( array $schema ): array {
		$out = array();
		foreach ( $schema as $key => $spec ) {
			$out[ $key ] = $spec['default'] ?? null;
		}
		return $out;
	}

	private static function option_key( string $slug ): string {
		return self::OPTION_PREFIX . $slug;
	}
}

```
