From 6317cf0a30aabcac797b1c5b054735e0c0a255c7 Mon Sep 17 00:00:00 2001 From: Daan van den Bergh <18595395+Dan0sz@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:26:40 +0200 Subject: [PATCH] Improved: added a service loader which makes sure requests succeed during updates. --- src/Plugin.php | 85 +++++++++++++++++++++++++------- tests/integration/PluginTest.php | 16 ++++++ 2 files changed, 83 insertions(+), 18 deletions(-) diff --git a/src/Plugin.php b/src/Plugin.php index ee5aede8..70bf7f63 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -16,7 +16,57 @@ final class Plugin { * @codeCoverageIgnore */ public function load_integrations() { - new Integrations(); + $this->load_service( Integrations::class ); + } + + /** + * Instantiates a service class without taking down the site when its file can't be loaded. + * + * WordPress' updater deletes the plugin directory before copying the new version into place, so a + * request landing in that window finds part of our source tree missing. Because + * @see register_services() runs on plugins_loaded for every request, an unguarded `new` turns that + * into a fatal error on the front end instead of a single disabled feature. + * + * Note that `::class` is resolved at compile time and doesn't trigger the autoloader, so passing a + * missing class here is safe. + * + * @since 2.6.1 + * + * @param string $class_name Fully qualified class name. + * + * @return object|null Null when the class couldn't be loaded. + */ + private function load_service( $class_name ) { + if ( ! class_exists( $class_name ) ) { + $this->log_missing_service( $class_name ); + + return null; + } + + return new $class_name(); + } + + /** + * Logs a service that couldn't be loaded by @see load_service(). + * + * Only logged when WP_DEBUG is enabled: during a plugin update this is expected and transient, and + * we don't want to fill production logs with it. + * + * @since 2.6.1 + * + * @param string $class_name Fully qualified class name. + * + * @return void + * + * @codeCoverageIgnore + */ + private function log_missing_service( $class_name ) { + if ( ! defined( 'WP_DEBUG' ) || ! WP_DEBUG ) { + return; + } + + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + error_log( sprintf( 'Plausible Analytics: %s could not be loaded and was skipped.', $class_name ) ); } /** @@ -44,8 +94,8 @@ public function load_plugin_textdomain() { * @codeCoverageIgnore */ public function load_provisioning() { - new Admin\Provisioning(); - new Admin\Provisioning\Integrations(); + $this->load_service( Admin\Provisioning::class ); + $this->load_service( Admin\Provisioning\Integrations::class ); } /** @@ -56,7 +106,7 @@ public function load_provisioning() { * @codeCoverageIgnore */ public function load_settings() { - new Admin\Settings\Page(); + $this->load_service( Admin\Settings\Page::class ); } /** @@ -82,7 +132,7 @@ public function register() { * @return void */ public function setup() { - new Setup(); + $this->load_service( Setup::class ); } /** @@ -93,26 +143,25 @@ public function setup() { * @return void */ public function register_services() { - if ( is_admin() ) { add_action( 'init', [ $this, 'load_settings' ] ); add_action( 'init', [ $this, 'load_provisioning' ] ); - new Admin\Upgrades(); - new Admin\Filters(); - new Admin\Actions(); - new Admin\Module(); - new Admin\PrivacyPolicy(); + $this->load_service( Admin\Upgrades::class ); + $this->load_service( Admin\Filters::class ); + $this->load_service( Admin\Actions::class ); + $this->load_service( Admin\Module::class ); + $this->load_service( Admin\PrivacyPolicy::class ); } add_action( 'init', [ $this, 'load_integrations' ] ); - new AdminBar(); - new Assets(); - new Ajax(); - new Compatibility(); - new InitOptions(); - new Proxy(); - new Verification(); + $this->load_service( AdminBar::class ); + $this->load_service( Assets::class ); + $this->load_service( Ajax::class ); + $this->load_service( Compatibility::class ); + $this->load_service( InitOptions::class ); + $this->load_service( Proxy::class ); + $this->load_service( Verification::class ); } } diff --git a/tests/integration/PluginTest.php b/tests/integration/PluginTest.php index a4b74603..a8404a3f 100644 --- a/tests/integration/PluginTest.php +++ b/tests/integration/PluginTest.php @@ -6,9 +6,25 @@ namespace Plausible\Analytics\Tests\Integration; use Plausible\Analytics\Tests\TestCase; +use Plausible\Analytics\WP\AdminBar; use Plausible\Analytics\WP\Plugin; class PluginTest extends TestCase { + /** + * A service whose file is missing (e.g. because WordPress is mid-update) should be skipped + * instead of failing the request. + * + * @see Plugin::load_service() + */ + public function testLoadServiceSkipsMissingClass() { + $class = new Plugin(); + $method = new \ReflectionMethod( $class, 'load_service' ); + $method->setAccessible( true ); + + $this->assertNull( $method->invoke( $class, '\Plausible\Analytics\WP\ThisClassDoesNotExist' ) ); + $this->assertInstanceOf( AdminBar::class, $method->invoke( $class, AdminBar::class ) ); + } + /** * @see Plugin::register() */