PluginProbe
phpinfo() WP – Site Health, PHP Compatibility & Server Audit / 7.2.5
phpinfo() WP – Site Health, PHP Compatibility & Server Audit v7.2.5
7.2.7 7.2.6 7.2.5 7.2.4 7.2.3 7.2.0 7.2.1 7.2.2 7.1.0 7.0.3 7.0.4 7.0.5 trunk 6.0 7.0.0 7.0.1 7.0.2
phpinfo-wp / includes / class-config-grader.php

class-config-grader.php in phpinfo() WP – Site Health, PHP Compatibility & Server Audit 7.2.5, at includes/class-config-grader.php

1,051 lines 53.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 defined('ABSPATH') or die('Unauthorized Access');
3
4 /**
5 * Config Grader — context-aware PHP/WordPress configuration audit.
6 *
7 * Sophistication levers (vs the prior static-threshold version):
8 *
9 * 1. Context-aware thresholds. We detect active plugins (WooCommerce, Elementor,
10 * LearnDash, BuddyPress, page builders, big-import tools), the hosting
11 * environment (Kinsta / WP Engine / SiteGround / Cloudways / Pantheon /
12 * Flywheel / LiquidWeb / LiteSpeed), PHP version, and HTTPS state, then
13 * adjust recommended values per directive accordingly. A WooCommerce site
14 * wants 512M of memory; a blog wants 256M; we now recommend the right one.
15 *
16 * 2. Cross-directive consistency checks. post_max_size must be ≥
17 * upload_max_filesize. memory_limit must be ≥ post_max_size + headroom.
18 * max_input_time must not exceed max_execution_time. Each is its own
19 * check run after the per-directive pass.
20 *
21 * 3. Live-data corroboration. We read the OPcache live stats and the recent
22 * error-log tail. If memory_limit "passes" the static threshold but the
23 * error log shows recent OOM kills, we escalate. If opcache.memory looks
24 * fine but the cache is currently full, we escalate. The static rule is
25 * a starting point; reality overrides it.
26 *
27 * 4. PHP 8.x modern directives. Added opcache.jit, opcache.jit_buffer_size,
28 * opcache.huge_code_pages, realpath_cache_size, realpath_cache_ttl,
29 * date.timezone, output_buffering, max_file_uploads.
30 *
31 * 5. Host-aware remediation. For directives that can't be changed by the
32 * user on managed hosts, point them at the host's panel instead of
33 * telling them to edit php.ini.
34 *
35 * 6. Severity matrix. Critical / High / Medium / Low replaces the old
36 * weight 1/2/3 + pass/warn/fail combo. Severity is the per-check
37 * attribute; status (pass/warn/fail) is derived from current value.
38 *
39 * 7. Trend tracking. Each Pro page-load on a site records the current
40 * score in a rolling 30-day option. We compute delta from previous
41 * recording and surface it so weekly reports can say "B (-3 since
42 * last week — memory_limit regressed)".
43 *
44 * The return shape of run() is backward-compatible with the audit-report
45 * consumer: `checks` is still an array of per-check rows with at least
46 * `key/label/value/good/status`. New fields (severity, live_evidence,
47 * host_fix, target_value) are additive.
48 */
49 class Phpinfo_WP_Config_Grader {
50
51 // Severity tiers
52 const SEV_CRITICAL = 'critical'; // RCE / data leak risk
53 const SEV_HIGH = 'high'; // ≥10% perf hit OR strong security weakness
54 const SEV_MEDIUM = 'medium'; // Best practice / minor perf
55 const SEV_LOW = 'low'; // Nice to have
56
57 // Severity → numeric weight used by the scoring formula
58 private const SEV_WEIGHT = [
59 self::SEV_CRITICAL => 4,
60 self::SEV_HIGH => 3,
61 self::SEV_MEDIUM => 2,
62 self::SEV_LOW => 1,
63 ];
64
65 const OPT_HISTORY = 'phpinfowp_grader_history';
66
67 /**
68 * @var mixed[]|null
69 */
70 private static $ctx_cache;
71
72 private static function _pro(): bool { return Phpinfo_WP_License::is_valid(); }
73
74 // ─────────────────────────────────────────────────────────────────
75 // Site context — detected once per request
76 // ─────────────────────────────────────────────────────────────────
77
78 public static function context(): array {
79 if (self::$ctx_cache !== null) return self::$ctx_cache;
80 return self::$ctx_cache = [
81 'php_major' => PHP_MAJOR_VERSION,
82 'php_minor' => PHP_MINOR_VERSION,
83 'php_full' => PHP_VERSION,
84 'is_https' => is_ssl(),
85 'is_production' => !(defined('WP_DEBUG') && WP_DEBUG),
86 'plugins' => self::detect_plugins(),
87 'host' => self::detect_host(),
88 'server' => self::detect_server(),
89 'live_opcache' => function_exists('opcache_get_status') ? @opcache_get_status(false) : null,
90 'error_signals' => self::error_log_signals(),
91 ];
92 }
93
94 /** Map of detected workloads we tune for. */
95 private static function detect_plugins(): array {
96 $active = (array) get_option('active_plugins', []);
97 if (is_multisite()) {
98 $active = array_merge($active, array_keys((array) get_site_option('active_sitewide_plugins', [])));
99 }
100 // basename → workload key
101 $map = [
102 'woocommerce/woocommerce.php' => 'woocommerce',
103 'elementor/elementor.php' => 'elementor',
104 'elementor-pro/elementor-pro.php' => 'elementor',
105 'js_composer/js_composer.php' => 'wpbakery',
106 'fusion-builder/fusion-builder.php' => 'avada',
107 'oxygen/functions.php' => 'oxygen',
108 'beaver-builder-lite-version/fl-builder.php' => 'beaver-builder',
109 'bb-plugin/fl-builder.php' => 'beaver-builder',
110 'buddypress/bp-loader.php' => 'buddypress',
111 'sfwd-lms/sfwd_lms.php' => 'learndash',
112 'tutor/tutor.php' => 'tutor',
113 'lifterlms/lifterlms.php' => 'lifterlms',
114 'easy-digital-downloads/easy-digital-downloads.php' => 'edd',
115 'wp-all-import/plugin.php' => 'wp-all-import',
116 'wp-all-import-pro/wp-all-import-pro.php' => 'wp-all-import',
117 'wpforms-lite/wpforms.php' => 'forms-heavy',
118 'wpforms/wpforms.php' => 'forms-heavy',
119 'formidable/formidable.php' => 'forms-heavy',
120 'gravityforms/gravityforms.php' => 'forms-heavy',
121 'updraftplus/updraftplus.php' => 'backup',
122 'backwpup/backwpup.php' => 'backup',
123 'duplicator/duplicator.php' => 'backup',
124 'duplicator-pro/duplicator-pro.php' => 'backup',
125 'wp-rocket/wp-rocket.php' => 'wp-rocket',
126 'litespeed-cache/litespeed-cache.php' => 'litespeed-cache',
127 ];
128 $detected = [];
129 foreach ($active as $p) {
130 if (isset($map[$p])) $detected[$map[$p]] = true;
131 }
132 // Theme detection — Divi
133 $theme = wp_get_theme();
134 if (!$theme->errors() && (strtolower($theme->get('Name') ?? '') === 'divi' || strtolower($theme->get('Template') ?? '') === 'divi')) {
135 $detected['divi'] = true;
136 }
137 return $detected;
138 }
139
140 /**
141 * Determines the single most critical "Application Profile" for the site to display in the UI banner.
142 * Checks installed plugins in order of their impact on server requirements.
143 */
144 public static function dominant_profile(array $ctx): ?array {
145 $p = $ctx['plugins'] ?? [];
146
147 // 1. E-commerce
148 if (!empty($p['woocommerce'])) {
149 return [
150 'id' => 'woocommerce',
151 'name' => 'WooCommerce Profile',
152 'icon' => '🛒',
153 'desc' => 'E-commerce sites require significantly more memory (512M+) and execution time than standard blogs. Apply this profile to prevent cart abandonment and slow checkouts.',
154 ];
155 }
156 if (!empty($p['edd'])) {
157 return [
158 'id' => 'edd',
159 'name' => 'EDD E-commerce Profile',
160 'icon' => '🛒',
161 'desc' => 'Easy Digital Downloads requires higher memory ceilings and optimal caching. Apply this profile to ensure stable transactions.',
162 ];
163 }
164
165 // 2. LMS / Courses
166 if (!empty($p['learndash']) || !empty($p['lifterlms']) || !empty($p['tutor'])) {
167 return [
168 'id' => 'lms',
169 'name' => 'LMS & Courses Profile',
170 'icon' => '🎓',
171 'desc' => 'Learning Management Systems have many concurrent logged-in users tracking progress. Apply this profile to prevent database and memory bottlenecks.',
172 ];
173 }
174
175 // 3. Heavy Imports
176 if (!empty($p['wp-all-import'])) {
177 return [
178 'id' => 'import',
179 'name' => 'Heavy Import Profile',
180 'icon' => '📦',
181 'desc' => 'Mass data imports require extremely long maximum execution times and memory limits to prevent timing out halfway through.',
182 ];
183 }
184
185 // 4. Page Builders
186 if (!empty($p['elementor']) || !empty($p['wpbakery']) || !empty($p['divi']) || !empty($p['avada']) || !empty($p['oxygen']) || !empty($p['beaver-builder'])) {
187 return [
188 'id' => 'builder',
189 'name' => 'Page Builder Profile',
190 'icon' => '🎨',
191 'desc' => 'Visual page builders construct pages using thousands of input variables. Apply this profile to prevent layout data from being silently truncated upon saving.',
192 ];
193 }
194
195 // 5. Community
196 if (!empty($p['buddypress'])) {
197 return [
198 'id' => 'community',
199 'name' => 'BuddyPress Community Profile',
200 'icon' => '👥',
201 'desc' => 'Social and community sites have highly uncacheable, dynamic traffic. Apply this profile to ensure enough memory overhead is available for concurrent users.',
202 ];
203 }
204
205 return null;
206 }
207
208 /** Managed-host fingerprint. Returns slug or null. */
209 private static function detect_host(): ?string {
210 if (defined('KINSTA_CACHE_ZONE')) return 'kinsta';
211 if (defined('WPE_APIKEY') || !empty($_SERVER['IS_WPE'])) return 'wpengine';
212 if (@file_exists('/var/lib/sgsystem')) return 'siteground';
213 if (defined('PANTHEON_ENVIRONMENT')) return 'pantheon';
214 if (defined('FLYWHEEL_CONFIG_DIR')) return 'flywheel';
215 if (@file_exists('/etc/cloudways')) return 'cloudways';
216 if (!empty($_SERVER['CLOUDWAYS_APP_ID'])) return 'cloudways';
217 if (defined('LIQUIDWEB_HOSTING')) return 'liquidweb';
218 if (!empty($_SERVER['HTTP_X_LSCACHE'])) return 'litespeed';
219 return null;
220 }
221
222 /** Server software family. */
223 private static function detect_server(): string {
224 $sw = strtolower($_SERVER['SERVER_SOFTWARE'] ?? '');
225 if (strpos($sw, 'litespeed') !== false) return 'litespeed';
226 if (strpos($sw, 'nginx') !== false) return 'nginx';
227 if (strpos($sw, 'apache') !== false) return 'apache';
228 if (strpos($sw, 'iis') !== false) return 'iis';
229 return 'unknown';
230 }
231
232 /** Tail the error log for actionable signals (memory OOM, time-exceeded, max-input-vars). */
233 private static function error_log_signals(): array {
234 if (!class_exists('Phpinfo_WP_Error_Log')) return [];
235 $path = Phpinfo_WP_Error_Log::find_path();
236 if (!$path || !@is_readable($path)) return [];
237 $size = (int) @filesize($path);
238 if ($size <= 0) return [];
239 $chunk = 256 * 1024;
240 $offset = max(0, $size - $chunk);
241 $fh = @fopen($path, 'rb');
242 if (!$fh) return [];
243 @fseek($fh, $offset);
244 $data = (string) @fread($fh, $chunk);
245 @fclose($fh);
246 if ($data === '') return [];
247 return [
248 'memory_exhausted' => (int) preg_match_all('/Allowed memory size of/i', $data),
249 'max_time_exceeded' => (int) preg_match_all('/Maximum execution time of \d+ seconds exceeded/i', $data),
250 'max_input_vars_exceeded' => (int) preg_match_all('/Input variables exceeded/i', $data),
251 'upload_too_large' => (int) preg_match_all('/POST Content-Length .* exceeds the limit/i', $data),
252 ];
253 }
254
255 // ─────────────────────────────────────────────────────────────────
256 // Recommendations — context-aware thresholds
257 // ─────────────────────────────────────────────────────────────────
258
259 /** Recommended memory_limit in bytes, with the reason list. */
260 public static function rec_memory(array $ctx): array {
261 $mb = 256; $reasons = [];
262 $p = $ctx['plugins'] ?? [];
263 if (!empty($p['woocommerce'])) { $mb = max($mb, 512); $reasons[] = 'WooCommerce'; }
264 if (!empty($p['learndash'])) { $mb = max($mb, 512); $reasons[] = 'LearnDash'; }
265 if (!empty($p['lifterlms'])) { $mb = max($mb, 512); $reasons[] = 'LifterLMS'; }
266 if (!empty($p['buddypress'])) { $mb = max($mb, 384); $reasons[] = 'BuddyPress'; }
267 if (!empty($p['wp-all-import'])) { $mb = max($mb, 512); $reasons[] = 'WP All Import'; }
268 if (!empty($p['backup'])) { $mb = max($mb, 384); $reasons[] = 'a backup plugin'; }
269 return ['mb' => $mb, 'reasons' => $reasons];
270 }
271
272 /** Recommended max_input_vars + reasons. */
273 public static function rec_input_vars(array $ctx): array {
274 $n = 3000; $reasons = [];
275 $p = $ctx['plugins'] ?? [];
276 if (!empty($p['elementor'])) { $n = max($n, 5000); $reasons[] = 'Elementor'; }
277 if (!empty($p['wpbakery'])) { $n = max($n, 5000); $reasons[] = 'WPBakery'; }
278 if (!empty($p['divi'])) { $n = max($n, 5000); $reasons[] = 'Divi'; }
279 if (!empty($p['avada'])) { $n = max($n, 5000); $reasons[] = 'Avada Fusion Builder'; }
280 if (!empty($p['oxygen'])) { $n = max($n, 5000); $reasons[] = 'Oxygen Builder'; }
281 if (!empty($p['beaver-builder'])) { $n = max($n, 4000); $reasons[] = 'Beaver Builder'; }
282 if (!empty($p['forms-heavy'])) { $n = max($n, 5000); $reasons[] = 'a form-builder plugin'; }
283 if (!empty($p['woocommerce'])) { $n = max($n, 5000); $reasons[] = 'WooCommerce'; }
284 return ['n' => $n, 'reasons' => $reasons];
285 }
286
287 /** Recommended max_execution_time + reasons. */
288 public static function rec_exec_time(array $ctx): array {
289 $s = 60; $reasons = [];
290 $p = $ctx['plugins'] ?? [];
291 if (!empty($p['backup'])) { $s = max($s, 300); $reasons[] = 'a backup plugin'; }
292 if (!empty($p['wp-all-import'])) { $s = max($s, 300); $reasons[] = 'WP All Import'; }
293 if (!empty($p['woocommerce'])) { $s = max($s, 120); $reasons[] = 'WooCommerce'; }
294 return ['s' => $s, 'reasons' => $reasons];
295 }
296
297 public static function rec_upload(array $ctx): int {
298 $mb = 64;
299 $p = $ctx['plugins'] ?? [];
300 if (!empty($p['woocommerce'])) $mb = max($mb, 128);
301 if (!empty($p['wp-all-import'])) $mb = max($mb, 256);
302 if (!empty($p['backup'])) $mb = max($mb, 128);
303 return $mb;
304 }
305
306 // ─────────────────────────────────────────────────────────────────
307 // Check definitions — single source of truth
308 // ─────────────────────────────────────────────────────────────────
309
310 /**
311 * Each check returns ['key','label','category','severity','target','target_label','pass','warn','note','php_min'].
312 * `target` is the recommended raw value (string or int) for display + fixer.
313 * `pass`/`warn` are closures accepting (current_value_string, target).
314 * `php_min` is the minimum PHP version this directive applies to (null = always).
315 */
316 private static function checks(array $ctx): array {
317 $mem = self::rec_memory($ctx);
318 $vars = self::rec_input_vars($ctx);
319 $exec = self::rec_exec_time($ctx);
320 $upload = self::rec_upload($ctx);
321 $is_https = $ctx['is_https'];
322
323 $checks = [
324 // ── Memory & Execution ──
325 [
326 'key' => 'memory_limit', 'label' => 'Memory Limit', 'category' => 'Performance',
327 'severity' => self::SEV_CRITICAL,
328 'target' => $mem['mb'] . 'M',
329 'target_label' => $mem['mb'] . 'M' . (count($mem['reasons']) ? ' (' . implode(', ', $mem['reasons']) . ')' : ''),
330 'pass' => function ($v, $t) {
331 return trim($v) === '-1' || self::bytes($v) >= self::bytes($t);
332 },
333 'warn' => function ($v, $t) {
334 return trim($v) === '-1' || self::bytes($v) >= self::bytes($t) * 0.5;
335 },
336 'note' => 'PHP\'s memory ceiling. WordPress baseline is 256M; e-commerce and LMS workloads need 512M+.',
337 ],
338 [
339 'key' => 'max_execution_time', 'label' => 'Max Execution Time', 'category' => 'Performance',
340 'severity' => self::SEV_HIGH,
341 'target' => (string) $exec['s'],
342 'target_label' => $exec['s'] . ' seconds' . (count($exec['reasons']) ? ' (' . implode(', ', $exec['reasons']) . ')' : ''),
343 'pass' => function ($v, $t) {
344 return (int) $v === 0 || (int) $v >= (int) $t;
345 },
346 'warn' => function ($v, $t) {
347 return (int) $v >= max(30, (int) $t / 2);
348 },
349 'note' => 'How long a single PHP request may run. Imports, updates, and backups time out below 60s.',
350 ],
351 [
352 'key' => 'max_input_vars', 'label' => 'Max Input Vars', 'category' => 'Performance',
353 'severity' => self::SEV_HIGH,
354 'target' => (string) $vars['n'],
355 'target_label' => $vars['n'] . ' or more' . (count($vars['reasons']) ? ' (' . implode(', ', $vars['reasons']) . ')' : ''),
356 'pass' => function ($v, $t) {
357 return (int) $v >= (int) $t;
358 },
359 'warn' => function ($v, $t) {
360 return (int) $v >= max(1000, (int) $t / 2);
361 },
362 'note' => 'Maximum form fields PHP will accept per request. Page builders and big forms silently lose fields below 3000.',
363 ],
364 [
365 'key' => 'upload_max_filesize', 'label' => 'Upload Max Filesize', 'category' => 'Performance',
366 'severity' => self::SEV_MEDIUM,
367 'target' => $upload . 'M',
368 'target_label' => $upload . 'M or more',
369 'pass' => function ($v, $t) {
370 return self::bytes($v) >= self::bytes($t);
371 },
372 'warn' => function ($v, $t) {
373 return self::bytes($v) >= self::bytes($t) * 0.5;
374 },
375 'note' => 'Largest single file PHP will accept. Caps media uploads, plugin zips, theme uploads.',
376 ],
377 [
378 'key' => 'post_max_size', 'label' => 'Post Max Size', 'category' => 'Performance',
379 'severity' => self::SEV_MEDIUM,
380 'target' => $upload . 'M',
381 'target_label' => 'At least equal to upload_max_filesize (' . $upload . 'M)',
382 'pass' => function ($v, $t) {
383 return self::bytes($v) >= self::bytes($t);
384 },
385 'warn' => function ($v, $t) {
386 return self::bytes($v) >= self::bytes($t) * 0.5;
387 },
388 'note' => 'Caps the entire POST body. Must be ≥ upload_max_filesize or large uploads fail.',
389 ],
390 [
391 'key' => 'max_input_time', 'label' => 'Max Input Time', 'category' => 'Performance',
392 'severity' => self::SEV_LOW,
393 'target' => '60',
394 'target_label' => '60 seconds or -1 (unlimited)',
395 'pass' => function ($v, $t) {
396 return (int) $v === -1 || (int) $v >= (int) $t;
397 },
398 'warn' => function ($v, $t) {
399 return (int) $v >= 30;
400 },
401 'note' => 'Time PHP spends parsing the request body. Affects multi-MB uploads on slow links.',
402 ],
403 [
404 'key' => 'max_file_uploads', 'label' => 'Max File Uploads', 'category' => 'Performance',
405 'severity' => self::SEV_LOW,
406 'target' => '20',
407 'target_label' => '20 or more',
408 'pass' => function ($v, $t) {
409 return (int) $v >= (int) $t;
410 },
411 'warn' => function ($v, $t) {
412 return (int) $v >= 10;
413 },
414 'note' => 'Max files per single upload form. WordPress gallery uploads need 20+.',
415 ],
416
417 // ── Security & Error Handling ──
418 [
419 'key' => 'display_errors', 'label' => 'Display Errors', 'category' => 'Security',
420 'severity' => $ctx['is_production'] ? self::SEV_CRITICAL : self::SEV_LOW,
421 'target' => '0',
422 'target_label' => 'Off (production) — leaks code paths to attackers',
423 'pass' => function ($v) {
424 return in_array(strtolower($v), ['0', 'off', ''], true);
425 },
426 'note' => 'When on, PHP errors print to the response. Stack traces leak credentials, paths, and table prefixes.',
427 ],
428 [
429 'key' => 'expose_php', 'label' => 'Expose PHP Version', 'category' => 'Security',
430 'severity' => self::SEV_MEDIUM,
431 'target' => '0',
432 'target_label' => 'Off — hides X-Powered-By header',
433 'pass' => function ($v) {
434 return in_array(strtolower($v), ['0', 'off', ''], true);
435 },
436 'note' => 'Stops PHP from advertising its version. Slows down version-specific exploit scanning.',
437 ],
438 [
439 'key' => 'allow_url_include', 'label' => 'Allow URL Include', 'category' => 'Security',
440 'severity' => self::SEV_CRITICAL,
441 'target' => '0',
442 'target_label' => 'Off — critical RCE vector if enabled',
443 'pass' => function ($v) {
444 return in_array(strtolower($v), ['0', 'off', ''], true);
445 },
446 'note' => 'Lets PHP `include` a remote URL. Single biggest RCE foot-gun in the language.',
447 ],
448 [
449 'key' => 'log_errors', 'label' => 'Log Errors', 'category' => 'Security',
450 'severity' => self::SEV_MEDIUM,
451 'target' => '1',
452 'target_label' => 'On — write errors to file, not to visitors',
453 'pass' => function ($v) {
454 return in_array(strtolower($v), ['1', 'on'], true);
455 },
456 'note' => 'Errors should be logged silently. Pair with display_errors=Off.',
457 ],
458 [
459 'key' => 'session.cookie_httponly', 'label' => 'Session Cookie HttpOnly', 'category' => 'Security',
460 'severity' => self::SEV_HIGH,
461 'target' => '1',
462 'target_label' => 'On — blocks JS access to session cookies (mitigates XSS)',
463 'pass' => function ($v) {
464 return in_array(strtolower($v), ['1', 'on'], true);
465 },
466 'note' => 'When On, JavaScript cannot read the session cookie via document.cookie.',
467 ],
468 [
469 'key' => 'session.use_strict_mode', 'label' => 'Session Strict Mode', 'category' => 'Security',
470 'severity' => self::SEV_HIGH,
471 'target' => '1',
472 'target_label' => 'On — rejects uninitialized session IDs (anti-fixation)',
473 'pass' => function ($v) {
474 return in_array(strtolower($v), ['1', 'on'], true);
475 },
476 'note' => 'Prevents attackers from forcing a chosen session ID onto a victim before they sign in.',
477 ],
478 [
479 'key' => 'session.cookie_secure', 'label' => 'Session Cookie Secure', 'category' => 'Security',
480 'severity' => $is_https ? self::SEV_HIGH : self::SEV_LOW,
481 'target' => $is_https ? '1' : '0',
482 'target_label' => $is_https ? 'On — required on HTTPS sites' : 'N/A (site is HTTP)',
483 'pass' => function ($v) use ($is_https) {
484 return !$is_https || in_array(strtolower($v), ['1', 'on'], true);
485 },
486 'note' => 'Restricts the session cookie to HTTPS transport. Mandatory once your site serves HTTPS.',
487 ],
488
489 // ── OPcache (PHP's bytecode cache) ──
490 [
491 'key' => 'opcache.enable', 'label' => 'OPcache Enabled', 'category' => 'OPcache',
492 'severity' => self::SEV_CRITICAL,
493 'target' => '1',
494 'target_label' => 'On — typical 50–80% PHP CPU reduction',
495 'pass' => function ($v) {
496 return in_array(strtolower($v), ['1', 'on'], true);
497 },
498 'note' => 'Caches compiled PHP bytecode. Without it, WordPress recompiles every file on every request.',
499 ],
500 [
501 'key' => 'opcache.memory_consumption', 'label' => 'OPcache Memory', 'category' => 'OPcache',
502 'severity' => self::SEV_HIGH,
503 'target' => '256',
504 'target_label' => '256 MB (medium site) / 512 MB (busy site)',
505 'pass' => function ($v) {
506 return (int) $v >= 256;
507 },
508 'warn' => function ($v) {
509 return (int) $v >= 128;
510 },
511 'note' => 'Memory budget for compiled bytecode. WordPress + 30 plugins easily exceeds 128M.',
512 ],
513 [
514 'key' => 'opcache.max_accelerated_files', 'label' => 'OPcache Max Files', 'category' => 'OPcache',
515 'severity' => self::SEV_HIGH,
516 'target' => '20000',
517 'target_label' => '20000 (WP + many plugins)',
518 'pass' => function ($v) {
519 return (int) $v >= 20000;
520 },
521 'warn' => function ($v) {
522 return (int) $v >= 4000;
523 },
524 'note' => 'Limits how many PHP files OPcache can keep in memory. Below 4000 you get cache thrashing.',
525 ],
526 [
527 'key' => 'opcache.validate_timestamps', 'label' => 'OPcache Validate Timestamps', 'category' => 'OPcache',
528 'severity' => $ctx['is_production'] ? self::SEV_MEDIUM : self::SEV_LOW,
529 'target' => $ctx['is_production'] ? '0' : '1',
530 'target_label' => $ctx['is_production'] ? 'Off (production) — biggest single perf win' : 'On (development) — sees edits without restart',
531 'pass' => function ($v) use ($ctx) {
532 return $ctx['is_production'] ? in_array(strtolower($v), ['0', 'off', ''], true) : true;
533 },
534 'note' => 'When Off, PHP never checks if source files changed. Massive perf win in prod, frustrating in dev.',
535 ],
536 [
537 'key' => 'opcache.jit', 'label' => 'OPcache JIT', 'category' => 'OPcache',
538 'severity' => self::SEV_MEDIUM,
539 'php_min' => '8.0',
540 'target' => 'tracing',
541 'target_label' => 'tracing — PHP 8\'s tracing JIT compiler',
542 'pass' => function ($v) {
543 return !in_array(strtolower(trim($v)), ['', 'disable', 'off', '0'], true);
544 },
545 'note' => 'PHP 8\'s Just-In-Time compiler. Adds 5–15% on top of OPcache for typical WP loads.',
546 ],
547 [
548 'key' => 'opcache.jit_buffer_size', 'label' => 'OPcache JIT Buffer', 'category' => 'OPcache',
549 'severity' => self::SEV_LOW,
550 'php_min' => '8.0',
551 'target' => '256M',
552 'target_label' => '256M (JIT memory budget)',
553 'pass' => function ($v) {
554 return self::bytes($v) >= 64 * MB_IN_BYTES;
555 },
556 'warn' => function ($v) {
557 return self::bytes($v) > 0;
558 },
559 'note' => 'Memory the JIT can use. Zero disables JIT entirely.',
560 ],
561 [
562 'key' => 'opcache.huge_code_pages', 'label' => 'OPcache Huge Pages', 'category' => 'OPcache',
563 'severity' => self::SEV_LOW,
564 'target' => '1',
565 'target_label' => 'On — reduces TLB pressure on Linux',
566 'pass' => function ($v) {
567 return in_array(strtolower($v), ['1', 'on'], true);
568 },
569 'note' => 'Maps PHP code into 2MB huge pages. Small but measurable perf win on Linux with transparent_hugepage on.',
570 ],
571
572 // ── Filesystem ──
573 [
574 'key' => 'realpath_cache_size', 'label' => 'Realpath Cache Size', 'category' => 'Performance',
575 'severity' => self::SEV_MEDIUM,
576 'target' => '4096K',
577 'target_label' => '4M or more',
578 'pass' => function ($v) {
579 return self::bytes($v) >= 4 * MB_IN_BYTES;
580 },
581 'warn' => function ($v) {
582 return self::bytes($v) >= 1 * MB_IN_BYTES;
583 },
584 'note' => 'Caches resolved file paths. WordPress hits the filesystem hard — small cache = slow.',
585 ],
586 [
587 'key' => 'realpath_cache_ttl', 'label' => 'Realpath Cache TTL', 'category' => 'Performance',
588 'severity' => self::SEV_LOW,
589 'target' => '600',
590 'target_label' => '600 seconds or more',
591 'pass' => function ($v) {
592 return (int) $v >= 600;
593 },
594 'warn' => function ($v) {
595 return (int) $v >= 120;
596 },
597 'note' => 'How long resolved paths stay cached. 600 (10 min) is standard for production.',
598 ],
599
600 // ── Output & misc ──
601 [
602 'key' => 'output_buffering', 'label' => 'Output Buffering', 'category' => 'Performance',
603 'severity' => self::SEV_LOW,
604 'target' => '4096',
605 'target_label' => '4096 or higher (4K buffer)',
606 'pass' => function ($v) {
607 return strtolower($v) === 'on' || (int) $v >= 4096;
608 },
609 'warn' => function ($v) {
610 return strtolower($v) === 'on' || (int) $v >= 1024;
611 },
612 'note' => 'Buffers output before sending. Required by some WordPress plugins; "On" works too.',
613 ],
614 [
615 'key' => 'date.timezone', 'label' => 'Default Timezone', 'category' => 'Configuration',
616 'severity' => self::SEV_LOW,
617 'target' => 'UTC',
618 'target_label' => 'UTC (or your local tz) — must not be empty',
619 'pass' => function ($v) {
620 return trim((string) $v) !== '';
621 },
622 'note' => 'When unset, PHP guesses and logs a warning on every request that uses date functions.',
623 ],
624 ];
625
626 // Filter out checks that don't apply to the running PHP version.
627 $php_full = $ctx['php_full'];
628 return array_values(array_filter($checks, function ($c) use ($php_full) {
629 if (empty($c['php_min'])) return true;
630 return version_compare($php_full, $c['php_min'], '>=');
631 }));
632 }
633
634 // ─────────────────────────────────────────────────────────────────
635 // Per-check evaluation + live-data corroboration + cross-checks
636 // ─────────────────────────────────────────────────────────────────
637
638 /**
639 * Run all checks against the current PHP runtime. Returns:
640 * ['checks' => [...], 'cross' => [...], 'score' => 0-100, 'grade' => 'A-F',
641 * 'severity_counts' => [...], 'categories' => [...], 'context' => [...],
642 * 'trend' => [...] ]
643 */
644 public static function run(): array {
645 $ctx = self::context();
646 $results = [];
647 $values = []; // for cross-checks
648 $total_w = 0;
649 $earned = 0;
650 $cats = [];
651 $sev_counts = [self::SEV_CRITICAL => 0, self::SEV_HIGH => 0, self::SEV_MEDIUM => 0, self::SEV_LOW => 0];
652
653 foreach (self::checks($ctx) as $c) {
654 $raw = ini_get($c['key']);
655 $cur = $raw === false ? '' : (string) $raw;
656 $target = $c['target'];
657 $pass = (bool) ($c['pass'])($cur, $target);
658 $warn_fn = $c['warn'] ?? null;
659 $warn = !$pass && is_callable($warn_fn) ? (bool) $warn_fn($cur, $target) : false;
660 $status = $pass ? 'pass' : ($warn ? 'warn' : 'fail');
661 $weight = self::SEV_WEIGHT[$c['severity']] ?? 2;
662
663 $total_w += $weight;
664 if ($pass) $earned += $weight;
665 elseif ($warn) $earned += $weight * 0.5;
666 else $sev_counts[$c['severity']]++;
667
668 $values[$c['key']] = $cur;
669 $cats[$c['category']] = true;
670
671 $results[] = [
672 'key' => $c['key'],
673 'label' => $c['label'],
674 'category' => $c['category'],
675 'severity' => $c['severity'],
676 'severity_label' => self::severity_label($c['severity']),
677 'target' => $target,
678 'good' => $c['target_label'], // backward-compat key for old consumers
679 'target_label' => $c['target_label'],
680 'value' => $cur === '' ? '(not set)' : $cur,
681 'status' => $status,
682 'note' => $c['note'],
683 'why' => $c['note'], // backward-compat
684 'live_evidence' => null, // filled by corroborate()
685 'host_fix' => self::host_fix_hint($ctx, $c['key']),
686 ];
687 }
688
689 // Live-data corroboration upgrades status/severity based on observed reality
690 self::corroborate($results, $ctx);
691
692 // Cross-directive consistency checks
693 $cross = self::consistency_checks($values);
694 foreach ($cross as $x) {
695 $sev_counts[$x['severity']]++;
696 $weight = self::SEV_WEIGHT[$x['severity']] ?? 2;
697 $total_w += $weight;
698 // Cross-checks always count as failing if present (they exist BECAUSE inconsistency was found)
699 }
700
701 $score = $total_w > 0 ? (int) round($earned / $total_w * 100) : 0;
702 $grade = self::grade($score);
703
704 // Record + load trend
705 $trend = self::record_and_load_trend($score);
706
707 return [
708 'checks' => $results,
709 'cross' => $cross,
710 'score' => $score,
711 'grade' => $grade,
712 'categories' => array_keys($cats),
713 'severity_counts' => $sev_counts,
714 'context' => $ctx,
715 'trend' => $trend,
716 ];
717 }
718
719 /** Free-tier teaser. */
720 public static function summary(): array {
721 $r = self::run();
722 $passes = 0; $warns = 0; $fails = 0;
723 foreach ($r['checks'] as $c) {
724 if ($c['status'] === 'pass') $passes++;
725 elseif ($c['status'] === 'warn') $warns++;
726 else $fails++;
727 }
728 return [
729 'score' => $r['score'],
730 'grade' => $r['grade'],
731 'passes' => $passes,
732 'warns' => $warns,
733 'fails' => $fails + count($r['cross']),
734 'total' => count($r['checks']) + count($r['cross']),
735 ];
736 }
737
738 // ─────────────────────────────────────────────────────────────────
739 // Live-data corroboration — upgrade severity when reality says so
740 // ─────────────────────────────────────────────────────────────────
741
742 private static function corroborate(array &$results, array $ctx): void {
743 $op = $ctx['live_opcache'] ?? null;
744 $sigs = $ctx['error_signals'] ?? [];
745
746 foreach ($results as &$r) {
747 switch ($r['key']) {
748 case 'memory_limit':
749 if (!empty($sigs['memory_exhausted'])) {
750 $r['live_evidence'] = sprintf(
751 'Error log shows %d recent "Allowed memory size of … exhausted" entr%s. Your current ceiling is being hit.',
752 (int) $sigs['memory_exhausted'],
753 $sigs['memory_exhausted'] === 1 ? 'y' : 'ies'
754 );
755 if ($r['status'] !== 'fail') {
756 $r['status'] = 'fail';
757 $r['severity'] = self::SEV_CRITICAL;
758 $r['severity_label'] = self::severity_label(self::SEV_CRITICAL);
759 }
760 }
761 break;
762
763 case 'max_execution_time':
764 if (!empty($sigs['max_time_exceeded'])) {
765 $r['live_evidence'] = sprintf(
766 'Error log shows %d "Maximum execution time exceeded" entr%s in the last 256 KB.',
767 (int) $sigs['max_time_exceeded'],
768 $sigs['max_time_exceeded'] === 1 ? 'y' : 'ies'
769 );
770 if ($r['status'] === 'pass') $r['status'] = 'warn';
771 elseif ($r['status'] === 'warn') $r['status'] = 'fail';
772 }
773 break;
774
775 case 'max_input_vars':
776 if (!empty($sigs['max_input_vars_exceeded'])) {
777 $r['live_evidence'] = sprintf(
778 'Error log shows %d "Input variables exceeded" entr%s — forms are losing fields silently.',
779 (int) $sigs['max_input_vars_exceeded'],
780 $sigs['max_input_vars_exceeded'] === 1 ? 'y' : 'ies'
781 );
782 $r['status'] = 'fail';
783 $r['severity'] = self::SEV_CRITICAL;
784 $r['severity_label'] = self::severity_label(self::SEV_CRITICAL);
785 }
786 break;
787
788 case 'opcache.memory_consumption':
789 if (is_array($op) && !empty($op['cache_full'])) {
790 $mem = $op['memory_usage'] ?? [];
791 $used = is_array($mem) ? (int) ($mem['used_memory'] ?? 0) : 0;
792 $r['live_evidence'] = sprintf(
793 'OPcache memory is currently FULL (%s used). New scripts can\'t be cached — they recompile every hit.',
794 size_format($used)
795 );
796 if ($r['status'] !== 'fail') {
797 $r['status'] = 'fail';
798 $r['severity'] = self::SEV_HIGH;
799 $r['severity_label'] = self::severity_label(self::SEV_HIGH);
800 }
801 } elseif (is_array($op)) {
802 $stats = $op['opcache_statistics'] ?? [];
803 $hits = is_array($stats) ? (int) ($stats['hits'] ?? 0) : 0;
804 $misses = is_array($stats) ? (int) ($stats['misses'] ?? 0) : 0;
805 if (($hits + $misses) > 0) {
806 $rate = $hits / ($hits + $misses) * 100;
807 if ($rate < 90 && $r['status'] === 'pass') {
808 $r['live_evidence'] = sprintf('OPcache hit rate is %.1f%% (target: 95%%+). Memory is probably undersized.', $rate);
809 $r['status'] = 'warn';
810 $r['severity'] = self::SEV_HIGH;
811 $r['severity_label'] = self::severity_label(self::SEV_HIGH);
812 }
813 }
814 }
815 break;
816
817 case 'upload_max_filesize':
818 case 'post_max_size':
819 if (!empty($sigs['upload_too_large'])) {
820 $r['live_evidence'] = 'Error log shows recent "POST Content-Length exceeds the limit" entries — uploads are being rejected.';
821 if ($r['status'] !== 'fail') {
822 $r['status'] = 'fail';
823 $r['severity'] = self::SEV_HIGH;
824 $r['severity_label'] = self::severity_label(self::SEV_HIGH);
825 }
826 }
827 break;
828 }
829 }
830 unset($r);
831 }
832
833 // ─────────────────────────────────────────────────────────────────
834 // Cross-directive consistency checks
835 // ─────────────────────────────────────────────────────────────────
836
837 private static function consistency_checks(array $v): array {
838 $issues = [];
839 $upload = self::bytes((string) ($v['upload_max_filesize'] ?? '0'));
840 $post = self::bytes((string) ($v['post_max_size'] ?? '0'));
841 $mem = self::bytes((string) ($v['memory_limit'] ?? '-1'));
842 $mit = (int) ($v['max_input_time'] ?? 0);
843 $met = (int) ($v['max_execution_time']?? 0);
844
845 if ($upload > 0 && $post > 0 && $post < $upload) {
846 $issues[] = [
847 'key' => 'cross-post-vs-upload',
848 'label' => 'post_max_size must be ≥ upload_max_filesize',
849 'category' => 'Performance',
850 'severity' => self::SEV_HIGH,
851 'severity_label' => self::severity_label(self::SEV_HIGH),
852 'reason' => sprintf(
853 'post_max_size is %s but upload_max_filesize is %s. PHP rejects uploads as soon as the request body exceeds post_max_size — large files fail before they\'re even checked against upload_max_filesize.',
854 size_format($post), size_format($upload)
855 ),
856 'fix' => sprintf('Raise post_max_size to at least %s (match upload_max_filesize, or slightly higher).', size_format($upload)),
857 ];
858 }
859 if ($mem > 0 && $post > 0 && $mem < $post + 64 * MB_IN_BYTES) {
860 $issues[] = [
861 'key' => 'cross-memory-vs-post',
862 'label' => 'memory_limit should be ≥ post_max_size + 64M headroom',
863 'category' => 'Performance',
864 'severity' => self::SEV_HIGH,
865 'severity_label' => self::severity_label(self::SEV_HIGH),
866 'reason' => sprintf(
867 'memory_limit is %s and post_max_size is %s. PHP needs memory_limit ≥ post_max_size + parsing headroom or large uploads OOM mid-request.',
868 size_format($mem), size_format($post)
869 ),
870 'fix' => sprintf('Raise memory_limit to at least %s.', size_format($post + 128 * MB_IN_BYTES)),
871 ];
872 }
873 if ($mit > 0 && $met > 0 && $mit > $met) {
874 $issues[] = [
875 'key' => 'cross-input-vs-exec',
876 'label' => 'max_input_time must not exceed max_execution_time',
877 'category' => 'Performance',
878 'severity' => self::SEV_MEDIUM,
879 'severity_label' => self::severity_label(self::SEV_MEDIUM),
880 'reason' => sprintf(
881 'max_input_time is %ds but max_execution_time is %ds. max_input_time counts against max_execution_time — when max_input_time is larger, you get fewer effective execution seconds than you think.',
882 $mit, $met
883 ),
884 'fix' => sprintf('Either lower max_input_time to ≤ %ds, or raise max_execution_time to ≥ %ds.', $met, $mit),
885 ];
886 }
887 return $issues;
888 }
889
890 // ─────────────────────────────────────────────────────────────────
891 // Host-aware remediation hints
892 // ─────────────────────────────────────────────────────────────────
893
894 public static function host_fix_hint(array $ctx, string $directive): ?array {
895 $host = $ctx['host'] ?? null;
896 if (!$host) return null;
897 static $matrix = null;
898 if ($matrix === null) {
899 $matrix = [
900 'kinsta' => [
901 '*' => [
902 'panel' => 'MyKinsta',
903 'url' => 'https://my.kinsta.com',
904 'how' => 'Kinsta sets PHP limits at the platform level. Open MyKinsta → Sites → [your site] → PHP Engine. Memory and execution-time limits are tied to your plan tier; for higher limits open a support ticket from MyKinsta.',
905 ],
906 ],
907 'wpengine' => [
908 '*' => [
909 'panel' => 'WP Engine User Portal',
910 'url' => 'https://my.wpengine.com',
911 'how' => 'WP Engine manages php.ini at the platform level — most directives are read-only. Open a chat support ticket from the User Portal requesting the directive change.',
912 ],
913 ],
914 'siteground' => [
915 '*' => [
916 'panel' => 'Site Tools',
917 'url' => 'https://my.siteground.com',
918 'how' => 'Site Tools → Devs → PHP Manager → PHP Variables. Find the directive, edit the value, click Confirm. Changes apply within ~1 minute.',
919 ],
920 ],
921 'cloudways' => [
922 '*' => [
923 'panel' => 'Cloudways Platform',
924 'url' => 'https://platform.cloudways.com',
925 'how' => 'Application Settings → PHP FPM Settings (top right). Edit the value and click Save Changes. Restart PHP-FPM after via Server Management → Manage Services.',
926 ],
927 ],
928 'pantheon' => [
929 '*' => [
930 'panel' => 'Pantheon Dashboard',
931 'url' => 'https://dashboard.pantheon.io',
932 'how' => 'Add a pantheon.yml entry under php_version (for version) or commit a custom php.ini in private/ — see Pantheon docs for ini-set policies.',
933 ],
934 ],
935 'flywheel' => [
936 '*' => [
937 'panel' => 'Flywheel',
938 'url' => 'https://app.getflywheel.com',
939 'how' => 'Most ini values are platform-managed. Submit a ticket through the Flywheel app for memory/exec-time raises.',
940 ],
941 ],
942 'liquidweb' => [
943 '*' => [
944 'panel' => 'LiquidWeb Manage',
945 'url' => 'https://my.liquidweb.com',
946 'how' => 'For Managed WordPress: open a support chat. For Cloud Sites: edit php.ini under your site\'s php-fpm pool, then restart PHP.',
947 ],
948 ],
949 'litespeed' => [
950 '*' => [
951 'panel' => 'LiteSpeed WebAdmin',
952 'url' => null,
953 'how' => 'LiteSpeed reads .htaccess php_value directives just like Apache. Our auto-fix can write these for you if .htaccess is writable.',
954 ],
955 ],
956 ];
957 }
958 return $matrix[$host]['*'] ?? null;
959 }
960
961 // ─────────────────────────────────────────────────────────────────
962 // Trend tracking — last 30 days of scores
963 // ─────────────────────────────────────────────────────────────────
964
965 private static function record_and_load_trend(int $current_score): array {
966 $hist = (array) get_option(self::OPT_HISTORY, []);
967 $today = wp_date('Y-m-d');
968 // Only update once per day to avoid noise
969 $changed = !isset($hist[$today]) || $hist[$today] !== $current_score;
970 if ($changed) {
971 $hist[$today] = $current_score;
972 if (count($hist) > 30) $hist = array_slice($hist, -30, null, true);
973 update_option(self::OPT_HISTORY, $hist, false);
974 }
975 return self::trend_from_history($hist, $current_score);
976 }
977
978 private static function trend_from_history(array $hist, int $current): array {
979 if (count($hist) < 2) {
980 return ['available' => false, 'current' => $current, 'history' => $hist];
981 }
982 $vals = array_values($hist);
983 $prev = $vals[count($vals) - 2];
984 return [
985 'available' => true,
986 'current' => $current,
987 'previous' => (int) $prev,
988 'delta' => $current - (int) $prev,
989 'history' => $hist,
990 'arrow' => $current > $prev ? 'up' : ($current < $prev ? 'down' : 'flat'),
991 ];
992 }
993
994 // ─────────────────────────────────────────────────────────────────
995 // Utility helpers
996 // ─────────────────────────────────────────────────────────────────
997
998 private static function grade(int $score): string {
999 if ($score >= 95) return 'A+';
1000 if ($score >= 85) return 'A';
1001 if ($score >= 75) return 'B';
1002 if ($score >= 60) return 'C';
1003 if ($score >= 45) return 'D';
1004 return 'F';
1005 }
1006
1007 public static function severity_label(string $sev): string {
1008 switch ($sev) {
1009 case self::SEV_CRITICAL:
1010 return 'Critical';
1011 case self::SEV_HIGH:
1012 return 'High';
1013 case self::SEV_MEDIUM:
1014 return 'Medium';
1015 case self::SEV_LOW:
1016 return 'Low';
1017 default:
1018 return ucfirst($sev);
1019 }
1020 }
1021
1022 public static function severity_color(string $sev): string {
1023 switch ($sev) {
1024 case self::SEV_CRITICAL:
1025 return '#d63638';
1026 case self::SEV_HIGH:
1027 return '#dba617';
1028 case self::SEV_MEDIUM:
1029 return '#777BB3';
1030 case self::SEV_LOW:
1031 return '#646970';
1032 default:
1033 return '#646970';
1034 }
1035 }
1036
1037 // Convert shorthand (256M, 1G) to bytes
1038 private static function bytes(string $val): int {
1039 $val = trim($val);
1040 if ($val === '') return 0;
1041 $last = strtolower(substr($val, -1));
1042 $num = (int) $val;
1043 switch ($last) {
1044 case 'g': return $num * GB_IN_BYTES;
1045 case 'm': return $num * MB_IN_BYTES;
1046 case 'k': return $num * KB_IN_BYTES;
1047 }
1048 return $num;
1049 }
1050 }
1051