The problem: you install or update a plugin, WordPress hands you “Activate Plugin” and “Return to Plugin Installer” — and then leaves you to hunt for where that plugin’s settings actually live. This started as an answer to a question Rodolfo Melogli raised: after installing a plugin from a ZIP, why isn’t there a “Go to plugin settings” link, with a fallback to the Plugins page? Here is the complete snippet that does it — and does it for any plugin, in any language, without a hardcoded list. Grab the full code here: the snippet on GitHub Gist. Last updated: July 2026.
Why the obvious fixes don’t hold up
Every “just add a settings link” approach I tried first broke somewhere. These are the four dead ends, because the fix only makes sense once you’ve seen them fail:
- Hardcode a table of plugin → settings URL. Fine for the top 20, useless for the long tail, and it drifts the moment a plugin renames its page.
- Match the word “Settings” in the admin menu. Dies on any non-English admin, and false-matches WordPress’ own Settings menu.
- Guess
admin.php?page={slug}. Wrong for a large share of plugins. Akismet lives atoptions-general.php?page=akismet-key-config, ACF atedit.php?post_type=acf-field-group, Redirection under Tools. A guessed URL that 404s is worse than no link at all. - Use
install_plugin_overwrite_actions. Tempting name, wrong hook — it fires on the overwrite confirmation screen, not the install/update result. The right hook isinstall_plugin_complete_actions, which fires after a normal install, an update, and a ZIP upload.
The one reliable signal: the plugin’s own settings link
Every plugin that has a settings page already adds its own link to its row on the Plugins list, through the plugin_action_links_{$plugin_file} filter. That link is authored by the plugin, points at the correct URL, and is completely independent of the admin language. So instead of guessing, the snippet reads that.
It fires the filter, parses the returned HTML with DOMDocument (not regex), keeps only internal wp-admin URLs, throws away the core row-actions (activate / deactivate / delete / editor), and prefers the candidate whose URL contains the plugin’s own slug. Whatever survives is the plugin’s real settings URL — ground truth, not a guess.
Learn it once, cache it, let it self-heal
The catch: that filter only returns anything while the plugin is loaded (active). So the snippet learns the link at the two moments it can, and remembers it:
- on
activated_plugin— the instant a plugin activates; - on
load-plugins.php— where every active plugin’s action links exist, so it back-fills the whole active set.
Both write to a single option keyed by plugin file. Resolution priority is learned cache → seed map → live best-effort. The seed map (~22 popular plugins) is only a bootstrap so the very first click is right before the cache warms; the learned cache overrides it, so a stale seed URL self-corrects the first time you open the Plugins screen. Only the reliable action-link signal is ever cached — the fuzzy menu scan is used live but never stored, so it can’t poison the cache.
Never show a dead link: activate first, then resolve
After a ZIP install the plugin isn’t active yet, so its settings page doesn’t exist and its own link can’t be read. A “Go to settings” link there would be dead. So the result screen branches:
- Already active (you just updated it) → Go to settings, straight to the resolved URL.
- Freshly installed, not active → Activate & open settings: one nonce-protected click that activates the plugin, reads its now-available real link, and redirects you there.
- Always → Go to Plugins page.
The privileged step sits behind admin_post + check_admin_referer + a capability check, so a logged-out or low-role user can’t trigger anything. Here is the whole flow:
install / update result screen
|
Is the plugin active?
|-- yes --> resolve URL (cache > seed > live) --> "Go to settings"
|-- no --> "Activate & open settings"
| --> activate_plugin() --> read the plugin's OWN link --> redirect there
|
always --> "Go to Plugins page"
get_settings_url( plugin_file ):
1. learned cache (the plugin's own link, saved on activate + on the Plugins screen)
2. seed map (22 popular plugins - bootstrap only, cache overrides it)
3. live best-effort (read the action link now; else a strict full-slug menu scan)
The complete snippet
<?php
/**
* Plugin Name: Smart "Plugin Installed" Links
* Description: After installing/updating a plugin (incl. ZIP uploads), adds a correct "Go to settings"
* link + a reliable "Go to Plugins page" link. LEARNS each plugin's real settings link (from
* the plugin's OWN declared Settings action link) on activation and on the Plugins screen, so
* it is self-correcting, version-proof and language-independent. No English keyword matching.
* Version: 2.1.0
* Author: Aditya Sharma
* License: GPL-2.0-or-later
*
* Resolution priority: learned cache > seed map > live best-effort. Only high-confidence signals are cached.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// Guard so the same code is safe whether dropped in as a plugin, mu-plugin, or theme functions.php snippet.
if ( ! class_exists( 'Aditya_Plugin_Install_Links' ) ) :
final class Aditya_Plugin_Install_Links {
const OPTION = 'aditya_plugin_settings_urls'; // [ plugin_file => admin-relative path ]
public static function init() {
add_filter( 'install_plugin_complete_actions', array( __CLASS__, 'install_actions' ), 10, 3 );
add_action( 'activated_plugin', array( __CLASS__, 'learn_one' ), 999, 1 );
add_action( 'load-plugins.php', array( __CLASS__, 'learn_active' ) );
add_action( 'admin_post_aditya_activate_goto_settings', array( __CLASS__, 'handle_activate_goto_settings' ) );
}
/**
* Seed map — bootstraps top plugins BEFORE their first activation. The learned cache (each plugin's
* OWN link) overrides these, so any drift self-heals. Admin-relative paths. Extend freely.
*/
public static function seed_map() {
return array(
'woocommerce/woocommerce.php' => 'admin.php?page=wc-settings',
'wordpress-seo/wp-seo.php' => 'admin.php?page=wpseo_dashboard',
'elementor/elementor.php' => 'admin.php?page=elementor',
'contact-form-7/wp-contact-form-7.php' => 'admin.php?page=wpcf7',
'akismet/akismet.php' => 'options-general.php?page=akismet-key-config',
'classic-editor/classic-editor.php' => 'options-writing.php',
'jetpack/jetpack.php' => 'admin.php?page=jetpack',
'wpforms-lite/wpforms.php' => 'admin.php?page=wpforms-settings',
'all-in-one-seo-pack/all_in_one_seo_pack.php' => 'admin.php?page=aioseo-settings',
'wordfence/wordfence.php' => 'admin.php?page=Wordfence',
'seo-by-rank-math/rank-math.php' => 'admin.php?page=rank-math',
'litespeed-cache/litespeed-cache.php' => 'admin.php?page=litespeed-cache',
'wp-super-cache/wp-cache.php' => 'options-general.php?page=wpsupercache',
'w3-total-cache/w3-total-cache.php' => 'admin.php?page=w3tc_general',
'wp-rocket/wp-rocket.php' => 'options-general.php?page=wprocket',
'updraftplus/updraftplus.php' => 'options-general.php?page=updraftplus',
'wp-mail-smtp/wp_mail_smtp.php' => 'admin.php?page=wp-mail-smtp',
'google-site-kit/google-site-kit.php' => 'admin.php?page=googlesitekit-settings',
'google-analytics-for-wordpress/googleanalytics.php' => 'admin.php?page=monsterinsights_settings',
'wp-smushit/wp-smush.php' => 'admin.php?page=smush',
'redirection/redirection.php' => 'tools.php?page=redirection.php',
'advanced-custom-fields/acf.php' => 'edit.php?post_type=acf-field-group',
);
}
/** Absolute settings URL or '' if unknown. Priority: learned cache > seed > live best-effort. */
public static function get_settings_url( $plugin_file ) {
if ( empty( $plugin_file ) ) {
return '';
}
$cache = get_option( self::OPTION, array() );
if ( is_array( $cache ) && ! empty( $cache[ $plugin_file ] ) ) {
return admin_url( $cache[ $plugin_file ] );
}
$seed = self::seed_map();
if ( isset( $seed[ $plugin_file ] ) ) {
return admin_url( $seed[ $plugin_file ] );
}
$rel = self::live_detect( $plugin_file );
return $rel ? admin_url( $rel ) : '';
}
/** Learn just-activated plugin (cache ONLY the reliable action-link signal). */
public static function learn_one( $plugin_file ) {
$rel = self::settings_from_action_links( $plugin_file );
if ( $rel ) {
self::cache_set( $plugin_file, $rel );
}
}
/** On the Plugins screen, every active plugin's action links exist → cache them (reliable signal only). */
public static function learn_active() {
if ( ! current_user_can( 'activate_plugins' ) ) {
return;
}
$cache = get_option( self::OPTION, array() );
$cache = is_array( $cache ) ? $cache : array();
$changed = false;
foreach ( (array) get_option( 'active_plugins', array() ) as $file ) {
if ( ! empty( $cache[ $file ] ) ) {
continue;
}
$rel = self::settings_from_action_links( $file );
if ( $rel ) {
$cache[ $file ] = $rel;
$changed = true;
}
}
if ( $changed ) {
update_option( self::OPTION, $cache, false );
}
}
private static function cache_set( $file, $rel ) {
$cache = get_option( self::OPTION, array() );
$cache = is_array( $cache ) ? $cache : array();
$cache[ $file ] = $rel;
update_option( self::OPTION, $cache, false );
}
/* ------------------------------------------------------------------ *
* Detection
* ------------------------------------------------------------------ */
/** RELIABLE signal (cacheable): the plugin's OWN declared "Settings" action link. Language-independent. */
private static function settings_from_action_links( $plugin_file ) {
$links = array();
$links = (array) apply_filters( "plugin_action_links_{$plugin_file}", $links, $plugin_file, array(), 'all' );
$links = (array) apply_filters( "network_admin_plugin_action_links_{$plugin_file}", $links, $plugin_file, array(), 'all' );
$admin = admin_url();
$net = network_admin_url();
$needles = self::slug_needles( $plugin_file );
$candidates = array();
foreach ( $links as $html ) {
if ( ! is_string( $html ) || false === stripos( $html, 'href' ) ) {
continue;
}
$dom = new DOMDocument();
libxml_use_internal_errors( true );
$dom->loadHTML( '<?xml encoding="utf-8"?><div>' . $html . '</div>' );
libxml_clear_errors();
foreach ( $dom->getElementsByTagName( 'a' ) as $a ) {
$href = html_entity_decode( trim( $a->getAttribute( 'href' ) ), ENT_QUOTES );
if ( '' === $href || '#' === $href ) {
continue;
}
if ( 0 !== strpos( $href, 'http' ) && 0 !== strpos( $href, '/' ) ) {
$href = $admin . ltrim( $href, '/' );
}
if ( 0 !== strpos( $href, $admin ) && 0 !== strpos( $href, $net ) ) {
continue; // internal admin URLs only
}
if ( preg_match( '#(plugins\.php\?|plugin-editor\.php|action=(activate|deactivate|delete)|update\.php|tgmpa)#i', $href ) ) {
continue; // skip core plugin-row actions
}
$candidates[] = $href;
}
}
if ( empty( $candidates ) ) {
return '';
}
// Prefer a candidate whose URL mentions the plugin's full slug; else the first custom link (conventionally Settings).
foreach ( $candidates as $c ) {
foreach ( $needles as $n ) {
if ( false !== stripos( $c, $n ) ) {
return self::to_relative( $c );
}
}
}
return self::to_relative( $candidates[0] );
}
/** BEST-EFFORT (live only, never cached): strict full-slug menu scan. */
private static function settings_from_menu( $plugin_file ) {
$needles = self::slug_needles( $plugin_file );
global $submenu, $menu;
foreach ( array( (array) $submenu, (array) $menu ) as $group ) {
foreach ( $group as $items ) {
foreach ( self::rows( $items ) as $row ) {
$page = isset( $row[2] ) ? (string) $row[2] : '';
if ( '' === $page ) {
continue;
}
foreach ( $needles as $n ) {
if ( false !== stripos( $page, $n ) ) {
return ( false !== strpos( $page, '.php' ) ) ? $page : 'admin.php?page=' . $page;
}
}
}
}
}
return '';
}
private static function live_detect( $plugin_file ) {
$rel = self::settings_from_action_links( $plugin_file );
return $rel ? $rel : self::settings_from_menu( $plugin_file );
}
/** Full-slug needles only (NOT generic tokens) — avoids "editor" matching site-editor.php etc. */
private static function slug_needles( $plugin_file ) {
$slug = ( false !== strpos( $plugin_file, '/' ) ) ? dirname( $plugin_file ) : basename( $plugin_file, '.php' );
$slug = sanitize_title( $slug );
if ( strlen( $slug ) < 4 ) {
return array();
}
return array_values( array_unique( array( $slug, str_replace( '-', '_', $slug ), str_replace( '-', '', $slug ) ) ) );
}
private static function rows( $items ) {
return ( isset( $items[2] ) && is_string( $items[2] ) ) ? array( $items ) : (array) $items;
}
private static function to_relative( $url ) {
$admin = admin_url();
if ( 0 === strpos( $url, $admin ) ) {
return substr( $url, strlen( $admin ) );
}
$net = network_admin_url();
if ( 0 === strpos( $url, $net ) ) {
return substr( $url, strlen( $net ) );
}
return $url;
}
/* ------------------------------------------------------------------ *
* The install / update result screen
* ------------------------------------------------------------------ */
public static function install_actions( $actions, $api, $plugin_file ) {
if ( ! current_user_can( 'activate_plugins' ) ) {
return $actions;
}
if ( ! function_exists( 'is_plugin_active' ) ) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
$sep = '<span style="margin:0 6px;color:#c3c4c7;" aria-hidden="true">|</span>';
$is_active = $plugin_file && is_plugin_active( $plugin_file );
$can_set = $plugin_file && current_user_can( 'manage_options' );
if ( $can_set && $is_active ) {
// Plugin is already active (e.g. updating an active plugin) → its settings page exists → link straight to it.
$settings = self::get_settings_url( $plugin_file );
if ( $settings ) {
$actions['aditya_settings'] = $sep . sprintf(
'<a href="%s" target="_parent"><strong>%s</strong></a>',
esc_url( $settings ),
esc_html__( 'Go to settings', 'aditya-plugin-links' )
);
}
} elseif ( $can_set && ! $is_active ) {
// Not active yet → don't offer a dead settings link. One click that activates THEN opens settings.
$url = wp_nonce_url(
admin_url( 'admin-post.php?action=aditya_activate_goto_settings&plugin=' . rawurlencode( $plugin_file ) ),
'aditya_activate_' . $plugin_file
);
$actions['aditya_activate_settings'] = $sep . sprintf(
'<a href="%s" target="_parent"><strong>%s</strong></a>',
esc_url( $url ),
esc_html__( 'Activate & open settings', 'aditya-plugin-links' )
);
}
$actions['aditya_plugins_page'] = $sep . sprintf(
'<a href="%s" target="_parent">%s</a>',
esc_url( self_admin_url( 'plugins.php' ) ),
esc_html__( 'Go to Plugins page', 'aditya-plugin-links' )
);
return $actions;
}
/** Activate the plugin, then redirect to its settings (resolving it live now that the plugin is loaded). */
public static function handle_activate_goto_settings() {
$file = isset( $_GET['plugin'] ) ? sanitize_text_field( wp_unslash( $_GET['plugin'] ) ) : '';
if ( '' === $file || ! current_user_can( 'activate_plugins' ) ) {
wp_die( esc_html__( 'You are not allowed to do that.', 'aditya-plugin-links' ) );
}
check_admin_referer( 'aditya_activate_' . $file );
if ( ! function_exists( 'activate_plugin' ) ) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
$res = activate_plugin( $file );
if ( is_wp_error( $res ) ) {
wp_safe_redirect( self_admin_url( 'plugins.php?plugin_activation_error=1' ) );
exit;
}
// Plugin is loaded now → learn its real settings link, then send the user there.
$rel = self::settings_from_action_links( $file );
if ( $rel ) {
self::cache_set( $file, $rel );
}
$url = self::get_settings_url( $file );
wp_safe_redirect( $url ? $url : self_admin_url( 'plugins.php' ) );
exit;
}
}
Aditya_Plugin_Install_Links::init();
endif;
Install it
- Paste it into your (child) theme’s
functions.php, save it as an mu-plugin (wp-content/mu-plugins/aditya-plugin-links.php), or drop it into a code-snippets plugin set to run everywhere. - That’s the whole setup — no options, no config. The next time you install or update a plugin, the links appear on the result screen.
- Optional: rename the
Aditya_Plugin_Install_Linksclass and theaditya_prefixes to your own.
It only hooks the wp-admin install and Plugins screens, so it does nothing on the front end and adds zero page-load overhead for visitors.
How well does it really cover “any plugin”?
Honest accounting, because “works with everything” is usually a lie:
- Plugins with a settings page — covered. The destination is whatever the plugin itself declares as its settings link. Tested across 15+ plugins (WooCommerce, Yoast, Rank Math, AIOSEO, Elementor, Contact Form 7, WPForms, Jetpack, LiteSpeed Cache, WP Mail SMTP, ACF, Akismet, Redirection and more) with no mismatches.
- Plugins with no settings page (Hello Dolly, Classic Widgets) — the correct behaviour is no settings link. You just get “Go to Plugins page.” The snippet never invents a page that doesn’t exist.
- Non-English admins — fine. Matching is on URLs, never on the word “Settings,” so a German or Japanese dashboard resolves identically.
- The known soft spot. For the rare plugin that exposes several custom action links with none of them containing its slug in the URL, the snippet picks the first one — which by long-standing convention is Settings. A plugin that orders its links differently could send you to the wrong one of its own pages. That’s the one case the URL-slug heuristic can miss; extend the seed map for that plugin and the learned cache takes over.
FAQ
Does it work for ZIP uploads, not just the WordPress.org installer?
Yes. install_plugin_complete_actions fires for a WordPress.org install, an update, and a manual ZIP upload alike. ZIP uploads were part of the testing.
Will it ever send me to a 404?
For an active plugin it links to the plugin’s own declared URL. For an inactive one it doesn’t offer a settings link at all until after activation, so there’s no dead settings link to click. Worst case, you land on the Plugins page.
Is it safe to run?
The activate-and-redirect path is a nonce-checked admin_post action that requires activate_plugins / manage_options. Nothing privileged is exposed to a logged-out or low-role user.
Will it slow my site down?
No. Every hook is on the wp-admin install and Plugins screens. The front end never loads it.
Which plugins are in the seed map, and why?
About 22 popular ones — purely so the first click is correct before the learned cache warms up. Once you activate a plugin or visit the Plugins screen, the plugin’s own link is cached and overrides the seed.
Full code: the snippet on GitHub Gist. Drop it in, install your next plugin, and the “now where are the settings?” step is just gone. If you hit a plugin that resolves to the wrong page, that’s exactly the edge the learned cache is built to swallow — tell me and I’ll fold it into the seed.