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
- 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-pageare the same redirect. A source may carry a query string (/product?id=12) when you want to redirect one exact address. - Target is either a path on this site (
/new-page) or a full address somewhere else (https://example.com/new-page). - Type is 301 (permanent), 302 (found, temporary) or 307 (temporary).
- 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,.woff2and 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
SELECTon 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 onplugins_loadedand hooksinitat 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 beforeinit. Every hook this plugin needs fires afterinit, 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 thanwp_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 withmanage_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/coresettings class, which is shared and not written here. - 353 lines are the two
WP_List_Tablesubclasses. 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.phponly 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-slugto/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.phpwith the Settings API's nonce and themanage_optionscapability.
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.
- Wildcard, where
- Capture groups in the target:
$1to$9are replaced with what the pattern captured, so/old-shop/*to/shop/$1sends/old-shop/blue-hatto/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
- The free plugin normalises the request: leading slash, no trailing slash, lower case, query string kept on the end.
- 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.
- 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.
- 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