How to intercept 404 and suggest related content with PHP in WordPress

Contents

Introduction

Goal: intercept WordPress 404 requests and suggest relevant content to visitors using PHP. This reduces bounce rate, helps users find what they want, and preserves SEO signals if handled correctly.

What youll learn: multiple practical implementations (theme 404 template change, template_redirect hook, fulltext queries, fuzzy title matching), caching and invalidation, best practices (SEO, performance, security), and example code you can drop into a plugin or theme functions.php.

High-level approaches

  • Enhance 404 template: modify your themes 404.php to compute and display suggestions. Simple and safe.
  • Intercept via hooks: use template_redirect or parse_request to collect suggestions before template is loaded and optionally provide a custom template or redirect.
  • Redirect to best match: attempt to find a canonical existing post and redirect (301/302). Use carefully — preserving 404 status is often better for SEO unless the page truly moved.
  • Use search engines / plugins: integrate with Relevanssi/ElasticPress/Algolia for production-grade relevance and performance.

Important SEO decision

Keep the 404 status if the requested URL is genuinely missing. Show suggestions inside the 404 page (HTTP 404) to signal search engines that the page doesnt exist while still helping the user. Only redirect with a 301/302 if the missing URL should permanently/temporarily map to an existing URL.

Practical implementation 1 — Minimal enhancement inside 404.php

This is the simplest. Add a function to your themes functions.php to compute suggestions, then call it from 404.php where you want suggestions to appear.

Step A: helper function (functions.php)

 array( post, page ),
        s              => s,
        posts_per_page => limit,
        post_status    => publish,
        no_found_rows  => true,
        ignore_sticky_posts => true,
    ) )

    return query->posts
}
?>

Step B: display from 404.php


Practical implementation 2 — Using template_redirect to prepare suggestions (plugin style)

This approach lets you compute suggestions once and inject them into the 404 template via a global or via a filter. It keeps logic out of theme templates and is portable as a plugin.

Example plugin-style code

 array( post, page ),
        s => s,
        posts_per_page => limit,
        post_status => publish,
        no_found_rows => true,
    ) )

    return q->posts
}

// Convenience getter for theme authors
function get_404_suggestions() {
    return isset( GLOBALS[my_404_suggestions] ) ? (array) GLOBALS[my_404_suggestions] : array()
}
?>

How to display in 404.php (theme)


Method variations: taxonomy-based matching

Often slugs and URL tokens correspond to categories or tags. A two-step approach improves relevance: try to match taxonomy terms first, then fallback to search.

Algorithm

  1. Extract tokens from URL path.
  2. Try to find tags or category slugs that match any token using get_terms.
  3. If you find terms, query posts in those terms sorted by date or relevance.
  4. If no taxonomy matches, fall back to keyword search or fuzzy matching.

Example: match tags first

 post_tag,
        slug => parts,
        hide_empty => true,
    ) )

    if ( ! is_wp_error( found_terms )  ! empty( found_terms ) ) {
        term_ids = wp_list_pluck( found_terms, term_id )
        q = new WP_Query( array(
            post_type => post,
            tax_query => array(
                array(
                    taxonomy => post_tag,
                    field => term_id,
                    terms => term_ids,
                ),
            ),
            posts_per_page => limit,
            no_found_rows => true,
            post_status => publish,
        ) )
        return q->posts
    }

    // fallback to slug keyword search
    return wp_404_suggestions_by_slug( limit )
}
?>

Advanced: fuzzy title matching (Levenshtein / similar_text)

Fuzzy matching is useful for typos. The technique below loads candidate posts (limited set) and computes similarity to the requested slug or query string using similar_text() or levenshtein().

Example: fuzzy top-N matching

 array( post, page ),
        posts_per_page => 200,
        no_found_rows => true,
        post_status => publish,
    ) )

    scores = array()
    foreach ( q->posts as p ) {
        title_slug = sanitize_title( get_the_title( p ) )
        // Use similar_text (0-100) for a simple percent match
        similar_text( candidate, title_slug, percent )
        scores[ p->ID ] = percent
    }

    arsort( scores )
    best_ids = array_slice( array_keys( scores ), 0, limit )
    if ( empty( best_ids ) ) {
        return array()
    }

    best_posts = get_posts( array(
        post__in => best_ids,
        orderby => post__in,
        posts_per_page => limit,
    ) )
    return best_posts
}
?>

Notes

  • Limit the candidate set (e.g., recent 200 posts) to avoid scanning the entire DB on every 404.
  • For very large sites, avoid PHP-level loops across thousands of posts — instead use specialized search systems.

Advanced: fulltext MySQL MATCH AGAINST via wpdb (faster relevance)

If your posts table has a fulltext index (title, content), a MATCH…AGAINST query can return good relevance quickly. Be very careful to sanitize inputs and understand your MySQL setup. This approach is more advanced and requires DB privileges and index management.

Example fulltext search with wpdb

esc_like( candidate )
    against = wpdb->prepare( %s, against ) // still pass through prepare

    // Note: ensure your site has FULLTEXT indexes on wp_posts (post_title, post_content).
    sql = wpdb->prepare( 
        SELECT ID, post_title, MATCH(post_title, post_content) AGAINST (%s IN NATURAL LANGUAGE MODE) AS relevance
        FROM {wpdb->posts}
        WHERE post_status = publish
        AND post_type IN (post,page)
        AND MATCH(post_title, post_content) AGAINST (%s IN NATURAL LANGUAGE MODE)
        ORDER BY relevance DESC
        LIMIT %d
    , candidate, candidate, limit )

    rows = wpdb->get_results( sql )
    if ( empty( rows ) ) {
        return array()
    }

    ids = wp_list_pluck( rows, ID )
    return get_posts( array( post__in => ids, orderby => post__in, posts_per_page => limit ) )
}
?>

Warnings

  • Make sure to test queries on staging and verify fulltext indexes exist.
  • Use prepared statements and escaping to prevent SQL injection.
  • MYSQL boolean/fulltext modes behave differently tune as needed.

Caching and performance

404 pages can be common. Avoid running heavy queries on every request. Use transient caching keyed by the normalized request path, and invalidate intelligently.

Strategy

  1. Compute a cache key based on sanitized URL path.
  2. Store suggestion results in a transient for a short TTL (e.g., 1 hour or more depending on content churn).
  3. Invalidate or delete related transients when you update posts/taxonomies using save_post and edited terms hooks for better freshness.

Example: caching wrapper

query( wpdb->prepare( 
        DELETE FROM {wpdb->options}
        WHERE option_name LIKE %s
    , like ) )
    // Note: This approach manipulates options table and requires care.
}
?>

Notes on invalidation

  • For large sites, avoid broad transient deletes. Instead track dependencies (e.g., which transients include a post or term) and clear selectively.
  • When using object cache like Redis/Memcached, set unique keys and expire regularly.

Redirecting to best match (use with care)

Sometimes its desirable to redirect the user to the best-matching existing resource. If you implement redirects, do not silently change a 404 to a 200 without logic. Use 301/302 when appropriate and possibly support a human review process for mass redirects.

Example: immediate redirect to single best match


SEO caution

  • Redirecting every 404 to your homepage or unrelated content is bad practice (it wastes crawl budget and hides missing pages).
  • Prefer showing suggestions within a true 404 page unless you have a verified mapping or very high-confidence match.

UX and presentation tips

  • Show the requested URL and suggest alternatives, e.g., Looking for /category/post-name? These pages might help.
  • Group suggestions: exact title matches, taxonomy matches, popular posts, or site search box.
  • Provide a prominent search box and links to site map, categories, and contact/help resources.
  • Consider adding analytics events for suggestion clicks to measure effectiveness.

Analytics tracking example (simple onclick snippet)

Include event attributes in suggestion links so your analytics can measure engagement. Below is an example link output actual JS tracking logic belongs in your analytics implementation.


Testing checklist

  • Test a variety of broken URLs (typos, missing slugs, deep nested paths).
  • Verify the 404 HTTP status remains 404 unless you deliberately redirect.
  • Measure performance impact under expected traffic — avoid slow DB queries on critical paths.
  • Check security: ensure inputs are sanitized before using in queries or output to HTML.
  • Monitor analytics to confirm suggestion clicks and conversions.

When to adopt a specialized search solution

For medium/large sites or when relevance matters, integrate a dedicated search platform:

  • Relevanssi (plugin) — better ranking than WP core search.
  • ElasticPress / ElasticSearch or Algolia — fast, scalable, and supports advanced relevance.
  • These services remove heavy DB work from PHP and provide much better matching for synonyms, typo tolerance, and facets.

Security and sanitization reminders

  • Always sanitize any data derived from _SERVER or parsed URLs: use sanitize_text_field(), sanitize_title(), esc_attr(), esc_url() where appropriate.
  • When running direct SQL via wpdb, always use wpdb->prepare and avoid interpolating raw values.
  • Respect post_status do not suggest drafts or private posts to anonymous visitors.

Summary of recommended implementation

Small site / quick fix Modify 404.php to call a slug-to-search helper with an internal WP_Query fallback. Keep HTTP 404 status.
Medium site Compute suggestions via template_redirect into a cached transient prefer taxonomy-first keyword fallback track clicks.
Large site / high traffic Use external search (ElasticSearch / Algolia). If using DB, create FULLTEXT indexes and use MATCH…AGAINST with careful caching and partial invalidation.

Useful links

Appendix — full working sample plugin (combined)

This combined example demonstrates a production-minded approach: taxonomy-first, fallback search, caching, and a theme getter. Drop into a plugin file.

 post_tag,
            slug => parts,
            hide_empty => true,
        ) )
        if ( ! is_wp_error( terms )  ! empty( terms ) ) {
            term_ids = wp_list_pluck( terms, term_id )
            q = new WP_Query( array(
                post_type => post,
                tax_query => array(
                    array(
                        taxonomy => post_tag,
                        field => term_id,
                        terms => term_ids,
                    ),
                ),
                posts_per_page => limit,
                no_found_rows => true,
                post_status => publish,
            ) )
            if ( q->have_posts() ) {
                return q->posts
            }
        }
    }

    // fallback to keyword search from slug
    return rs_404_keyword_search( limit )
}

function rs_404_keyword_search( limit = 6 ) {
    requested = isset(_SERVER[REQUEST_URI]) ? wp_unslash( _SERVER[REQUEST_URI] ) : 
    path = trim( parse_url( requested, PHP_URL_PATH ), / )
    if ( empty( path ) ) {
        return array()
    }
    candidate = sanitize_title( end( explode(/, path ) ) )
    if ( empty( candidate ) ) {
        candidate = sanitize_title( str_replace( /,  , path ) )
    }
    if ( empty( candidate ) ) {
        return array()
    }
    terms = preg_split( /[-_s] /, candidate )
    s = implode(  , array_filter( terms ) )

    q = new WP_Query( array(
        post_type => array( post, page ),
        s => s,
        posts_per_page => limit,
        no_found_rows => true,
        post_status => publish,
    ) )
    return q->posts
}

// Getter for themes
function rs_get_404_suggestions() {
    return isset( GLOBALS[rs_404_suggestions] ) ? (array) GLOBALS[rs_404_suggestions] : array()
}

// Simple invalidation on post save (broad)
add_action( save_post, rs_clear_404_cache, 10, 3 )
function rs_clear_404_cache( post_id, post, update ) {
    global wpdb
    like = %rs_404_%
    wpdb->query( wpdb->prepare( 
        DELETE FROM {wpdb->options}
        WHERE option_name LIKE %s
    , like ) )
}
?>

Final technical reminders

  • Do not return HTTP 200 for an actually missing URL unless intentionally mapping to a canonical resource.
  • Keep heavy processing off the main request path when possible use caching or async indexing.
  • Test on a staging site before deploying to production.


Acepto donaciones de BAT's mediante el navegador Brave 🙂



Leave a Reply

Your email address will not be published. Required fields are marked *