Skip to content
Plugin PantryPlugins

Documentation / Pantry Redirects

Pantry Redirects

Contents: 301 and 302 redirects with a 404 log.

301 and 302 redirects with a 404 log.

A redirect list you can read at a glance and a 404 log that shows what to fix. Add exact-path redirects, see how often they fire, and turn a logged 404 into a redirect. Import and export as CSV.

Built to the Pantry Standard.

Where it lives

Plugin Pantry > Redirects. One screen with three tabs:

  • Redirects - the add or edit form, the redirect list, and CSV import and export.
  • 404 log - paths that returned a 404, with a clear button.
  • Settings - matching and logging options.

Everything needs the manage_options capability.

Adding a redirect

  1. Source path is a path on this site, starting with a slash: /old-page. A trailing slash and letter case are ignored, so /Old-Page/ and /old-page are the same redirect. A source may carry a query string (/product?id=12) when you want to redirect one exact address.
  2. Target is either a path on this site (/new-page) or a full address somewhere else (https://example.com/new-page).
  3. Type is 301 (permanent), 302 (found, temporary) or 307 (temporary).
  4. Enabled turns the redirect on. A disabled redirect stays in the list and does nothing.

The form refuses a source that is empty or is the home page, a target that is neither a path nor an http or https address, a source that already has a redirect, a redirect that points at itself, and a redirect that would send a visitor round in a loop. Loop checking follows the chain of redirects on this site for up to ten hops.

Redirects are matched on template_redirect at priority 1, before WordPress decides the request is a 404, so a redirect wins over an existing page at the same path.

The 404 log

Every front-end request that ends in a 404 has its path written to the log with a hit count and the time it was last seen. Nothing else is stored: no IP address, no user agent, no referrer, no user id.

These are never logged:

  • Admin, AJAX, cron, REST and WP-CLI requests.
  • The home page.
  • Anything starting with /wp-.
  • Anything ending in a common asset ending (.css, .js, .png, .woff2 and so on).

Create redirect on a log row opens the Redirects tab with that path already in the source box. Fill in the target and save. Delete removes one row, and Clear the log empties it.

When the log passes the limit in Settings, the paths seen longest ago are dropped.

Settings

Setting Default What it does
Query strings On With this on, /old-page?utm_source=news matches a redirect whose source is /old-page. With it off, only a redirect whose source carries the same query string matches. A source with its own query string always wins over the bare path either way.
Default type 301 Pre-selected in the add form, and used for CSV rows that leave the type column out.
Logging On Turns 404 logging off without touching what is already logged.
Paths to keep 200 How many unique paths the log holds. Between 20 and 1000.

CSV

Export CSV downloads every redirect as source,target,type,enabled, newest id last.

Import CSV reads the same four columns. A header row is optional and is skipped when the first cell reads source. A row whose source already exists updates that redirect rather than adding a second one. The type column may be left empty, in which case the default type from Settings is used. The enabled column accepts 1, yes, true, on or enabled; anything else is off, and a missing column is on.

Rows that fail the same validation as the form are skipped and counted in the notice. The file must be under one megabyte and at most 5000 rows are read.

source,target,type,enabled
/old-page,/new-page,301,1
/brochure.html,https://example.com/brochure,302,1
/retired,/,301,0

Filters and actions

All seven are prefixed pantry_redirects_.

pantry_redirects_pre_match

Added in 1.1.0. Runs on template_redirect before the exact lookup, once the request path has been normalised. Return a rule row and the visitor is sent there without the exact list being read; return the value you were given and matching carries on as usual.

A rule row needs id, target and type. The id is the row whose hit count is raised, so a rule stored in this plugin's own table is counted like any other, and an id of 0 sends the redirect without counting it.

add_filter( 'pantry_redirects_pre_match', function ( $rule, $request ) {
	if ( null !== $rule || 0 !== strpos( $request, '/legacy/' ) ) {
		return $rule;
	}
	return (object) array(
		'id'     => 0,
		'source' => $request,
		'target' => '/archive' . $request,
		'type'   => 301,
	);
}, 10, 2 );

pantry_redirects_settings_after

Added in 1.1.0. Fires at the end of the Settings tab, after the settings form, with the settings screen object as its argument. An add-on can render its own section there. The form above it posts to options.php with this plugin's own settings group, so a section that has fields of its own brings its own form and its own group.

add_action( 'pantry_redirects_settings_after', function ( $settings ) {
	echo '<section class="pantry-section"><h2>My add-on</h2></section>';
} );

pantry_redirects_enabled

Turn redirect matching off for a request.

add_filter( 'pantry_redirects_enabled', function ( $enabled ) {
	return is_user_logged_in() ? false : $enabled;
} );

pantry_redirects_target

Change the address a matched rule sends the visitor to. Return an empty string to cancel the redirect.

add_filter( 'pantry_redirects_target', function ( $url, $rule, $request ) {
	return add_query_arg( 'from', rawurlencode( $request ), $url );
}, 10, 3 );

pantry_redirects_redirecting

Fires just before the header is sent, after the hit has been counted.

add_action( 'pantry_redirects_redirecting', function ( $rule, $url, $status ) {
	error_log( sprintf( '%d to %s', $status, $url ) );
}, 10, 3 );

pantry_redirects_should_log

Decide whether one path is written to the 404 log.

add_filter( 'pantry_redirects_should_log', function ( $should_log, $path ) {
	return 0 === strpos( $path, '/feed' ) ? false : $should_log;
}, 10, 2 );

pantry_redirects_asset_endings

Replace the list of file endings that are never logged.

add_filter( 'pantry_redirects_asset_endings', function ( $endings ) {
	$endings[] = 'pdf';
	return $endings;
} );

What it stores

Two options, pantry_redirects_settings and pantry_redirects_db_version, and two tables, {prefix}pantry_redirects_rules and {prefix}pantry_redirects_log. No post meta, no custom post types, no cron events, no transients, no external requests.

Uninstalling removes all four, on every site of a network.

The kind column

Since 1.1.0 the rules table has a kind column, varchar(20), defaulting to exact. This plugin only ever reads, lists, edits, exports, matches or deletes rows whose kind is exact, which is every row it writes. The column is there so an add-on can keep rules of another kind in the same table and have the hit count, the last-hit time and the 404 log work for them without a second table. A row of another kind is invisible here, with one exception: the source column is unique across the whole table, so the add form still refuses a source that another kind of rule has already taken.

The schema version is held in pantry_redirects_db_version and the column is added by dbDelta on the first admin request after an update.

Notes for the curious

  • Matching costs one indexed SELECT on the rules table per front-end request, and a second one only when the request has a query string and the query string option is on. Nothing is cached, so a redirect works the moment it is saved.
  • Plugin::boot() runs on plugins_loaded and hooks init at priority 5, where it builds the objects. The menu title and every field label are translatable, and WordPress 6.7 and newer log a notice when a text domain is used before init. Every hook this plugin needs fires after init, so nothing is lost by waiting.
  • wp_http_validate_url() is deliberately not used to check targets. It resolves the host through DNS and refuses private addresses, which would break a redirect to a staging host and would count as an external request. Targets are checked structurally instead: a path must start with a slash, and an address must parse to an http or https scheme with a host.
  • wp_redirect() is used rather than wp_safe_redirect() because a redirect to another site is a normal thing to want. The target is validated when it is saved by a user with manage_options.

Why this plugin is over the line limit

The Pantry Standard aims for under 1,500 lines of PHP per plugin and asks for a written reason above 2,000. This one measures 3,221 lines, counted with find plugins/pantry-redirects -name '*.php' -not -path '*/tests/*' | xargs cat | wc -l. The reason, in order of size:

  • 742 lines are the vendored includes/core settings class, which is shared and not written here.
  • 353 lines are the two WP_List_Table subclasses. The scope asks for a redirect list and a 404 log, and each list needs its own columns, sortable columns, row actions, bulk actions and empty-state text. There is no smaller way to get a native WordPress list.
  • 518 lines are the admin write paths in one class: the add and edit handler, the row and bulk handlers, the CSV reader, the CSV writer, the validator, and the notice texts. The notice map alone is 70 lines because every outcome gets a sentence a person can act on rather than a code.
  • 571 lines are the database layer. Ordering and paging are written out twice, once ascending and once descending, because the coding standard will not allow a direction to be concatenated into a prepared statement, and 1.1.0 added a kind-limited version of the rule reads and writes.
  • The rest is path normalisation shared by the front end and the admin, the screen markup, and one docblock per method.

Nothing here is dead code or a feature outside the scope contract. If the limit has to be met, the honest cut is the 404 log, which would move the plugin back under 1,900 lines and remove half of what it is for.

Pro modules

What Pantry Pro adds to this plugin. None of it is inside the free plugin, and a module only runs when the free plugin is active.

Automatic redirects

Pantry\Pro\Modules\Redirects\Auto_Redirects, in src/modules/redirects/class-auto-redirects.php. Keeps old addresses working when a published post changes its slug. Needs Pantry Redirects 1.1.0 or newer.

What it adds

On post_updated, when a post that was published before the save is still published and its slug has changed, the module writes an ordinary exact redirect from the old permalink path to the new one: a 301, enabled, in the free plugin's own rules table. It shows up in the Redirects list straight away and can be edited, disabled, exported or deleted there like any other redirect.

Nothing is written when:

  • the switch below is off;
  • the post was not published before the change, or is not published now (a draft that is renamed, or a post being published for the first time);
  • the post type is not viewable, or the row is a revision or an autosave;
  • the old path is empty, is the home page, or is longer than the 191 characters the source column holds;
  • a redirect already exists for the old path, of any kind: a redirect a person wrote by hand is never overwritten;
  • the new address leads back to the old one through the redirects already stored, which would be a loop.

The setting

One switch, on the free plugin's own screen: Plugin Pantry > Redirects > Settings, in a section called "Automatic redirects" below the free plugin's own fields.

Setting Default What it does
Permalink changes On Adds a 301 from the old address to the new one when a published post, page or custom post type changes its slug.

It is stored in the module's own option, pantry_pro_redirects_auto_redirects:

array( 'enabled' => true )

install() writes that default, so the module is on as soon as Pantry Pro is licensed. uninstall() deletes the option and nothing else: redirects already written are the site's, and the site still needs them.

How it saves

The option is registered with register_setting() in two groups: the free plugin's own settings group, pantry-redirects, and a small group of the module's own, pantry_pro_redirects. The section is rendered through the free plugin's pantry_redirects_settings_after action, which fires after the free plugin's form, so the section brings its own form posting to options.php with its own Save button and its own group. Two consequences worth knowing:

  • Saving the free plugin's form never disturbs this setting. WordPress calls every sanitiser in a group even for an option the form did not carry, so the module's sanitiser treats an input that is not an array as "leave the stored value alone".
  • Saving this section never disturbs the free plugin's settings, because options.php only writes the options in the group that was posted.

A hidden [submitted] field goes with the checkbox, because an unticked box sends nothing at all and a save that means "off" has to be told apart from a save that did not carry the field.

Hooks

pantry_pro_redirects_auto_redirect

Filter. Decide whether one permalink change becomes a redirect.

add_filter( 'pantry_pro_redirects_auto_redirect', function ( $write, $old, $new, $post ) {
	return 'product' === $post->post_type ? false : $write;
}, 10, 4 );
Argument Type What it is
$write bool True to store the redirect.
$old string The old path, normalised.
$new string The new path, normalised.
$post WP_Post The post as it is now.

The module also uses one hook the free plugin publishes, pantry_redirects_settings_after (Pantry Redirects 1.1.0), to draw its section on the free plugin's settings tab.

Worth knowing

  • The redirect is written from the whole permalink path, so a site on /%year%/%monthnum%/%day%/%postname%/ gets /2026/09/12/old-slug to /2026/09/12/new-slug. Changing a permalink structure is not a slug change and is not covered.
  • Renaming twice leaves a chain: the first old path points at the second, which points at the third. Each hop is a row you can shorten by hand.
  • Renaming back to an address that already has one of these redirects leaves that redirect pointing away from a page that exists again, and a redirect wins over a page at the same path. The module refuses to add a second rule in that case, but it never deletes the first: delete it in the Redirects list.
  • Capability and nonce: the module writes nothing of its own through a form of its own beyond the setting, which goes through options.php with the Settings API's nonce and the manage_options capability.

Pattern rules

Pantry\Pro\Modules\Redirects\Pattern_Rules, in src/modules/redirects/class-pattern-rules.php. Extends Pantry Redirects with wildcard and regular-expression redirects. Needs Pantry Redirects 1.1.0 or newer, which is where the kind column and the pantry_redirects_pre_match filter arrived.

What it adds

  • A Pattern rules screen under the shared Plugin Pantry menu, directly after Redirects: an add and edit form, a list table and a CSV export.
  • Two kinds of rule:
    • Wildcard, where * stands for any run of characters. /old-shop/* is compiled to #^/old\-shop/(.*)$#i, so each * is also a capture group.
    • Regular expression, written without delimiters and matched without case. ^/news/[0-9]{4}/(.*)$ is compiled to #^/news/[0-9]{4}/(.*)$#i. A bare # in the pattern is escaped for you.
  • Capture groups in the target: $1 to $9 are replaced with what the pattern captured, so /old-shop/* to /shop/$1 sends /old-shop/blue-hat to /shop/blue-hat.
  • Order: patterns are tried from the top of the list. Move up and Move down on a row change which one wins. The exact redirect list is not consulted for a request a pattern has matched.
  • Hit counting: rules live in the free plugin's own rules table, so the free plugin counts a pattern's hits and stamps its last hit exactly as it does for an exact redirect. The Hits and Last hit columns are those same values.
  • Validation on save: the pattern is compiled and run once before it is stored, so a broken expression is refused with a message instead of printing a PHP warning on the front end. The target is checked to be a path or an http or https address, and a rule whose target would match its own pattern again, directly or through the exact redirects, is refused as a loop.

A pattern is cleaned with Rules::clean_pattern(), which drops control characters and trims, rather than with sanitize_text_field(), which would turn (?<=/blog) into an entity, empty (?<year>...) and delete anything shaped like %c3. The target goes through esc_url_raw(), the same function the free plugin uses for its own targets, so $1 and percent sequences survive. Both are escaped where they are printed.

Where the rules are kept

In {prefix}pantry_redirects_rules, the free plugin's table, with kind set to wildcard or regex. Pantry Redirects 1.1.0 reads only rows whose kind is exact, so a pattern rule never appears in its list, its add and edit form, its CSV export, or its own matching. The source column is unique across the whole table, so a pattern cannot reuse the text of an exact redirect.

Settings

None on the free plugin's screen. Everything is on the module's own screen.

The module keeps one option, pantry_pro_redirects_pattern_rules, holding the match order as a list of rule ids:

array( 'order' => array( 38, 37 ) )

A rule that is not in the list yet is matched after the ones that are, oldest first. uninstall() deletes every rule whose kind is not exact and removes the option. Exact redirects, including any this or another module created, are left alone.

Screen and capabilities

admin.php?page=pantry-pro-redirects-patterns, capability manage_options. Every write path checks that capability and a nonce:

Action Nonce
Add or save a rule (admin-post.php?action=pantry_pro_pattern_save) pantry_pro_pattern_save
Export (admin-post.php?action=pantry_pro_pattern_export) pantry_pro_pattern_export
Row links: enable, disable, delete, up, down pantry_pro_redirects_pattern_row
Bulk enable, disable, delete bulk-pantry-pro-pattern-rules

The vendored Pantry stylesheet is enqueued on this screen only. No JavaScript, no front-end assets.

Hooks

The module adds no hooks of its own. It uses one the free plugin publishes:

pantry_redirects_pre_match

Added in Pantry Redirects 1.1.0. Runs on template_redirect before the exact lookup. Return a rule row to send the visitor there; return the value you were given to leave matching alone. This module hooks it at priority 10 and answers with the first pattern that matches, its target already carrying the capture groups.

add_filter( 'pantry_redirects_pre_match', function ( $rule, $request ) {
	if ( null !== $rule || 0 !== strpos( $request, '/legacy/' ) ) {
		return $rule;
	}
	return (object) array(
		'id'     => 0,
		'source' => $request,
		'target' => '/archive' . $request,
		'type'   => 301,
	);
}, 5, 2 );

The row's id is the row whose hit count the free plugin raises, so an id of 0 means the redirect is sent without being counted.

Matching, in order

  1. The free plugin normalises the request: leading slash, no trailing slash, lower case, query string kept on the end.
  2. This module is asked first. It tries every enabled pattern in match order against the full request, and then, when the free plugin's "Ignore query strings" setting is on and the request had a query string, against the path without it.
  3. The first pattern that matches has its target built from the capture groups and checked. A target that comes out empty or unusable is skipped and the next pattern is tried.
  4. If no pattern matched, the free plugin's exact lookup runs as usual.

A capture group that starts with a slash cannot turn a path target into a protocol-relative address: leading slashes are collapsed to one, so /$1 can never become //example.com.

Costs

One indexed SELECT on the rules table per front-end request, limited to 500 enabled pattern rules, plus the autoloaded order option. Nothing is cached, so a pattern works the moment it is saved.

Comparison

How Pantry Redirects compares with the popular plugins that do the same job, and when one of them is the better choice.

Pantry Redirects compared with Redirection, Rank Math and Yoast SEO

The plugin