Confident 404 Redirect

Description

When a URL 404s, Confident 404 Redirect looks for the post that URL was probably meant to reach. If it is confident, it redirects. If it is not, the visitor gets a normal 404 page by default, or whatever fallback you choose instead.

The usual approach is to send every missing URL to the homepage. That returns HTTP 200 for a page that does not exist, which search engines read as a soft 404, and it hides broken links instead of surfacing them. You can switch it on here, but it is not the default.

What it does

  • Matches the requested slug against your post slugs, including WordPress’ own -2 duplicate suffix; a directory’s index file, /about/index.php or /services/default.aspx, is read as the directory it indexes, and one at the root names the root, which is left alone like the root itself
  • Reads a page number WordPress itself routed off a post or page address – /old-slug/page/2/, /old-slug/2/, /old-slug/comment-page-2/ – as a page of old-slug, matches on the slug before it and redirects to the page itself, since the corrected post may not have that page; an address WordPress did not route that way is read as it stands
  • Matches against post titles when the slug has changed completely
  • When two posts share a slug or a title, the rest of the requested path decides between them: a request one letter off below /north/ goes to the team page under /north/, and a tie the path cannot settle is never redirected
  • Redirects only above a confidence threshold you set, anywhere from 50% to 100%, and 90% by default
  • Limits matching to the post types you choose, or searches every public type a visitor can open
  • Optional fallback for everything else: an honest 404, 410 Gone, the homepage, or a URL of your own that WordPress will redirect to. A fallback redirect is sent as a temporary 302, whatever status the matched redirects carry: nothing was found at the missing address, so nothing permanent is said about it. An address of this site spelled without a scheme takes the site’s own scheme
  • Takes over WordPress’ own permalink guessing by default, and hands it back if you would rather keep it
  • Carries the recognised campaign tags (a fixed list: utm_source and its eight siblings, plus gclid, fbclid and the other click identifiers) across same-origin redirects, so attribution survives the move; everything else in the request query stays behind, no request parameter is appended to a redirect on another origin – another host or port, or a downgrade to http; the https upgrade of the same host is the one origin change the tags cross, because WordPress’s own canonical redirect keeps the scheme a request came on – and a query flooded with parameters is treated as noise. The actual Location produced by WordPress’ redirect filters is checked again, so a late filter cannot move a locally carried query to another origin. A query already configured on the destination URL remains part of that destination
  • Path exclusions with wildcards, matched against the path after the site’s own address: on a site at example.org/blog/, /shop/* covers /blog/shop/, and a line spelled with the site’s own path is flagged on the settings screen, with the spelling that would match; a full address of this site – its own host and port, on either scheme, an internationalised domain in its punycode or its own alphabet where PHP’s intl extension is available – is stored as the path it names, and one naming this host on another port, or another site altogether, is flagged too, as is a fallback URL WordPress no longer redirects to; a doubled leading slash before a plain name is read as the path it is, an address of this host outside the site’s own directory is flagged as such, and a line that would match every path is named on the screen as switching the plugin off

What it does not do

  • No front-end assets. No stylesheet and no script are loaded on any public page.
  • No .htaccess changes. The plugin never writes to your server configuration.
  • No dashboard notices. Nothing appears outside its own settings screen.
  • No ads, no upsell popups, no review nags.
  • No database writes on the public site outside a 404. A page that loads normally costs nothing at all; the first eligible 404 of the day may refresh one cached count of your content, and so may opening the plugin’s settings screen, which shows that count.

Always excluded

Anything that asks for a file rather than a page. A URL ending in a recognised extension (one to fifteen ASCII characters, at least one of them a letter) is left alone unless the extension is one a page can have, such as .html, .php or .aspx from a site migrated off another system, so a format nobody anticipated is excluded rather than answered with HTML.

Also anything under the uploads directory and WordPress’s own directories wherever the site keeps them – its own directory when it lives in one, and the admin, includes and content directories – feeds, sitemaps, the embed and trackback endpoints and the endpoints plugins register (a shop’s account pages, AMP), any path with a hidden dot-prefixed directory in it, robots.txt, favicon.ico, .well-known, wp-admin, wp-json, REST, XML-RPC, cron, WP-CLI, the site root however it is spelled (/index.php?p=123 included), and any request that is not GET or HEAD. A path is left alone when its percent escapes are malformed, decoding would introduce a new separator, or the decoded spelling contains a backslash, invalid UTF-8, a control or line separator, or one of the invisible characters WordPress itself strips from a slug – the zero-width and bidi characters, the soft hyphen, the byte order mark. The plugin refuses the whole spelling rather than repairing it into another path. The marks WordPress keeps in a slug, such as the variation selector an emoji ends in, are part of the address and stay valid.

For developers

Filters: confident_404_redirect_engines, confident_404_redirect_match, confident_404_redirect_target, confident_404_redirect_carried_params, confident_404_redirect_post_types, confident_404_redirect_candidate_limit, confident_404_redirect_scan_limit, confident_404_redirect_ambiguity_margin.

confident_404_redirect_post_types may narrow, restore or reorder the searchable public post types. Values outside that public, viewable set are discarded.

A redirect target is sent exactly in the spelling it was judged in: an absolute URL on a host WordPress accepts, on a port a socket can have, or a path with a leading slash. A relative path without one would have to be resolved against the request that just 404ed, so it is refused instead.

A callback that throws – on any hook this plugin fires, or on a WordPress filter it consults, and whatever it throws, the plugin’s own Read_Failure included – ends the request the way it would anywhere in WordPress. Core has no way to unwind a hook frame, and this plugin does not pretend to: a swallowed throw would leave the dispatcher mid-frame for the rest of the request, with the later callbacks on that hook never run and current_filter() answering a hook that had finished. What the plugin does absorb is what never crossed a hook – a third-party engine that throws from its own code, or a Match_Result built with an impossible score – and those stand the whole verdict down for that request, no redirect, no suggestion, no fallback and no action, the same as a read the database refused.

Actions: confident_404_redirect_before, confident_404_redirect_no_match. The before-redirect action fires only after the plugin has applied WordPress’ redirect filters and accepted their final Location and status as safe; its URL and status are the final values after those filters and URL sanitisation. The no-match action fires before the configured fallback, which may still redirect – as a temporary 302, unless a wp_redirect_status filter says otherwise – or return 410. Neither the action nor the fallback runs for a page the plugin found but may not send – a confident match whose redirect is refused or cancelled, whether as a loop, as an address WordPress will not accept, or by a filter’s answer, and an engine or match filter whose winner has such an address and could have been sent – past the confidence threshold, and not beaten by a readable candidate: that page exists, and the request ends as an honest 404. Among equally close candidates, one with such an address counts at the tie’s own score: the rest of the path might have named it, and that cannot be checked without its address, so the request ends as a 404 where, with every address readable, a tie the path leaves standing would take the fallback. A refused candidate the plugin would not have sent anyway is set aside, and the rest of the verdict, fallback included, goes on without it. Where a refusal does end the verdict, the confident_404_redirect_match filter is still asked, with null, and a result it returns replaces the refused page only when it is safe and confident enough to send.

When no redirect is sent, a safe candidate can still be useful to the 404 template. Usually it stayed below the confidence threshold; a safe original candidate also remains available when WordPress or a target filter cancels or rewrites the redirect. A refused unsafe or same-page URL is never published as a suggestion. A theme can offer a surviving candidate from 404.php:

$match = PlugForge\Confident404\Redirector::suggestion();

if ( $match ) {
    printf(
        '<p>Did you mean <a href="%s">%s</a>?</p>',
        esc_url( $match->url() ),
        esc_html( $match->title() )
    );
}

The result also carries score(), object_id() and engine(). It is null when the plugin redirected, found nothing worth offering, found a page whose address it may not send, or could not get an answer from the database and declined to guess.

Matching engines implement a single interface, so adding your own takes a few lines. The contract it has to keep: should_run() and find() may throw Read_Failure to say the database could not answer, and the whole verdict stands down for that request, fallback included; any other exception thrown from the engine’s own code stands it down the same way rather than reaching a Location header, while one thrown from inside a hook the engine ran propagates as it would anywhere; and a Match_Result score has to be a finite number from 0 to 100 – the constructor refuses anything else, and an engine that tries counts as a verdict nobody could reach. An engine whose winner has an address the plugin may not send throws Refused_Target::scored() with the most that winner could have earned – for a tied candidate, the tie’s own score before any demotion for the tie – rather than returning null: null lets the next engine answer for a page it was never about. The verdict then ends as an honest 404 when that score could have been sent and no readable candidate clearly beats it, and the refusal is set aside otherwise.

Screenshots

Installation

  1. Install and activate the plugin.
  2. Go to Settings Confident 404 Redirect.
  3. The defaults are the recommended setup, so you do not have to change anything.

FAQ

Why not redirect everything to the homepage?

The visitor asked for a specific page and the homepage is not it. A 200 response for a URL that does not exist is also a soft 404, which tells search engines your site is padded with thin pages. If you want that behaviour anyway, it is one setting away. It is not the default.

Does it slow my site down?

No. Nothing runs on a page that loads normally. A unique exact slug hit costs one indexed lookup, plus a permalink read for whatever it found – one query on a plain permalink structure, a few more where the structure asks for categories or ancestors – unless that post is already in the cache. A slug several posts share pays that permalink read per candidate.

A miss costs more. The near-match pass reads up to four indexed prefix scans, widest first – the first word of the slug, then two, three, and the slug missing its last character – and stops at the first that comes back whole and contains the narrower ones – which the prefixes of one slug normally do, though a slug filter can make them not, and then the ladder reads on; on a site where the first word is rare that is one scan. Title matching, where it runs, adds one more: a single bounded read of the titles themselves – one row past the scan threshold at most, which is how it notices a corpus that outgrew the guard and stands down – scored in PHP, which is also what lets a Latin URL find the Cyrillic title a slug plugin transliterated it from. A URL that matches nothing at all costs up to seven queries in the measured baseline, and a slug a dozen posts share, where each candidate’s permalink is read, measured at about thirteen – both numbers from a plain permalink structure, which is what the benchmarks run; a category-based structure adds its own reads to every permalink.

Once a day, the first 404 that gets as far as title matching also refreshes a cached count of your content, which adds a few queries and, on a site of twenty thousand posts, tens of milliseconds. When the cache retains that count, later 404s read it; when storage cannot retain it, title matching alone stands down after the count and asks again later instead of paying for an unguarded title scan – slug matching, your fallback and the no-match action carry on, because the database did answer. A count the database itself refuses is different: that stands the whole matcher down for the request, fallback included, because an unanswered read is not a 404 verdict. A 404 that resolves on a unique, perfect slug match never asks for the count at all – anything less certain still consults it before title matching weighs in.

Why did it not redirect when there was an obvious match?

Several things can stop it, and they all come down to the same rule: a permanent redirect to the wrong page is worse than a 404. The three most common are these.

The score was below your confidence threshold, which you can lower under Settings.

Or the two best candidates were too close to tell apart, whether the slug engine, the title engine, or one of each found them. Two pages called Contact score the same; two slugs one character away from what was asked for score within a point or two of each other, and that gap measures their length rather than how close they are. Two engines finding the same post still count as one candidate. A close pair of different posts identifies neither, so neither earns a permanent redirect. The rest of the requested path can still settle it: two pages slugged or titled alike under two parents are told apart by the parent the request was made below; a request at the root prefers the candidate at the root, so a site whose translations share a slug under a language directory still gets its default-language near misses; two candidates at one address – a post and a page sharing a slug – are one destination and no tie; a request on one language domain prefers the translation on that domain; when one of them has an address the plugin would not send nothing is offered at all; and the comparison ignores ASCII case, as WordPress does when it routes a path. confident_404_redirect_ambiguity_margin sets how wide the gap has to be, and zero restores acting on anything that is not an exact draw.

Or the URL pointed into a crowded group: a slug that more than five posts share exactly, or a prefix of the requested slug – down to its first word – that more slugs share than the plugin reads at once, twenty-five unless confident_404_redirect_candidate_limit says otherwise. There the plugin can only see a sample of the group, and the post you wanted may not be in it, so the closest it saw is offered as a suggestion and never sent. Either kind of crowd holds back every other match for that request too, a title match included: a title unique among those posts is no evidence which of them the visitor meant, and a slug the plugin did not read may be closer than the title. On a site where more slugs than that share the first word of a mistyped address, this means such a typo is offered as a suggestion rather than redirected.

When there is a usable near miss, the plugin offers it to your 404 template instead, so a theme can still ask the visitor whether that was what they meant. That includes a near miss the plugin deliberately held below its score floor – a crowded slug, a tie, a truncated sample – which is offered but never redirected. A title whose raw similarity falls below the floor is noise and is never offered; nor is a URL that is unsafe or resolves back to the same request, or a match abandoned because the database could not answer.

Does it work with caching plugins?

Yes, with one caveat that belongs to the cache rather than the plugin: WordPress can only redirect a 404 it actually serves. Most page caches pass 404s through, which is their common default, and the plugin adds nothing to pages that are cached. A server cache or CDN configured to cache 404 or redirect responses will keep serving its stored copy until it expires or is purged.

Does it work with HyperDB or LudicrousDB?

Yes. The plugin watches its own database reads through wpdb’s query filter and its query counter, which every drop-in built on wpdb keeps. wpdb’s ready flag is read only on a stock wpdb: those drop-ins never set it, and reading it there once stood the plugin down on every request behind them. A connection that dies mid-request is read as an unanswered read, not as an empty site: WordPress reports that failure to nobody once the template stage has begun, and the plugin reads the result handle it leaves behind.

Will it redirect my images, or my JSON and CSV files?

No. Anything under this site’s own uploads directory – the one on this site’s host and port; uploads moved to another host, or another port of this host, are another site’s – is excluded, and so is any URL ending in a recognised file extension, unless that extension is one a page can have. The rule works that way round on purpose: a format nobody thought of is left alone rather than answered with a page.

The settings screen says the site address cannot be read. What does that mean?

The plugin reads the site’s home address once per request and judges every path and its own directories against it, while a redirect target and the loop check are judged against the origin the visitor actually requested, so it has to be a plain http or https address of an ASCII host, on a port a socket can have, with no credentials. An internationalised domain is spelled in punycode there, as WordPress itself expects; a home_url filter has to answer an address. Until it does, the plugin stands down – no redirect, no fallback – and, under the default guessing setting, hands WordPress its own permalink guessing back rather than hold it down for nothing.

Why is title matching switched off on my site?

WordPress does not index post titles, so searching them means reading every title, on every 404 that gets that far. The cost grows with your content – tens of milliseconds a few thousand items in, and around a second at the twenty-thousand ceiling on the benchmark site – and crawlers produce 404s in bulk, so above a threshold of 2,000 items the plugin stands the title engine down. Slug matching keeps working.

The count is of the content the plugin is set to search, so narrowing the post types narrows it too. The threshold is filterable through confident_404_redirect_scan_limit, up to a hard ceiling of twenty thousand. The title pass costs the corpus size per 404, linearly, times a per-title price that grows with title length – measured at that ceiling over ordinary titles, a miss across twenty thousand titles takes around a second on the benchmark site, and a corpus of titles hundreds of characters long can multiply that – so raising the threshold trades 404 latency for reach, and the ceiling is where that trade stops being offered.

Reviews

There are no reviews for this plugin.

Contributors & Developers

“Confident 404 Redirect” is open source software. The following people have contributed to this plugin.

Contributors

Changelog

0.1.0

  • First release: slug and title matching, a confidence threshold, post type limits, fallback modes, path exclusions, recognised campaign tags carried across same-origin redirects, and control over WordPress’ built-in permalink guessing.