How to intercept post saving to generate custom slugs in PHP in WordPress

Contents

Overview

This tutorial explains, in exhaustive detail, how to intercept post saving in WordPress to generate custom slugs using PHP. You will learn the recommended hooks, examples for basic and advanced slug generation, handling of edge cases (autosaves, revisions, REST/Gutenberg), multilingual/transliteration strategies, uniqueness handling, best practices to avoid infinite loops, and a complete example plugin you can drop into your installation.

Why intercept post saving to generate custom slugs?

  • SEO: Create human-friendly URLs that include keywords, taxonomies, or custom field values.
  • Business rules: Enforce specific patterns (e.g., prefix by category, include product SKU).
  • Localization: Generate slugs appropriate for different languages or transliterated forms of non-Latin titles.
  • Consistency: Ensure slugs follow organization-wide formatting rules (lowercase, no stop-words, etc.).

Available hooks and approaches (pros cons)

  • wp_insert_post_data (filter) — Recommended:
    • Runs just before data is sanitized and saved to DB.
    • You can change post_name (slug) in the sanitized array and WordPress will save it.
    • Works for admin inserts, REST/Gutenberg and programmatic inserts where WP uses wp_insert_post().
  • save_post (action) — Alternative:
    • Runs after the post is saved to DB. To change slug you generally call wp_update_post().
    • Requires care to avoid infinite loops (remove_action/add_action pattern).
    • Has access to post ID and meta after save (useful when you need saved meta values to build slug).
  • sanitize_title (filter) — Global slug generation changes:
    • Intercepts the sanitization step used to create slugs from any title or string. Useful for global transliteration or stopword removal.
    • Affects every call to sanitize_title(), so handle carefully to not break other plugins.
  • wp_unique_post_slug (function) — Ensures uniqueness:
    • Call this if you need to ensure the final slug is unique according to WordPress rules at the time you set it.

Core principles and safety checks

  1. Always check for autosave and revision: do not alter slugs during autosave or when handling revisions.
  2. Make sure you target correct post types and statuses to avoid unexpected changes.
  3. Use capability checks in save_post flows when acting upon user-submitted data.
  4. Avoid heavy database queries inside hooks on every save to keep admin performance responsive.
  5. When using save_post and performing wp_update_post, remove your save_post hook before updating to avoid recursion.

Example 1 — Basic slug from title using wp_insert_post_data (recommended)

This example rewrites post_name based on either a custom field _custom_slug if present, otherwise the post title. It runs on insert/update and respects autosave and revision checks.

lt?php
// Basic example: generate slug from custom field or title using wp_insert_post_data
function my_custom_slug_wp_insert( data, postarr ) {
    // Only target post type (change as needed)
    if ( post !== data[post_type] ) {
        return data
    }

    // Avoid autosaves and revisions
    if ( defined( DOING_AUTOSAVE )  DOING_AUTOSAVE ) {
        return data
    }
    if ( ! empty( postarr[post_ID] )  wp_is_post_revision( postarr[post_ID] ) ) {
        return data
    }

    // Use a custom field value if provided (stored as plain text)
    custom_slug = 
    if ( ! empty( postarr[ID] ) ) {
        custom_slug = get_post_meta( postarr[ID], _custom_slug, true )
    }

    // Choose source for slug
    source = custom_slug ? custom_slug : ( data[post_title] ?:  )

    // Generate a sanitized slug
    slug = sanitize_title( source )

    // Optionally, ensure uniqueness now:
    post_id = ! empty( postarr[ID] ) ? (int) postarr[ID] : 0
    slug = wp_unique_post_slug( slug, post_id, data[post_status], data[post_type], data[post_parent] )

    data[post_name] = slug

    return data
}
add_filter( wp_insert_post_data, my_custom_slug_wp_insert, 10, 2 )
?gt

Why wp_insert_post_data is generally safer

  • It runs before the DB insert and allows you to set post_name directly, so you avoid a second update operation.
  • It works for REST API saves done by Gutenberg because wp_insert_post_data is invoked there as well.

Example 2 — Using save_post when you must rely on saved meta

Sometimes you need the meta to be saved first (for example, the slug depends on a file upload or on meta saved by another hook that runs during save). In those cases, use save_post but take care to avoid infinite looping.

lt?php
// Example using save_post: update post_name after saving, avoid recursion
function my_custom_slug_on_save( post_id, post, update ) {
    // Autosave / revision checks
    if ( wp_is_post_autosave( post_id )  wp_is_post_revision( post_id ) ) {
        return
    }

    // Permission check (optional)
    if ( ! current_user_can( edit_post, post_id ) ) {
        return
    }

    // Only handle post type here
    if ( post !== post->post_type ) {
        return
    }

    // Example: build slug from a meta value saved by the same form
    meta_source = get_post_meta( post_id, _my_meta_for_slug, true )
    source = meta_source ? meta_source : post->post_title
    slug = sanitize_title( source )

    // Ensure unique slug
    slug = wp_unique_post_slug( slug, post_id, post->post_status, post->post_type, post->post_parent )

    // If slug is already the same, do nothing
    if ( slug === post->post_name ) {
        return
    }

    // Prevent recursion
    remove_action( save_post, my_custom_slug_on_save, 10 )

    // Update the post_name
    wp_update_post( array(
        ID        => post_id,
        post_name => slug,
    ) )

    // Re-hook
    add_action( save_post, my_custom_slug_on_save, 10, 3 )
}
add_action( save_post, my_custom_slug_on_save, 10, 3 )
?gt

Example 3 — Global slug rules via sanitize_title

If you want to globally adjust how titles are converted into slugs (for example, strip stop words, transliterate differently, or remove certain characters across the site), use sanitize_title. Remember: this affects every call to sanitize_title().

lt?php
// Global slug processing: remove a list of stop words and transliterate
function my_sanitize_title_filter( title, raw_title, context ) {
    // Only tweak for slug context (not for other uses)
    if ( save !== context  display !== context  query !== context ) {
        // Adjust as needed many calls provide different contexts
    }

    // Convert to lowercase
    title = mb_strtolower( title, UTF-8 )

    // Remove specific stop words (example)
    stop_words = array( the, a, an, and, or, of, for )
    title = preg_replace( /b( . implode( , array_map( preg_quote, stop_words ) ) . )b/u,  , title )

    // Trim excessive whitespace
    title = trim( preg_replace( /s /,  , title ) )

    // Use WPs remove_accents to transliterate non-latin characters where possible
    title = remove_accents( title )

    // Let WP continue with default sanitization
    return title
}
add_filter( sanitize_title, my_sanitize_title_filter, 10, 3 )
?gt

Multilingual and non-Latin characters

  • WordPress ships with remove_accents() to transliterate many accents and non-Latin characters. Use it before sanitize_title or inside sanitize_title filter.
  • For complex transliteration (e.g., Chinese, Arabic), consider a library tailored to those scripts — but be careful of performance and dependencies.
  • Always test the resulting slugs in your environment and with your permalink structure.

Ensuring uniqueness correctly

WordPress normally handles uniqueness of slugs if the post_name is empty (it will compute one). When you set post_name yourself via wp_insert_post_data, call wp_unique_post_slug() if you need to compute a final unique slug immediately. The function signature:

// wp_unique_post_slug( slug, post_ID, post_status, post_type, post_parent )

For new posts (post_ID = 0) WordPress will still accept the slug and later ensure uniqueness, but calling wp_unique_post_slug with 0 will append numeric suffixes if there are conflicts — so it can be used during insert as well.

Targeting specific post types, taxonomies, and statuses

Always restrict your hook code to only handle the post types and statuses you need. Example checks include:

  • if ( product !== post->post_type ) return
  • if ( publish !== post->post_status private !== post->post_status ) return
  • for hierarchical post types (pages), supply post_parent to wp_unique_post_slug so uniqueness is evaluated in the correct context.

Handling hierarchical post types (pages)

Pages take parent/child relationships into account for unique slugs. When checking uniqueness with wp_unique_post_slug, pass the post_parent argument so WordPress can compute the right suffixes relative to siblings.

Performance and security tips

  • Keep slug-generation code lightweight — avoid expensive external API calls on every post save.
  • If you must do heavy work (e.g., call an external service), offload that to a background job and do not block the save process.
  • Sanitize and validate any input you use for slug generation (even meta values) to avoid introducing harmful characters.
  • Avoid altering slugs for every update unless desired — changing permalinks can break inbound links. Consider marking posts as slug locked to prevent unintended updates.

Debugging and testing

  1. Enable WP_DEBUG and WP_DEBUG_LOG to capture PHP notices or errors.
  2. Use error_log() to trace values inside your hook functions during development.
  3. Test via:
    • the post editor (classic),
    • Gutenberg/editor via REST,
    • wp_insert_post() programmatic inserts,
    • quick edit and bulk edits (they may behave differently).
  4. Test for edge cases: empty titles, same titles for multiple posts, new vs. update, hierarchical parents, and custom post types.

Complete plugin example

Drop this file into wp-content/plugins/custom-slug-generator/custom-slug-generator.php and activate it. It demonstrates:

  • wp_insert_post_data hook approach
  • wp_unique_post_slug usage
  • restricting to selected post types
  • basic transliteration
lt?php
/
  Plugin Name: Custom Slug Generator
  Description: Intercepts post saving and generates a custom slug based on custom fields or title with transliteration and uniqueness.
  Version: 1.0
  Author: Example Author
 /

// Prevent direct access
if ( ! defined( ABSPATH ) ) {
    exit
}

/
  Generate a custom slug for posts and selected post types.
 
  @param array data   An array of sanitized post data to be inserted into the database.
  @param array postarr The original (unsanitized) array of post data.
  @return array Modified data array with custom post_name
 /
function csg_generate_custom_slug( data, postarr ) {
    // Configure which post types to handle
    allowed_post_types = array( post, product ) // change as needed

    if ( empty( data[post_type] )  ! in_array( data[post_type], allowed_post_types, true ) ) {
        return data
    }

    // Avoid acting during autosave or on revisions
    if ( defined( DOING_AUTOSAVE )  DOING_AUTOSAVE ) {
        return data
    }
    if ( ! empty( postarr[post_ID] )  wp_is_post_revision( postarr[post_ID] ) ) {
        return data
    }

    // Example logic: prefer a meta value _slug_source (could be set by CPT fields)
    source_value = 
    if ( ! empty( postarr[ID] ) ) {
        source_value = get_post_meta( postarr[ID], _slug_source, true )
    }

    // Fallback to post title
    if ( empty( source_value ) ) {
        source_value = data[post_title]
    }

    // If still empty (e.g., auto-drafts), return unchanged
    if ( empty( source_value ) ) {
        return data
    }

    // Basic transliteration: remove accents and normalize whitespace
    source_value = remove_accents( source_value )
    source_value = preg_replace( /[^p{L}p{N}s-] /u, , source_value ) // keep letters, numbers, whitespace, dash
    source_value = trim( preg_replace( /s /,  , source_value ) )

    // Create a sanitized slug
    slug = sanitize_title( source_value )

    // Optionally: apply additional business rules
    // Example: prefix with category slug for posts (if assigned in postarr)
    if ( post === data[post_type]  ! empty( postarr[tax_input][category] ) ) {
        // tax_input may contain array of category IDs or slugs depending on context guard carefully
        cats = (array) postarr[tax_input][category]
        if ( ! empty( cats ) ) {
            first_cat = cats[0]
            // attempt to get term slug
            if ( is_numeric( first_cat ) ) {
                term = get_term( (int) first_cat, category )
                if ( ! is_wp_error( term )  term  ! empty( term->slug ) ) {
                    slug = sanitize_title( term->slug . - . slug )
                }
            } else {
                slug = sanitize_title( first_cat . - . slug )
            }
        }
    }

    // Ensure the slug is unique given the post context
    post_id = ! empty( postarr[ID] ) ? (int) postarr[ID] : 0
    slug = wp_unique_post_slug( slug, post_id, data[post_status], data[post_type], data[post_parent] )

    // Assign the computed slug
    data[post_name] = slug

    return data
}
add_filter( wp_insert_post_data, csg_generate_custom_slug, 10, 2 )
?gt

Common pitfalls and FAQs

  • Q: My save_post approach causes an infinite loop after wp_update_post.
    A: Remove your save_post hook before calling wp_update_post and re-add it afterwards (see example). Also ensure you compare current slug to new slug to avoid unnecessary updates.
  • Q: My custom slug isnt being saved when creating posts from REST/Gutenberg.
    A: Use wp_insert_post_data which runs during REST saves as well. save_post may behave differently depending on sequence of hooks and meta saving.
  • Q: Why does WordPress still add -2/-3 suffixes to my slug?
    A: WordPress enforces uniqueness. If a post with same slug exists (even in trash), wp_unique_post_slug or core logic may append numeric suffixes. Consider using wp_unique_post_slug to precompute unique slug or check existing posts.
  • Q: How do I avoid changing slugs after publication?
    A: Add logic to skip slug regeneration when a post is already published or when a meta flag like _slug_locked is present.

Useful WordPress functions and documentation

Checklist before deploying to production

  1. Test on a staging environment that mirrors production permalink settings and plugins.
  2. Confirm admin workflows (quick edit, bulk edit, REST API, front-end programmatic inserts) behave correctly.
  3. Ensure you do not inadvertently change slugs for existing published content unless intended.
  4. Monitor server load if slug generation uses heavy processes.
  5. Document your slug policy for content editors.

Appendix — quick reference patterns

  • Use wp_insert_post_data to set post_name pre-save.
  • If you require saved meta to construct slug, use save_post with remove_action/add_action pattern.
  • Use sanitize_title to globally adjust slug sanitization.
  • Use wp_unique_post_slug to obtain a slug that respects WordPress uniqueness rules.


Acepto donaciones de BAT's mediante el navegador Brave 🙂



Leave a Reply

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