Skip to content

Instantly share code, notes, and snippets.

@cagrimmett
Created December 18, 2025 20:02
Show Gist options
  • Select an option

  • Save cagrimmett/acb474e6380d416e9e327165be6fd48e to your computer and use it in GitHub Desktop.

Select an option

Save cagrimmett/acb474e6380d416e9e327165be6fd48e to your computer and use it in GitHub Desktop.
wp-cli syntax checker
<?php
/**
* Plugin Name: PHP Syntax Linter
* Description: WP-CLI command to check PHP syntax across WordPress installations
* Version: 1.0.0
* Author: Your Organization
* License: GPL v2 or later
* Requires PHP: 7.4
* WP-CLI: true
*/
// Prevent direct access
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// Only load if WP-CLI is present
if ( defined( 'WP_CLI' ) && WP_CLI ) {
class WP_PHP_Syntax_Checker {
private $log_file;
private $php_bin;
private $start_time;
private $total_errors = 0;
private $results = []; // Store results for JSON output
/**
* Check PHP syntax across WordPress installation
*
* ## OPTIONS
*
* [--php=<version>]
* : PHP version to use for syntax checking
* ---
* default: 8.4
* options:
* - 8.1
* - 8.2
* - 8.3
* - 8.4
* ---
*
* [--batch-size=<number>]
* : Number of files to process per batch (helps with memory)
* ---
* default: 25
* ---
*
* ## EXAMPLES
*
* wp syntax-check run
* wp syntax-check run --php=8.1
* wp syntax-check run --php=8.2 --batch-size=15
*
* @when after_wp_load
*/
public function run( $args, $assoc_args ) {
$php_version = $assoc_args['php'] ?? '8.4';
$batch_size = intval( $assoc_args['batch-size'] ?? 25 );
// Validation
$valid_versions = [ '8.1', '8.2', '8.3', '8.4' ];
if ( ! in_array( $php_version, $valid_versions, true ) ) {
WP_CLI::error( "Invalid PHP version: $php_version. Valid: " . implode( ', ', $valid_versions ) );
}
$this->php_bin = "/usr/local/php{$php_version}/bin/php";
if ( ! file_exists( $this->php_bin ) ) {
WP_CLI::error( "PHP binary not found: {$this->php_bin}" );
}
if ( $batch_size < 5 || $batch_size > 100 ) {
WP_CLI::error( "Batch size must be between 5 and 100" );
}
// Setup log file in uploads with consistent naming
$upload_dir = wp_upload_dir();
$domain = $this->get_domain();
$this->log_file = $upload_dir['basedir'] . "/php-syntax-check_{$domain}_{$php_version}.json";
$this->start_time = microtime( true );
// Initialize results structure
$this->results = [
'domain' => $domain,
'php_version' => $php_version,
'timestamp' => gmdate( 'Y-m-d\TH:i:s\Z' ),
'batch_size' => $batch_size,
'directories' => [],
'summary' => [
'total_files' => 0,
'total_errors' => 0,
'duration_seconds' => 0
],
'errors' => [],
'status' => 'running'
];
// Write initial JSON to track progress
file_put_contents( $this->log_file, wp_json_encode( $this->results, JSON_PRETTY_PRINT ), LOCK_EX );
WP_CLI::log( "Starting PHP {$php_version} syntax check" );
WP_CLI::log( "Results file: {$this->log_file}" );
// Process wp-content directories separately to manage memory
$directories = [
'Themes' => get_theme_root(),
'Plugins' => WP_PLUGIN_DIR,
'Must-Use Plugins' => WPMU_PLUGIN_DIR,
'Uploads' => $upload_dir['basedir'],
];
foreach ( $directories as $dir_name => $dir_path ) {
if ( is_dir( $dir_path ) ) {
$this->process_directory( $dir_name, $dir_path, $batch_size );
// Force garbage collection between directories
if ( function_exists( 'gc_collect_cycles' ) ) {
gc_collect_cycles();
}
}
}
$this->finish_log();
if ( $this->total_errors > 0 ) {
WP_CLI::warning( "Found {$this->total_errors} files with syntax errors. Check log: {$this->log_file}" );
} else {
WP_CLI::success( "No syntax errors found! Log: {$this->log_file}" );
}
}
/**
* Process a single directory
*/
private function process_directory( $dir_name, $dir_path, $batch_size ) {
WP_CLI::log( "Processing {$dir_name}..." );
$files = $this->find_php_files( $dir_path );
$total_files = count( $files );
// Initialize directory result
$dir_result = [
'name' => $dir_name,
'path' => $dir_path,
'total_files' => $total_files,
'processed_files' => 0,
'error_count' => 0,
'errors' => []
];
if ( $total_files === 0 ) {
$this->results['directories'][] = $dir_result;
return;
}
// Process in small batches to avoid memory issues
$batches = array_chunk( $files, $batch_size );
$processed = 0;
$progress = WP_CLI\Utils\make_progress_bar( $dir_name, $total_files );
foreach ( $batches as $batch ) {
$batch_errors = $this->process_batch( $batch );
$dir_result['errors'] = array_merge( $dir_result['errors'], $batch_errors );
$processed += count( $batch );
$progress->tick( count( $batch ) );
// Small delay and memory cleanup between batches
usleep( 50000 ); // 0.05 seconds
if ( function_exists( 'gc_collect_cycles' ) ) {
gc_collect_cycles();
}
}
$progress->finish();
$dir_result['processed_files'] = $processed;
$dir_result['error_count'] = count( $dir_result['errors'] );
// Add directory result and update totals
$this->results['directories'][] = $dir_result;
$this->results['summary']['total_files'] += $processed;
$this->results['errors'] = array_merge( $this->results['errors'], $dir_result['errors'] );
$this->total_errors += $dir_result['error_count'];
}
/**
* Find PHP files in directory
*/
private function find_php_files( $directory ) {
// Exclude common directories that don't need checking
$exclude_patterns = [
'node_modules',
'vendor',
'.git',
'cache',
'backups',
'logs'
];
// Add managed plugin exclusions if we're in the plugins directory
if ( strpos( $directory, 'plugins' ) !== false ) {
$managed_plugins = [ 'jetpack', 'akismet', 'classic-editor' ];
$exclude_patterns = array_merge( $exclude_patterns, $managed_plugins );
}
$find_parts = [ 'find', '-L', escapeshellarg( $directory ) ];
// Add exclusion patterns
foreach ( $exclude_patterns as $pattern ) {
$find_parts[] = '-path';
$find_parts[] = escapeshellarg( "*/{$pattern}/*" );
$find_parts[] = '-prune';
$find_parts[] = '-o';
}
$find_parts[] = '-name';
$find_parts[] = escapeshellarg( '*.php' );
$find_parts[] = '-type';
$find_parts[] = 'f';
$find_parts[] = '-print0';
$find_parts[] = '2>/dev/null';
$find_cmd = implode( ' ', $find_parts );
$file_list = shell_exec( $find_cmd );
if ( ! $file_list ) {
return [];
}
return array_filter( explode( "\0", trim( $file_list ) ) );
}
/**
* Process a batch of files and return only errors
*/
private function process_batch( $files ) {
$errors = [];
foreach ( $files as $filepath ) {
if ( empty( $filepath ) ) {
continue;
}
// Security check
$real_path = realpath( $filepath );
if ( ! $real_path || ! file_exists( $real_path ) ) {
continue;
}
// Use proc_open for reliable execution
$descriptors = [
0 => [ 'pipe', 'r' ],
1 => [ 'pipe', 'w' ],
2 => [ 'pipe', 'w' ],
];
$process = proc_open(
[ $this->php_bin, '-l', $real_path ],
$descriptors,
$pipes,
null,
[ 'LC_ALL' => 'C' ]
);
if ( is_resource( $process ) ) {
fclose( $pipes[0] );
$stdout = stream_get_contents( $pipes[1] );
$stderr = stream_get_contents( $pipes[2] );
fclose( $pipes[1] );
fclose( $pipes[2] );
$exit_code = proc_close( $process );
// Only process errors, skip successful files
if ( $exit_code !== 0 ) {
$relative_path = str_replace( ABSPATH, '', $real_path );
$error_msg = trim( $stderr . $stdout );
$error_entry = [
'file' => $relative_path,
'error' => $error_msg,
'exit_code' => $exit_code
];
$errors[] = $error_entry;
}
}
}
return $errors;
}
/**
* Write JSON results to log file
*/
private function log( $data ) {
// For JSON output, we'll write the complete results at the end
// This method is kept for compatibility but won't write individual lines
}
/**
* Finish the log with complete JSON output
*/
private function finish_log() {
$end_time = microtime( true );
$duration = round( $end_time - $this->start_time, 2 );
// Complete the results with all required fields
$this->results['summary']['total_files'] = $this->results['summary']['total_files'] ?? 0;
$this->results['summary']['total_errors'] = $this->total_errors;
$this->results['summary']['duration_seconds'] = $duration;
$this->results['status'] = 'completed';
// Ensure all expected fields are present
$this->results['domain'] = $this->results['domain'] ?? $this->get_domain();
$this->results['php_version'] = $this->results['php_version'] ?? '8.4';
$this->results['timestamp'] = $this->results['timestamp'] ?? gmdate( 'Y-m-d\TH:i:s\Z' );
$this->results['batch_size'] = $this->results['batch_size'] ?? 25;
$this->results['directories'] = $this->results['directories'] ?? [];
$this->results['errors'] = $this->results['errors'] ?? [];
// Write complete JSON to log file
$json_output = wp_json_encode( $this->results, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES );
if ( $json_output === false ) {
// Fallback if JSON encoding fails
$json_output = json_encode( $this->results, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES );
}
file_put_contents( $this->log_file, $json_output, LOCK_EX );
}
/**
* Get domain for identification
*/
private function get_domain() {
// Try various methods to get domain
if ( defined( 'WP_HOME' ) ) {
$host = parse_url( WP_HOME, PHP_URL_HOST );
if ( $host ) return $host;
}
if ( isset( $_SERVER['HTTP_HOST'] ) ) {
return $_SERVER['HTTP_HOST'];
}
$siteurl = get_option( 'siteurl' );
if ( $siteurl ) {
$host = parse_url( $siteurl, PHP_URL_HOST );
if ( $host ) return $host;
}
// Fallback to hostname, sanitized for filename
$hostname = gethostname();
return preg_replace( '/[^a-zA-Z0-9.-]/', '_', $hostname );
}
}
WP_CLI::add_command( 'syntax-check', 'WP_PHP_Syntax_Checker' );
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment