Got a call from a client last week. They’ve got a WooCommerce extension that’s starting to get popular, but its settings page was buried under ‘Settings -> My Plugin’. They wanted to give it a proper top-level menu item, just like the big players. Simple request, right? But I knew the codebase. It was built to handle one thing: being a submenu page. This “simple” request was about to expose a design flaw. It’s a classic problem with custom WordPress admin pages; what starts as a simple settings screen evolves into something bigger, and the initial code just can’t keep up.
The whole system was hardcoded to use add_submenu_page(). My first thought was the quick fix: just create a new, separate function that calls add_menu_page() and slap a conditional in the main loop. And yeah, it would have worked. For about five minutes. Here’s the kicker: I’d have ended up with two different ways of registering pages, duplicating logic for permissions, titles, and rendering. Total nightmare to maintain. Trust me on this, future you will hate you for taking shortcuts like that. The real fix had to be architectural.
The original setup, which was a good starting point inspired by an article over at carlalexander.ca, used a single interface for all admin pages. To handle both top-level and submenu pages cleanly, we needed to break that apart.
Designing for Flexibility with Interfaces
Instead of one generic AdminPageInterface, I split it into three:
- AdminPageInterface: The base, with methods common to all pages (
get_capability,get_slug, etc.). - MenuPageInterface: Extends the base, adding methods specific to
add_menu_page()likeget_icon_url()andget_position(). - SubmenuPageInterface: Also extends the base, but adds
get_parent_slug().
/**
* A WordPress admin page.
*/
interface MyPlugin_AdminPageInterface {
public function get_capability();
public function get_menu_title();
public function get_page_title();
public function get_slug();
public function render_page();
}
/**
* A WordPress top-level menu page.
*/
interface MyPlugin_MenuPageInterface extends MyPlugin_AdminPageInterface {
public function get_icon_url();
public function get_position();
}
/**
* A WordPress submenu page.
*/
interface MyPlugin_SubmenuPageInterface extends MyPlugin_AdminPageInterface {
public function get_parent_slug();
}
Now, the class that loops through the pages and registers them becomes much smarter. It doesn’t just blindly call one function. It checks which interface the page object is using.
// Inside the registration loop...
foreach ($this->admin_pages as $admin_page) {
if ($admin_page instanceof MyPlugin_MenuPageInterface) {
add_menu_page(
$admin_page->get_page_title(),
$admin_page->get_menu_title(),
$admin_page->get_capability(),
$admin_page->get_slug(),
array($admin_page, 'render_page'),
$admin_page->get_icon_url(),
$admin_page->get_position()
);
} elseif ($admin_page instanceof MyPlugin_SubmenuPageInterface) {
add_submenu_page(
$admin_page->get_parent_slug(),
$admin_page->get_page_title(),
$admin_page->get_menu_title(),
$admin_page->get_capability(),
$admin_page->get_slug(),
array($admin_page, 'render_page')
);
}
}
This way, adding a new page is as simple as implementing the right interface. No conditionals, no duplicate code. Clean. And when the same client came back asking for a special settings page only for network admins on their multisite setup, the foundation was already there. We just created a new class that returned a different parent slug (settings.php instead of options-general.php) and a more restrictive capability (manage_network_plugins). The core system didn’t need to change at all. That was it.
So, What’s the Point?
This isn’t just about adding a menu. It’s about writing code that you don’t have to be afraid to touch six months later.
- Anticipate Change: Plugins evolve. A settings page today is a top-level menu tomorrow. Build the flexibility in from the start.
- Use Interfaces as Contracts: Interfaces let you define what a class should do without locking yourself into how. This makes your registration logic clean and simple.
- Avoid Duplication: The moment you’re tempted to copy-paste a block of code and change one line, stop. That’s a sign you need to refactor.
Look, this stuff gets complicated fast. If you’re tired of debugging someone else’s mess and just want your site to work, drop my team a line. We’ve probably seen it before.
Leave a Reply