logger = $logger; } /** * Purge caches for a post without allowing a cache failure to affect a write. * * @param int $post_id Post being written. * @param bool $include_elementor Whether to invalidate Elementor's CSS. * @return array */ public function purge_post_cache( int $post_id, bool $include_elementor = false ): array { $purged = array(); $failed = array(); $this->run( 'wordpress', static function () use ( $post_id ): void { if ( function_exists( 'clean_post_cache' ) ) { clean_post_cache( $post_id ); } }, $purged, $failed ); /* * The hooks a host-level cache listens to. * * A host's page cache (20i StackCache, Kinsta, WP Engine, Cloudways and * the rest) is not a plugin this registry can name; it purges a page * when WordPress says the post was saved. A builder edit writes postmeta * and fires none of those hooks, so the edit was live at the origin * while the cache served the old page for its full lifetime — measured * on a site whose cache-control said s-maxage=216000. The stored data * did change, so the save hooks are fired with the same arguments core * uses; nothing is re-saved, so no filter can rewrite the content. */ $this->run( 'wordpress_save_hooks', static function () use ( $post_id ): void { $post = function_exists( 'get_post' ) ? get_post( $post_id ) : null; if ( ! $post instanceof \WP_Post || ! function_exists( 'do_action' ) ) { return; } do_action( 'save_post', $post_id, $post, true ); do_action( "save_post_{$post->post_type}", $post_id, $post, true ); do_action( 'post_updated', $post_id, $post, $post ); }, $purged, $failed ); if ( $include_elementor ) { $this->run( 'elementor', static function () use ( $post_id ): void { delete_post_meta( $post_id, '_elementor_css' ); if ( class_exists( '\\Elementor\\Plugin' ) ) { $instance = \Elementor\Plugin::$instance; if ( isset( $instance->files_manager ) && method_exists( $instance->files_manager, 'clear_cache' ) ) { $instance->files_manager->clear_cache(); } } }, $purged, $failed ); } foreach ( $this->registry( $post_id ) as $integration ) { if ( ! $integration['available']() ) { continue; } $this->run( $integration['name'], $integration['purge'], $purged, $failed ); } try { $edge = $this->verify_edge_cache( $post_id ); } catch ( \Throwable $error ) { $this->log_warning( 'Cache edge verification failed', array( 'post_id' => $post_id, 'error' => $error->getMessage(), ) ); $edge = array( 'suspected' => false, 'headers' => array() ); } return array( 'layers_purged' => $purged, 'layers_failed' => $failed, 'edge_cache_suspected' => $edge['suspected'], 'edge_cache_headers' => (object) $edge['headers'], ); } /** * Purge what a change to a SHARED Elementor document touches. * * A MetForm form or a theme-builder template (header, footer, the layout * every tour page uses) is not one page: it renders inside every page that * shows it, so purging its own address leaves every one of those pages * cached with the old form or text. The document's own purge runs first * (its generated CSS, its save hooks), then the site-wide purge of every * cache plugin that offers one — the same reach a menu change needs. * * @return array */ public function purge_shared_document_cache( int $post_id ): array { $result = $this->purge_post_cache( $post_id, true ); $purged = (array) $result['layers_purged']; $failed = (array) $result['layers_failed']; foreach ( $this->site_registry() as $integration ) { if ( ! $integration['available']() ) { continue; } $this->run( $integration['name'] . '_site', $integration['purge'], $purged, $failed ); } $result['layers_purged'] = $purged; $result['layers_failed'] = $failed; return $result; } /** * Purge what a menu change touches: every cached page. * * A menu is rendered on every page, so a post-scoped purge cannot reach * what a relabelled item changed. Measured on a host cache with * s-maxage=216000: the item read "Photos & Videos" in wp-admin while the * homepage served "Media" for as long as the cache allowed. Two layers: * the hook WordPress itself fires when a menu is saved from its own screen * (`wp_update_nav_menu`), which is what a host cache listens to, and the * site-wide purge of every cache plugin that offers one. * * @return array */ public function purge_menu_cache( int $menu_id ): array { $purged = array(); $failed = array(); $this->run( 'wordpress_menu_hooks', static function () use ( $menu_id ): void { if ( ! function_exists( 'do_action' ) ) { return; } $menu = function_exists( 'wp_get_nav_menu_object' ) ? wp_get_nav_menu_object( $menu_id ) : null; do_action( 'wp_update_nav_menu', $menu_id, $menu ? array( 'menu-name' => (string) $menu->name ) : array() ); }, $purged, $failed ); foreach ( $this->site_registry() as $integration ) { if ( ! $integration['available']() ) { continue; } $this->run( $integration['name'], $integration['purge'], $purged, $failed ); } /* * And then the honest part: whether anything in FRONT of WordPress is * still serving the old navigation. * * Nothing inside WordPress can clear a host CDN that exposes no hook — * on the site this was measured against, the cache library is a stub * requiring a file outside the install, and the edge went on serving * the old menu with `s-maxage=216000` after every layer above had run. * Reported rather than hidden, so the owner is told what they can still * see instead of being told the change is everywhere. */ $edge = array( 'suspected' => false, 'headers' => array() ); try { $edge = $this->verify_edge_cache_at( function_exists( 'home_url' ) ? (string) home_url( '/' ) : '', array( 'menu_id' => $menu_id ) ); } catch ( \Throwable $error ) { $this->log_warning( 'Cache edge verification failed', array( 'menu_id' => $menu_id, 'error' => $error->getMessage() ) ); } return array( 'layers_purged' => $purged, 'layers_failed' => $failed, 'edge_cache_suspected' => $edge['suspected'], 'edge_cache_headers' => (object) $edge['headers'], ); } /** * Rebuild Elementor's generated files, as Elementor's own tools do. * * Two caches, and the per-post write above clears only the first: * * - the generated CSS (`files_manager->clear_cache()`: every page's CSS file, * its cache meta, the global CSS) — "Regenerate Files & Data"; * - the Google Fonts Elementor downloads and serves from the site itself * (`Google_Font::clear_cache()`: the files and the * `_elementor_local_google_fonts` option, which stores each font's CSS * address ABSOLUTELY). Only Elementor's "Replace URL" tool clears these. * * The second is why this exists. A site built on a hosting company's * temporary address had its fonts downloaded there, so after it moved every * page still loaded its font CSS from the temporary address — and would lose * its fonts the day that address was deleted. Clearing both makes Elementor * rebuild them from the live address on the next visit, exactly as after * its own Replace URL. Nothing is lost: both are regenerated caches. * * Best-effort per layer, like every purge here; a missing class is skipped, * never an error. * * @return array{elementor_active: bool, cleared: array, pages_purged?: array, failed: array} */ public function regenerate_elementor_assets(): array { $cleared = array(); $failed = array(); if ( ! class_exists( '\\Elementor\\Plugin' ) ) { return array( 'elementor_active' => false, 'cleared' => $cleared, 'failed' => $failed, ); } $this->run( 'elementor_css', static function (): void { $instance = \Elementor\Plugin::$instance; if ( ! isset( $instance->files_manager ) || ! method_exists( $instance->files_manager, 'clear_cache' ) ) { throw new \RuntimeException( 'Elementor files manager is not available' ); } $instance->files_manager->clear_cache(); }, $cleared, $failed ); $google_font = '\\Elementor\\Core\\Files\\Fonts\\Google_Font'; if ( class_exists( $google_font ) && method_exists( $google_font, 'clear_cache' ) ) { $this->run( 'elementor_google_fonts', static function () use ( $google_font ): void { $google_font::clear_cache(); }, $cleared, $failed ); } /* * Every page's names those files, so a page cache would keep * serving the old addresses until it expired. The same site-wide purge a * menu change uses, for the same reason: the change is on every page. */ $pages_purged = array(); foreach ( $this->site_registry() as $integration ) { if ( ! $integration['available']() ) { continue; } $this->run( $integration['name'], $integration['purge'], $pages_purged, $failed ); } return array( 'elementor_active' => true, 'cleared' => $cleared, 'pages_purged' => $pages_purged, 'failed' => $failed, ); } /** * Site-wide purges, for a change that is on every page. * * @return array> */ private function site_registry(): array { return array( array( 'name' => 'wp_rocket', 'available' => static fn(): bool => function_exists( 'rocket_clean_domain' ), 'purge' => static function (): void { rocket_clean_domain(); }, ), array( 'name' => 'w3_total_cache', 'available' => static fn(): bool => function_exists( 'w3tc_flush_all' ), 'purge' => static function (): void { w3tc_flush_all(); }, ), array( 'name' => 'wp_super_cache', 'available' => static fn(): bool => function_exists( 'wp_cache_clear_cache' ), 'purge' => static function (): void { wp_cache_clear_cache(); }, ), array( 'name' => 'litespeed', 'available' => static fn(): bool => function_exists( 'do_action' ) && has_action( 'litespeed_purge_all' ), 'purge' => static function (): void { do_action( 'litespeed_purge_all' ); }, ), array( 'name' => 'wp_fastest_cache', 'available' => static fn(): bool => function_exists( 'wpfc_clear_all_cache' ), 'purge' => static function (): void { wpfc_clear_all_cache( true ); }, ), array( 'name' => 'cache_enabler', 'available' => static fn(): bool => class_exists( 'Cache_Enabler' ) && method_exists( 'Cache_Enabler', 'clear_complete_cache' ), 'purge' => static function (): void { \Cache_Enabler::clear_complete_cache(); }, ), array( 'name' => 'sg_optimizer', 'available' => static fn(): bool => function_exists( 'sg_cachepress_purge_cache' ), 'purge' => static function (): void { sg_cachepress_purge_cache(); }, ), array( 'name' => 'breeze', 'available' => static fn(): bool => function_exists( 'breeze_clear_cache' ), 'purge' => static function (): void { breeze_clear_cache(); }, ), array( 'name' => 'autoptimize', 'available' => static fn(): bool => class_exists( 'autoptimizeCache' ) && method_exists( 'autoptimizeCache', 'clearall' ), 'purge' => static function (): void { \autoptimizeCache::clearall(); }, ), array( 'name' => 'nginx_helper', 'available' => static fn(): bool => has_action( 'rt_nginx_helper_purge_all' ), 'purge' => static function (): void { do_action( 'rt_nginx_helper_purge_all' ); }, ), array( 'name' => 'varnish', 'available' => static fn(): bool => has_action( 'vhp_purge_url' ), 'purge' => static function (): void { do_action( 'vhp_purge_url', home_url( '/?vhp-regex' ) ); }, ), array( 'name' => 'cloudflare', 'available' => static fn(): bool => has_action( 'cloudflare_purge_by_url' ), 'purge' => static function (): void { do_action( 'cloudflare_purge_by_url', array( home_url( '/' ) ) ); }, ), ); } /** * @return array> */ private function registry( int $post_id ): array { $url = (string) get_permalink( $post_id ); return array( array( 'name' => 'wp_rocket', 'available' => static fn(): bool => function_exists( 'rocket_clean_post' ), 'purge' => static function () use ( $post_id ): void { rocket_clean_post( $post_id ); }, ), array( 'name' => 'w3_total_cache', 'available' => static fn(): bool => function_exists( 'w3tc_flush_post' ), 'purge' => static function () use ( $post_id ): void { w3tc_flush_post( $post_id ); }, ), array( 'name' => 'wp_super_cache', 'available' => static fn(): bool => function_exists( 'wpsc_delete_post_cache' ), 'purge' => static function () use ( $post_id ): void { wpsc_delete_post_cache( $post_id ); }, ), array( 'name' => 'litespeed', 'available' => static fn(): bool => function_exists( 'do_action' ) && has_action( 'litespeed_purge_post' ), 'purge' => static function () use ( $post_id ): void { do_action( 'litespeed_purge_post', $post_id ); }, ), array( 'name' => 'wp_fastest_cache', 'available' => static fn(): bool => function_exists( 'wpfc_clear_post_cache_by_id' ), 'purge' => static function () use ( $post_id ): void { wpfc_clear_post_cache_by_id( $post_id ); }, ), array( 'name' => 'cache_enabler', 'available' => static fn(): bool => class_exists( 'Cache_Enabler' ) && method_exists( 'Cache_Enabler', 'clear_page_cache_by_post' ), 'purge' => static function () use ( $post_id ): void { \Cache_Enabler::clear_page_cache_by_post( $post_id ); }, ), array( 'name' => 'sg_optimizer', 'available' => static fn(): bool => function_exists( 'sg_cachepress_purge_cache' ), 'purge' => static function (): void { sg_cachepress_purge_cache(); }, ), array( 'name' => 'breeze', 'available' => static fn(): bool => function_exists( 'breeze_clear_cache' ), 'purge' => static function (): void { breeze_clear_cache(); }, ), array( 'name' => 'autoptimize', 'available' => static fn(): bool => class_exists( 'autoptimizeCache' ) && method_exists( 'autoptimizeCache', 'clearall' ), 'purge' => static function (): void { \autoptimizeCache::clearall(); }, ), array( 'name' => 'nginx_helper', 'available' => static fn(): bool => has_action( 'rt_nginx_helper_purge_all' ), 'purge' => static function (): void { do_action( 'rt_nginx_helper_purge_all' ); }, ), array( 'name' => 'varnish', 'available' => static fn(): bool => '' !== $url && has_action( 'vhp_purge_url' ), 'purge' => static function () use ( $url ): void { do_action( 'vhp_purge_url', $url ); }, ), array( 'name' => 'cloudflare', 'available' => static fn(): bool => '' !== $url && has_action( 'cloudflare_purge_by_url' ), 'purge' => static function () use ( $url ): void { do_action( 'cloudflare_purge_by_url', $url ); }, ), ); } /** * Run one integration in isolation. A broken cache plugin must never break * the already-successful content write. * * @param array $purged * @param array $failed */ private function run( string $name, callable $purge, array &$purged, array &$failed ): void { try { $purge(); $purged[] = $name; } catch ( \Throwable $error ) { $failed[] = $name; $this->log_warning( 'Cache purge failed for ' . $name, array( 'error' => $error->getMessage() ) ); } } private function log_warning( string $message, array $context ): void { try { $this->logger->warning( $message, $context ); } catch ( \Throwable $ignored ) { // Logging must not turn a best-effort cache operation into a write failure. } } /** * @return array{suspected: bool, headers: array} */ private function verify_edge_cache( int $post_id ): array { return $this->verify_edge_cache_at( (string) get_permalink( $post_id ), array( 'post_id' => $post_id ) ); } /** * Does something in front of this URL look like it is serving a cached copy? * * Takes the URL rather than a post, because the change that most needs this * answer is not on one page: a menu is rendered on every page, and the page * to ask about is the home page. * * @param array $context * @return array{suspected: bool, headers: array} */ private function verify_edge_cache_at( string $url, array $context = array() ): array { if ( '' === $url || ! function_exists( 'wp_remote_get' ) ) { return array( 'suspected' => false, 'headers' => array() ); } $response = wp_remote_get( $url, array( 'timeout' => 1, 'redirection' => 0, 'blocking' => true, ) ); if ( is_wp_error( $response ) ) { $this->log_warning( 'Cache edge verification failed', array_merge( $context, array( 'error' => $response->get_error_message() ) ) ); return array( 'suspected' => false, 'headers' => array() ); } /* * Every header that names a cache, whatever the host calls it. The old * list was three names, and the site this was measured on answered with * `x-stackcache-cacheable: yes` and `x-cache-enabled: true` — neither * on the list — so a page served from a cache with a sixty-hour lifetime * was reported as not cached at all. */ $headers = array(); $all = function_exists( 'wp_remote_retrieve_headers' ) ? wp_remote_retrieve_headers( $response ) : array(); $all = is_object( $all ) && method_exists( $all, 'getAll' ) ? $all->getAll() : (array) $all; foreach ( $all as $name => $value ) { $name = strtolower( (string) $name ); if ( false === strpos( $name, 'cache' ) && 'age' !== $name ) { continue; } $value = is_array( $value ) ? implode( ', ', $value ) : (string) $value; if ( '' !== trim( $value ) ) { $headers[ $name ] = sanitize_text_field( substr( $value, 0, 200 ) ); } } $suspected = false; foreach ( $headers as $name => $value ) { if ( 'age' === $name && (int) $value > 0 ) { $suspected = true; break; } if ( 'cache-control' !== $name && preg_match( '/\b(HIT|STALE|true|yes|on|enabled|cached)\b/i', $value ) ) { $suspected = true; break; } } return array( 'suspected' => $suspected, 'headers' => $headers, ); } }