options->general->monitorPostSlug ) { return; } // Ignore autosaves and revisions — neither is a real slug change. if ( ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) || wp_is_post_revision( $postAfter ) ) { return; } // Only posts that were live before and remain live after: a draft // has no public URL to preserve, and redirecting to an unpublished // target would send visitors to a 404 of a different kind. if ( 'publish' !== $postBefore->post_status || 'publish' !== $postAfter->post_status ) { return; } // Public, redirectable post types only. $type = get_post_type_object( $postAfter->post_type ); if ( null === $type || empty( $type->public ) ) { return; } $oldUrl = get_permalink( $postBefore ); $newUrl = get_permalink( $postAfter ); // Nothing to do when the permalink is unchanged (eg. a plain // `?p=123` permalink structure, or an edit that didn't touch the // slug) or either lookup failed. if ( ! is_string( $oldUrl ) || ! is_string( $newUrl ) || '' === $oldUrl || $oldUrl === $newUrl ) { return; } /** * Filter whether to auto-create a redirect for this slug change. * * Return false to skip a specific post — eg. to exclude a custom * post type or a bulk slug-normalisation run. * * @since 4.0.0 * * @param bool $create Whether to create the redirect. * @param WP_Post $postAfter Post after the update. * @param WP_Post $postBefore Post before the update. */ if ( ! apply_filters( '404_to_301_monitor_slug_create', true, $postAfter, $postBefore ) ) { return; } $this->upsertRedirect( $oldUrl, $newUrl ); } /** * Create — or refresh — the redirect for an old → new URL pair. * * Stores the source as the old URL's path so it matches the request * URI regardless of host. When a redirect already exists for the old * URL (eg. the post was renamed twice) its target is updated rather * than inserting a duplicate, which the unique `source_hash` index * would reject anyway. * * @since 4.0.0 * * @param string $oldUrl Previous permalink (full URL). * @param string $newUrl New permalink (full URL). * * @return void */ private function upsertRedirect( string $oldUrl, string $newUrl ): void { $source = (string) wp_parse_url( $oldUrl, PHP_URL_PATH ); if ( '' === $source ) { return; } $existing = Redirect::findExact( $source ); if ( $existing && isset( $existing->id ) ) { // Don't clobber a redirect an admin built by hand - only refresh rows we auto-created. if ( self::NOTES !== (string) $existing->notes ) { return; } $redirect = new Redirect( (int) $existing->id ); $redirect->target_url = $newUrl; $redirect->save(); return; } $redirect = new Redirect(); $redirect->source = $source; $redirect->target_type = 'link'; $redirect->target_url = $newUrl; $redirect->redirect_type = 301; $redirect->match_type = 'exact'; $redirect->is_active = 1; $redirect->notes = self::NOTES; $redirect->save(); // Flatten any chain we'd otherwise create. If the post was renamed A -> B before and is now // B -> C, the A -> B row should be repointed to C so visitors (and search engines) follow a // single 301 instead of a hop chain. Only our own auto-created rows are touched. $this->repointChains( $oldUrl, $newUrl ); } /** * Repoint previously auto-created redirects that targeted the old URL * so they point at the new one, collapsing 301 chains into single * hops. * * @since 4.0.0 * * @param string $oldUrl Previous permalink (now itself redirected). * @param string $newUrl New permalink. * * @return void */ private function repointChains( string $oldUrl, string $newUrl ): void { global $wpdb; $table = $wpdb->prefix . '404_to_301_redirects'; // Read-only lookup of our own auto-created rows that still target the now-redirected old URL. // There's no model "find by target" helper, hence the direct prepared SELECT; the writes below // go back through the model so its cache flush and audit event fire. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter $ids = $wpdb->get_col( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is internal, values are prepared. "SELECT id FROM {$table} WHERE target_url = %s AND notes = %s", $oldUrl, self::NOTES ) ); foreach ( (array) $ids as $id ) { $redirect = new Redirect( (int) $id ); $redirect->target_url = $newUrl; $redirect->save(); } } }