Always hook load_plugin_textdomain in WordPress

Right before the winter break we started chasing an odd error that we encountered with the Simply Static plugin on one of our WordPress multisite networks. Aside–great plugin, highly recommend! The problem was this: with both it and its pro counterpart activated, we received a white screen of death on the Site Editor, with this stack trace:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<b>Fatal error</b>:  Uncaught Error: Undefined constant "SECURE_AUTH_COOKIE" in /var/www/html/wp-includes/pluggable.php:929
Stack trace:
#0 /var/www/html/wp-includes/pluggable.php(694): wp_parse_auth_cookie(false, '')
#1 /var/www/html/wp-includes/class-wp-hook.php(324): wp_validate_auth_cookie(false)
#2 /var/www/html/wp-includes/plugin.php(205): WP_Hook-&gt;apply_filters(false, Array)
#3 /var/www/html/wp-includes/user.php(3628): apply_filters('determine_curre...', false)
#4 /var/www/html/wp-includes/pluggable.php(70): _wp_get_current_user()
#5 /var/www/html/wp-includes/l10n.php(98): wp_get_current_user()
#6 /var/www/html/wp-includes/l10n.php(152): get_user_locale()
#7 /var/www/html/wp-includes/l10n.php(947): determine_locale()
#8 /var/www/html/wp-content/plugins/simply-static/simply-static.php(32): load_plugin_textdomain('simply-static', false, 'simply-static/l...')
#9 /var/www/html/wp-settings.php(418): include_once('/var/www/html/w...')
#10 /var/www/html/wp-config.php(130): require_once('/var/www/html/w...')
#11 /var/www/html/wp-load.php(50): require_once('/var/www/html/w...')
#12 /var/www/html/wp-admin/admin.php(34): require_once('/var/www/html/w...')
#13 /var/www/html/wp-admin/site-editor.php(12): require_once('/var/www/html/w...')
#14 {main}

Googling SECURE_AUTH_COOKIE undefined constant errors isn’t entirely helpful; there’s no one cause. Moving to PHP 8 surfaced many undefined constant errors that had flown under the radar before (that’s good). We didn’t think that there was anything wrong with our cookie setup–we didn’t define COOKIEHASH or SECURE_AUTH_COOKIE in our wp-config.php, but you don’t need to either.

We suspected an order of operations issue–somewhere, somehow, Simply Static is checking the cookies before they’re defined. The relevant part of the stack track is the eighth step:

1
#8 /var/www/html/wp-content/plugins/simply-static/simply-static.php(32): load_plugin_textdomain('simply-static', false, 'simply-static/l...')

Let’s look at that in version 3.1.3 of the plugin, the most recent as of writing:

1
2
$textdomain_dir = plugin_basename( dirname( __FILE__ ) ) . '/languages';
load_plugin_textdomain( 'simply-static', false, $textdomain_dir );

load_plugin_textdomain is a core WordPress function that loads string translations. There isn’t a recommended implementation for this function, though the top comment by Fahad Alduraibi gave us the clue we needed. Alduraibi suggested hooking into the init action instead of plugins_loaded because the latter was too early in the process (joost de keijzer suggests using after_theme_setup instead.)

In this version of the plugin, load_plugin_textdomain isn’t hooked at all, so it’s executing very early indeed. As an initial step, we wrapped the call in a function and hooked in to init:

1
2
3
4
5
function simply_static_load_textdomain() {
$textdomain_dir = plugin_basename( dirname( __FILE__ ) ) . '/languages';
load_plugin_textdomain( 'simply-static', false, $textdomain_dir );
}
add_action( 'init', 'simply_static_load_textdomain' );

This cleared up the immediate issue of the white screen of death, though I’m still left wondering why this broke the way it did. Why does load_plugin_textdomain care about cookies?

Investigation

Let’s start working our way down the stack trace. The site editor page (wp-admin/site-editor.php) requires wp-admin/admin.php (step #13). wp-admin/admin.php requires wp-load.php (step #12). wp-load.php requires wp-config.php (step #11). If we had defined constants, they’d be available now. wp-config.php requires wp-settings.php (step #10). wp-settings.php is a large file that executes many, many functions. One thing that it does is load all of the network-activated plugins, including Simply Static (step #9).

Simply Static, in its main file and not wrapped in any function, executes load_plugin_textdomain (step #8). load_plugin_textdomain starts by invoking determine_locale (step #7). determine_locale needs to know something about the user. It does various checks: is the user an admin, is there a cookie set. Remember, we accessed a page that requires you to be logged-in. Eventually, after striking out a few times, it calls get_user_locale (step #6).

get_user_locale will try to get the current user via wp_get_current_user (step #5). That function in turn calls the private function _wp_get_current_user (step #4). We’re early enough that there’s no user object yet. This isn’t really an intended outcome and we skip right down to applying the determine_current_user filter (step #3). WP_Hook::apply_filters starts going through the callback functions (step #2). One callback function that gets called is wp_validate_auth_cookie (step #1).

As your plumber or electrician would say; “Well there’s your problem!” The first thing wp_validate_auth_cookie does is call wp_parse_auth_cookie (step #0). With no cookie nor scheme set, it defaults to SECURE_AUTH_COOKIE for a site running under TLS. Here’s the kicker:

1
2
3
4
5
6
7
if ( is_ssl() ) {
$cookie_name = SECURE_AUTH_COOKIE;
$scheme = 'secure_auth';
} else {
$cookie_name = AUTH_COOKIE;
$scheme = 'auth';
}

We don’t have SECURE_AUTH_COOKIE yet. I’m not even talking about the cookie, I’m just talking about the WordPress constant. Where does that come from, assuming you don’t define it wp-config.php?

Having worked downwards in the stack trace, we need to work back up, starting with wp_cookie_constants, which ensures that COOKIEHASH, USER_COOKIE, PASS_COOKIE, AUTH_COOKIE, SECURE_AUTH_COOKIE, LOGGED_IN_COOKIE, TEST_COOKIE, COOKIEPATH, SITECOOKIEPATH, ADMIN_COOKIE_PATH, PLUGINS_COOKIE_PATH, COOKIE_DOMAIN, and RECOVERY_MODE_COOKIE are all defined, even if they’re false.

wp_cookie_constants is called in wp-settings.php, which we already passed through in above in step #9. However, wp_cookie_constants is called after the network-activated plugins are loaded, so in this scenario that code is never reached. The various hooks discussed above for load_plugin_textdomain such as init or after_setup_theme all take place well afterwards. For that matter, the loading of non-network activated plugins comes after wp_cookie_constants, so switching to individual activation would also probably have resolved the issue.

Summary

When in doubt, use hooks and filters to control when things happen on a WordPress deployment. WordPress is forgiving, but the order of operations can be super relevant in unexpected ways. And if you encounter a missing constant error on WordPress under PHP 8 or higher, it’s a fair bet that’s what happened. Get a stack trace and look for non-WordPress core code.