Troubleshooting hook execution order overrides in production when using modern Sage Roots modern environments wrappers
Diagnosing Intermittent Hook Execution Order Issues in Sage 10+ Environments
Production environments, especially those running complex e-commerce platforms built on modern frameworks like Sage 10+ with its associated wrappers (e.g., Bedrock, Roots.io’s ecosystem), can present unique challenges when debugging. One particularly insidious problem is the intermittent or seemingly random failure of actions and filters (hooks) to execute in the expected order. This often manifests as data corruption, incorrect UI rendering, or features failing to activate under specific, hard-to-reproduce conditions. The root cause is frequently a subtle override or conflict in how WordPress’s hook system interacts with the layered architecture of these modern development stacks.
Understanding the Sage 10+ Hook Execution Landscape
Sage 10+, built on Laravel’s Blade templating engine and leveraging Composer for dependency management, introduces a more structured approach to WordPress development. This structure, while beneficial for maintainability and scalability, can also introduce new points of failure for hook execution. Key components that influence hook order include:
- Composer Autoloading: PHP’s autoloader, managed by Composer, dictates when classes are loaded. If a hook callback relies on a class that hasn’t been loaded yet, it can lead to fatal errors or, more subtly, the hook not executing as intended if the class is loaded *after* the hook fires.
- Service Container (via Acorn): Sage 10+ uses Acorn to integrate Laravel’s Service Container. Services are registered and booted, and their associated hooks might be registered at different stages of the WordPress lifecycle.
- Theme/Plugin Initialization Order: The traditional WordPress `functions.php` or plugin main files are still involved, but their execution is now often managed or influenced by Composer’s autoloader and the framework’s bootstrapping process.
- Conditional Hook Registration: Developers might register hooks conditionally based on user roles, URL parameters, or other runtime factors. If these conditions are met inconsistently in production, it can lead to intermittent hook failures.
Production Debugging Strategy: Beyond `WP_DEBUG`
Standard `WP_DEBUG` flags are essential but often insufficient for diagnosing these complex, environment-specific issues. We need a more granular approach. The primary goal is to pinpoint *when* and *why* a specific hook is being registered or executed out of sequence.
1. Enhanced Hook Logging
The most direct method is to instrument the WordPress hook system itself. We can create a temporary, production-safe logging mechanism to track hook registrations and executions. This should be enabled only when actively debugging a specific issue.
First, let’s create a simple logging utility. This could be a dedicated class or a set of functions. For this example, we’ll use a basic file-based logger. Ensure the `wp-content/uploads` directory is writable by the web server.
1.1. Logging Utility Implementation
Add this to a temporary file, perhaps in a custom plugin or a dedicated debugging theme file that’s only activated when needed. Remember to remove it after debugging.
<?php
/**
* Simple Production Hook Logger
*
* Logs hook registrations and executions for debugging.
* Enable by defining DEBUG_HOOKS in wp-config.php.
*/
if ( ! defined( 'DEBUG_HOOKS' ) || ! DEBUG_HOOKS ) {
return;
}
class ProductionHookLogger {
private static $log_file;
private static $instance;
private function __construct() {
// Ensure log directory exists and is writable
$upload_dir = wp_upload_dir();
$log_dir = trailingslashit( $upload_dir['basedir'] ) . 'hook-logs';
if ( ! wp_mkdir_p( $log_dir ) ) {
error_log( 'ProductionHookLogger: Could not create log directory: ' . $log_dir );
return;
}
self::$log_file = trailingslashit( $log_dir ) . 'hook-execution.log';
}
public static function getInstance() {
if ( self::$instance === null ) {
self::$instance = new self();
}
return self::$instance;
}
public static function log( $message, $level = 'INFO' ) {
$logger = self::getInstance();
if ( ! $logger || ! self::$log_file ) {
return;
}
$timestamp = current_time( 'Y-m-d H:i:s' );
$pid = getmypid();
$request_uri = isset( $_SERVER['REQUEST_URI'] ) ? $_SERVER['REQUEST_URI'] : 'CLI';
$log_entry = sprintf(
"[%s] [PID:%d] [%s] [%s] %s%s",
$timestamp,
$pid,
$level,
$request_uri,
$message,
PHP_EOL
);
// Use file_put_contents with LOCK_EX for basic concurrency safety
file_put_contents( self::$log_file, $log_entry, FILE_APPEND | LOCK_EX );
}
public static function registerHook( $tag, $function, $priority, $accepted_args ) {
$function_info = is_string( $function ) ? $function : ( is_array( $function ) ? ( is_object( $function[0] ) ? get_class( $function[0] ) . '::' . $function[1] : $function[0] . '::' . $function[1] ) : 'Closure' );
$message = sprintf(
'REGISTERED HOOK: Tag="%s", Function="%s", Priority=%d, AcceptedArgs=%d',
$tag,
$function_info,
$priority,
$accepted_args
);
self::log( $message, 'REGISTER' );
}
public static function executeHook( $tag, $function, $args ) {
$function_info = is_string( $function ) ? $function : ( is_array( $function ) ? ( is_object( $function[0] ) ? get_class( $function[0] ) . '::' . $function[1] : $function[0] . '::' . $function[1] ) : 'Closure' );
$message = sprintf(
'EXECUTING HOOK: Tag="%s", Function="%s", Args=%s',
$tag,
$function_info,
wp_json_encode( $args ) // Log args for context, be mindful of sensitive data
);
self::log( $message, 'EXECUTE' );
}
public static function clearLog() {
$logger = self::getInstance();
if ( ! $logger || ! self::$log_file ) {
return;
}
if ( file_exists( self::$log_file ) ) {
file_put_contents( self::$log_file, '' ); // Truncate the file
}
}
}
// --- Hook Wrapping ---
// Override add_action and add_filter to log registrations
// This is a bit of a hack, but effective for debugging.
// Ensure this runs *after* WordPress core and plugins have potentially added their hooks.
// A late-running action hook is ideal.
add_action( 'plugins_loaded', function() {
// Temporarily replace global functions
global $wp_filter;
// Store original functions
$original_add_action = 'add_action';
$original_add_filter = 'add_filter';
// Define wrapper functions
$wrapper_add_action = function( $tag, $function_to_add, $priority = 10, $accepted_args = 1 ) {
// Log the registration
ProductionHookLogger::registerHook( $tag, $function_to_add, $priority, $accepted_args );
// Call the original add_action
// Use call_user_func_array to handle potential array arguments for $function_to_add
return call_user_func_array( 'add_action', func_get_args() );
};
$wrapper_add_filter = function( $tag, $function_to_add, $priority = 10, $accepted_args = 1 ) {
// Log the registration
ProductionHookLogger::registerHook( $tag, $function_to_add, $priority, $accepted_args );
// Call the original add_filter
return call_user_func_array( 'add_filter', func_get_args() );
};
// Replace global functions with wrappers
// Note: This is highly intrusive and should be used with extreme caution.
// It's generally better to hook into WordPress's internal filter system if possible,
// but for direct debugging of add_action/add_filter calls, this is effective.
// A more robust solution might involve a custom autoloader or a dedicated plugin.
// For demonstration, we'll assume this code runs early enough to wrap subsequent calls.
// In a real scenario, you might need to ensure this runs *after* all other plugins/themes
// have registered their hooks, which is tricky.
// A more targeted approach: Hook into the internal WP_Hook class if possible,
// but that's more complex and subject to WP internal changes.
// For simplicity and directness in this example, we'll rely on this code running
// and then subsequent calls to add_action/add_filter being intercepted.
// This is best achieved by placing this code in a file that's included very early,
// or by using a mechanism that can override global functions.
// The most reliable way to achieve this level of introspection without global function overrides
// is to hook into the `{$tag}.added_filter` and `{$tag}.added_action` internal WP hooks,
// but these are not officially documented and can change.
// Let's refine: Instead of overriding global functions, we'll hook into the internal `WP_Hook`
// object's `add_filter` and `add_action` methods if we can access them.
// This is still complex.
// A simpler, albeit less comprehensive, approach:
// Hook into `all_actions` and `all_filters` to log *executions*, not registrations.
// This is what we'll implement below.
}, 9999 ); // Run very late
// --- Hook Execution Logging ---
// Log when any action is executed
add_action( 'all_actions', function( $tag ) {
global $wp_filter;
if ( ! isset( $wp_filter[ $tag ] ) ) {
return;
}
// Iterate through priorities and functions for this tag
foreach ( $wp_filter[ $tag ]->callbacks as $priority => $callbacks ) {
foreach ( $callbacks as $callback_id => $callback_data ) {
$function = $callback_data['function'];
$accepted_args = $callback_data['accepted_args'];
// Log the execution of each callback
// Note: This logs *every* time a hook is executed, which can be very noisy.
// Filter this log heavily in production.
$function_info = is_string( $function ) ? $function : ( is_array( $function ) ? ( is_object( $function[0] ) ? get_class( $function[0] ) . '::' . $function[1] : $function[0] . '::' . $function[1] ) : 'Closure' );
$message = sprintf(
'ACTION EXECUTED: Tag="%s", Function="%s", Priority=%d, AcceptedArgs=%d',
$tag,
$function_info,
$priority,
$accepted_args
);
ProductionHookLogger::log( $message, 'ACTION' );
}
}
}, 9999, 1 ); // Run very late
// Log when any filter is executed
add_action( 'all_filters', function( $tag ) {
global $wp_filter;
if ( ! isset( $wp_filter[ $tag ] ) ) {
return;
}
// Iterate through priorities and functions for this tag
foreach ( $wp_filter[ $tag ]->callbacks as $priority => $callbacks ) {
foreach ( $callbacks as $callback_id => $callback_data ) {
$function = $callback_data['function'];
$accepted_args = $callback_data['accepted_args'];
// Log the execution of each callback
$function_info = is_string( $function ) ? $function : ( is_array( $function ) ? ( is_object( $function[0] ) ? get_class( $function[0] ) . '::' . $function[1] : $function[0] . '::' . $function[1] ) : 'Closure' );
$message = sprintf(
'FILTER EXECUTED: Tag="%s", Function="%s", Priority=%d, AcceptedArgs=%d',
$tag,
$function_info,
$priority,
$accepted_args
);
ProductionHookLogger::log( $message, 'FILTER' );
}
}
}, 9999, 1 ); // Run very late
// --- Utility Functions ---
// Helper to clear the log file (e.g., via a WP-CLI command or admin page)
function debug_clear_hook_log() {
ProductionHookLogger::clearLog();
echo "Hook log cleared.\n";
}
// Example WP-CLI command registration (optional)
if ( defined( 'WP_CLI' ) && WP_CLI ) {
WP_CLI::add_command( 'debug hook-log clear', 'debug_clear_hook_log' );
}
// Define DEBUG_HOOKS in wp-config.php to enable this logger:
// define( 'DEBUG_HOOKS', true );
?>
Explanation:
- The `ProductionHookLogger` class provides a simple, file-based logging mechanism.
- It logs messages with timestamps, process IDs, and the current request URI for context.
- The `registerHook` and `executeHook` methods are placeholders for more detailed logging of hook registration and execution.
- Crucially, we use the `all_actions` and `all_filters` hooks. These are executed *after* every action and filter, respectively. By hooking into them at a very high priority (e.g., 9999), we can inspect the `$wp_filter` global array to see which callbacks are registered for a given hook and log their execution.
- Important: This approach logs *every* hook execution. In a busy production site, this will generate a massive log file very quickly. It’s essential to enable this logging only when actively troubleshooting a specific issue and to filter the logs aggressively.
- A WP-CLI command `wp debug hook-log clear` is included to easily clear the log file.
1.2. Enabling and Using the Logger
1. **Define `DEBUG_HOOKS`:** Add `define( ‘DEBUG_HOOKS’, true );` to your `wp-config.php` file. This should be done carefully, ideally through a deployment pipeline or a controlled update. 2. **Deploy the Logger Code:** Place the PHP code above into a file that will be included during WordPress’s initialization. A custom plugin is ideal, or a file within your theme that’s conditionally loaded. 3. **Reproduce the Issue:** Trigger the scenario in production where the hook execution order problem occurs. 4. **Analyze the Log:** Access the log file at `wp-content/uploads/hook-logs/hook-execution.log`. Look for the sequence of events around the problematic hook. Pay attention to:
- The order in which your expected hook callbacks are registered.
- The order in which they are executed.
- Any unexpected hooks firing or missing.
- The priorities assigned to each callback.
2. Inspecting Composer Autoloading and Service Bootstrapping
In Sage 10+, Composer’s autoloader plays a critical role. If a hook callback depends on a class that hasn’t been loaded yet, it can fail. Similarly, Acorn’s service container boots services at specific times. If a hook is registered by a service that boots too late, it might miss earlier actions.
2.1. Verifying Class Loading
Use Composer’s `dump-autoload` command with the `–optimize` flag in your production build process. This ensures the autoloader is as efficient as possible. However, for debugging, you might temporarily disable optimization to see if it reveals loading order issues.
# In your project root, after deploying code changes composer dump-autoload # Or for more verbose output during debugging: composer dump-autoload -o # -o enables optimization
If you suspect a class loading issue, you can add temporary `class_exists()` checks or even `require_once` statements before hook registrations that depend on those classes. This is a workaround, not a fix, but it helps isolate the problem.
2.2. Tracing Service Container Boot Order
Acorn registers services during the WordPress lifecycle. You can add logging within your service provider’s `register()` and `boot()` methods to understand when they are initialized.
// Example: app/Providers/MyCustomServiceProvider.php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use Psr\Log\LoggerInterface; // Assuming you have PSR-3 logging configured
class MyCustomServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*
* @return void
*/
public function register()
{
// Log when the service provider is being registered
if ( $this->app->bound( LoggerInterface::class ) ) {
$this->app->make( LoggerInterface::class )->debug( 'MyCustomServiceProvider registered.' );
} else {
error_log( 'MyCustomServiceProvider registered (no PSR-3 logger available).' );
}
// Register your services here...
}
/**
* Bootstrap any application services.
*
* @return void
*/
public function boot()
{
// Log when the service provider is booting
if ( $this->app->bound( LoggerInterface::class ) ) {
$this->app->make( LoggerInterface::class )->debug( 'MyCustomServiceProvider booting.' );
} else {
error_log( 'MyCustomServiceProvider booting (no PSR-3 logger available).' );
}
// Register hooks here, or ensure they are registered by other services
// that are guaranteed to boot before or during this provider's boot.
add_action( 'some_early_action', [$this, 'handleEarlyAction'] );
}
public function handleEarlyAction() {
// ...
}
}
By correlating these service provider logs with the hook execution logs, you can determine if a hook is being registered too late because its service provider hasn’t booted yet.
3. Analyzing Plugin and Theme Initialization Order
WordPress loads plugins before themes. However, the order in which plugins are loaded can be influenced by their filenames (alphabetical) and the `active_plugins` option in the database. Sage 10+ often uses Bedrock, which manages plugin loading via Composer. Understanding this order is crucial.
3.1. Debugging Plugin Load Order
If you’re using Bedrock, plugins are typically managed in `config/application.php` or via Composer’s `extra.wordpress.plugins` configuration. Ensure that plugins responsible for critical hooks are loaded in an order that respects their dependencies.
// Example: config/application.php (Bedrock)
return [
'plugins' => [
'advanced-custom-fields/acf.php', // Example: ACF might need to load early
'woocommerce/woocommerce.php', // Example: WooCommerce
'my-custom-plugin/my-custom-plugin.php', // Your plugin
],
// ... other config
];
If you’re not using Bedrock’s explicit plugin management, the order is determined by the `active_plugins` array in the database. You can inspect this using WP-CLI:
wp option get active_plugins
And modify it (with extreme caution):
# Example: Move 'my-custom-plugin/my-custom-plugin.php' to the beginning wp option update active_plugins '["my-custom-plugin/my-custom-plugin.php","advanced-custom-fields/acf.php","woocommerce/woocommerce.php"]' --format=json
3.2. Theme Initialization Hooks
Sage themes typically hook into `after_setup_theme` and `init`. If your theme’s hooks depend on functionality provided by plugins that are initialized *after* these hooks fire, you’ll encounter issues. The `plugins_loaded` hook is generally a safer bet for actions that need to run after all plugins are loaded but before WordPress is fully initialized.
4. Using `remove_action` and `remove_filter` Strategically
If you identify a specific hook that is being added multiple times or by a plugin/theme that shouldn’t be, you can use `remove_action` or `remove_filter` to disable unwanted callbacks. This requires knowing the exact tag, the function name (or object and method), and the priority.
// Example: Remove a specific action added by another plugin
// Ensure this runs *after* the action you want to remove has been added.
add_action( 'init', function() {
// Remove the 'some_plugin_callback' function from the 'init' hook
// if it was added with priority 10 and accepts 1 argument.
remove_action( 'init', 'some_plugin_callback', 10 );
// If it's a method on an object, you need the object instance:
// global $some_plugin_instance;
// remove_action( 'init', [$some_plugin_instance, 'some_method'], 10 );
}, 20 ); // Run after the original action is likely added
The key here is to ensure your `remove_action` call happens *after* the action/filter you want to remove has been registered. Use the hook logging from step 1 to determine the correct priority and function signature.
Conclusion: A Systematic Approach
Troubleshooting hook execution order in complex Sage 10+ environments requires a systematic approach that goes beyond basic debugging. By implementing granular logging, understanding the interplay of Composer, Acorn, and WordPress’s core, and carefully analyzing initialization orders, you can effectively diagnose and resolve these often elusive production issues. Remember to disable all debugging tools and logs once the problem is resolved to maintain production performance.