Right after I uploaded a WordPress plugin I built, every frontend request came back with HTTP 500. Not one page — all of them. Homepage, single posts, archives, everything down. The PHP log showed a single line:
PHP Fatal error: Uncaught Error: Class "WP_List_Table" not found
What made it worse: before deploying I'd run php -l across the files and it passed clean. No syntax errors. Yet the moment it went live, the frontend died completely.
Why this happens
The plugin had an admin screen that listed conversations in a table, so I wrote a class extending WP_List_Table:
class My_Conversations_List_Table extends WP_List_Table {
// columns, rows, pagination, etc.
}The problem wasn't the class itself — it was which file the class lived in and when that file got loaded. This class sat in a file that was require_once'd unconditionally on the plugins_loaded hook. I loaded it on plugins_loaded on purpose, because another hook in that file had to fire on REST requests too, not just admin.
Here's the trap: WP_List_Table is not one of the always-available classes. It lives in:
wp-admin/includes/class-wp-list-table.php
That file is only loaded in the wp-admin context — not on the frontend, not in REST. So on any frontend request, PHP parses the declaration:
class My_Conversations_List_Table extends WP_List_Table { ... }…then tries to resolve the parent class WP_List_Table, can't find it, and fatals immediately. Because this file is required on plugins_loaded, which runs on every request, that fatal fires on every frontend page.
And this is exactly why php -l never caught it: lint only checks syntax, not parent-class resolution. class A extends B {} is perfectly valid syntax even if B never exists. Parent resolution only happens at runtime, when PHP actually loads the class — and that's where it explodes.
The fix: split the file out, lazy-require it in admin context
The solution: move the wp-admin-extending class into its own file, and never require that file unconditionally. Require it only from inside a callback that runs in admin context, after pulling in the parent first:
// Called only when building the admin screen (e.g. from 'admin_menu')
function my_render_conversations_screen() {
if ( ! class_exists( 'WP_List_Table' ) ) {
require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php';
}
if ( ! class_exists( 'My_Conversations_List_Table' ) ) {
require_once __DIR__ . '/class-my-conversations-list-table.php';
}
$table = new My_Conversations_List_Table();
$table->prepare_items();
$table->display();
}Because the require for the class file now lives inside the admin callback, PHP never parses the extends WP_List_Table declaration on a frontend request. The parent is guaranteed present first via class_exists() + require_once, then the child class is loaded. The frontend came back to life, and the admin screen still worked.
Which parents are safe, which are dangerous
Not every extends is a trap. What matters: is the parent loaded at bootstrap, or only in wp-admin?
WP_REST_Controlleris safe to extend at parse time — core loads it during bootstrap, including on REST and frontend requests.WP_Widgetis safe too — loaded early by core.- The dangerous ones are wp-admin-only parents:
WP_List_Table, and other admin-screen classes likeWP_Customize_Control/WP_Customize_Manager. Those only exist in admin context.
The rule: if the parent lives in wp-admin/includes/*, don't declare its child in a file that loads on every request.
A smoke test that beats php -l
Since php -l is blind to parent resolution, I now use a parse-time test that actually loads the file, simulating frontend context (where WP_List_Table doesn't exist):
php -r 'define("ABSPATH","/tmp/"); require "class-my-conversations-list-table.php";'If the file tries to extend a parent that isn't there, this command fatals right in the terminal — before it reaches production. It's the fast way to confirm no "always-loaded" file secretly depends on a wp-admin class.
The lesson
A green php -l is no guarantee a file is safe to load. Never declare a class that extends a wp-admin-only parent (WP_List_Table and friends) in a file loaded on every request. Split it into its own file, then lazy-load it from an admin-context callback after require_once-ing its parent. And replace lint with a smoke test that actually loads the file — that's what catches parent-resolution fatals before your frontend goes down with them.
