diff --git a/plugins/system/cache/cache.php b/plugins/system/cache/cache.php
deleted file mode 100644
index 79b74556bc2b7..0000000000000
--- a/plugins/system/cache/cache.php
+++ /dev/null
@@ -1,280 +0,0 @@
-
- * @license GNU General Public License version 2 or later; see LICENSE.txt
- */
-
-defined('_JEXEC') or die;
-
-use Joomla\CMS\Cache\Cache;
-use Joomla\CMS\Factory;
-use Joomla\CMS\Plugin\CMSPlugin;
-use Joomla\CMS\Plugin\PluginHelper;
-use Joomla\CMS\Profiler\Profiler;
-use Joomla\CMS\Uri\Uri;
-
-/**
- * Joomla! Page Cache Plugin.
- *
- * @since 1.5
- */
-class PlgSystemCache extends CMSPlugin
-{
- /**
- * Cache instance.
- *
- * @var \Joomla\CMS\Cache\CacheController
- * @since 1.5
- */
- public $_cache;
-
- /**
- * Cache key
- *
- * @var string
- * @since 3.0
- */
- public $_cache_key;
-
- /**
- * Application object.
- *
- * @var \Joomla\CMS\Application\CMSApplication
- * @since 3.8.0
- */
- protected $app;
-
- /**
- * Constructor.
- *
- * @param object &$subject The object to observe.
- * @param array $config An optional associative array of configuration settings.
- *
- * @since 1.5
- */
- public function __construct(&$subject, $config)
- {
- parent::__construct($subject, $config);
-
- // Run only when we're on Site Application side
- if (!$this->app->isClient('site'))
- {
- return;
- }
-
- // Set the cache options.
- $options = array(
- 'defaultgroup' => 'page',
- 'browsercache' => $this->params->get('browsercache', 0),
- 'caching' => false,
- );
-
- // Instantiate cache with previous options and create the cache key identifier.
- $this->_cache = Cache::getInstance('page', $options);
- $this->_cache_key = Uri::getInstance()->toString();
- }
-
- /**
- * Get a cache key for the current page based on the url and possible other factors.
- *
- * @return string
- *
- * @since 3.7
- */
- protected function getCacheKey()
- {
- static $key;
-
- // Run only when we're on Site Application side
- if (!$this->app->isClient('site'))
- {
- return '';
- }
-
- if (!$key)
- {
- PluginHelper::importPlugin('pagecache');
-
- $parts = $this->app->triggerEvent('onPageCacheGetKey');
- $parts[] = Uri::getInstance()->toString();
-
- $key = md5(serialize($parts));
- }
-
- return $key;
- }
-
- /**
- * Checks if URL exists in cache, if so dumps it directly and closes.
- *
- * @return void
- *
- * @since 4.0.0
- */
- public function onAfterRoute()
- {
- if (!$this->app->isClient('site') || $this->app->get('offline', '0') || $this->app->getMessageQueue())
- {
- return;
- }
-
- // If any pagecache plugins return false for onPageCacheSetCaching, do not use the cache.
- PluginHelper::importPlugin('pagecache');
-
- $results = $this->app->triggerEvent('onPageCacheSetCaching');
- $caching = !in_array(false, $results, true);
-
- if ($caching && $this->app->getIdentity()->guest && $this->app->input->getMethod() === 'GET')
- {
- $this->_cache->setCaching(true);
- }
-
- $data = $this->_cache->get($this->getCacheKey());
-
- // If page exist in cache, show cached page.
- if ($data !== false)
- {
- // Set HTML page from cache.
- $this->app->setBody($data);
-
- // Dumps HTML page.
- echo $this->app->toString((bool) $this->app->get('gzip'));
-
- // Mark afterCache in debug and run debug onAfterRespond events, e.g. show Joomla Debug Console if debug is active.
- if (JDEBUG)
- {
- // Create a document instance and load it into the application.
- $document = Factory::getContainer()->get('document.factory')->createDocument($this->app->input->get('format', 'html'));
- $this->app->loadDocument($document);
-
- Profiler::getInstance('Application')->mark('afterCache');
- $this->app->triggerEvent('onAfterRespond');
- }
-
- // Closes the application.
- $this->app->close();
- }
- }
-
- /**
- * After Render Event.
- * Verify if current page is not excluded from cache.
- *
- * @return void
- *
- * @since 3.9.12
- */
- public function onAfterRender()
- {
- // Run only when we're on Site Application side
- if (!$this->app->isClient('site'))
- {
- return;
- }
-
- if ($this->_cache->getCaching() === false)
- {
- return;
- }
-
- // We need to check if user is guest again here, because auto-login plugins have not been fired before the first aid check.
- // Page is excluded if excluded in plugin settings.
- if (!$this->app->getIdentity()->guest || $this->app->getMessageQueue() || $this->isExcluded() === true)
- {
- $this->_cache->setCaching(false);
-
- return;
- }
-
- // Disable compression before caching the page.
- $this->app->set('gzip', false);
- }
-
- /**
- * After Respond Event.
- * Stores page in cache.
- *
- * @return void
- *
- * @since 1.5
- */
- public function onAfterRespond()
- {
- // Run only when we're on Site Application side
- if (!$this->app->isClient('site'))
- {
- return;
- }
-
- if ($this->_cache->getCaching() === false)
- {
- return;
- }
-
- // Saves current page in cache.
- $this->_cache->store($this->app->getBody(), $this->getCacheKey());
- }
-
- /**
- * Check if the page is excluded from the cache or not.
- *
- * @return boolean True if the page is excluded else false
- *
- * @since 3.5
- */
- protected function isExcluded()
- {
- // Check if menu items have been excluded.
- if ($exclusions = $this->params->get('exclude_menu_items', array()))
- {
- // Get the current menu item.
- $active = $this->app->getMenu()->getActive();
-
- if ($active && $active->id && in_array((int) $active->id, (array) $exclusions))
- {
- return true;
- }
- }
-
- // Check if regular expressions are being used.
- if ($exclusions = $this->params->get('exclude', ''))
- {
- // Normalize line endings.
- $exclusions = str_replace(array("\r\n", "\r"), "\n", $exclusions);
-
- // Split them.
- $exclusions = explode("\n", $exclusions);
-
- // Gets internal URI.
- // Router can be injected when turned into a DI built plugin
- $internal_uri = '/index.php?' . Uri::getInstance()->buildQuery(Factory::getContainer()->get(SiteRouter::class)->getVars());
-
- // Loop through each pattern.
- if ($exclusions)
- {
- foreach ($exclusions as $exclusion)
- {
- // Make sure the exclusion has some content
- if ($exclusion !== '')
- {
- // Test both external and internal URI
- if (preg_match('#' . $exclusion . '#i', $this->_cache_key . ' ' . $internal_uri))
- {
- return true;
- }
- }
- }
- }
- }
-
- // If any pagecache plugins return true for onPageCacheIsExcluded, exclude.
- PluginHelper::importPlugin('pagecache');
-
- $results = $this->app->triggerEvent('onPageCacheIsExcluded');
-
- return in_array(true, $results, true);
- }
-}
diff --git a/plugins/system/cache/cache.xml b/plugins/system/cache/cache.xml
index 35e2d96375345..11d8eaf24dff5 100644
--- a/plugins/system/cache/cache.xml
+++ b/plugins/system/cache/cache.xml
@@ -9,8 +9,10 @@
www.joomla.org
3.0.0
PLG_CACHE_XML_DESCRIPTION
+ Joomla\Plugin\System\Cache
- cache.php
+ services
+ src
language/en-GB/plg_system_cache.ini
diff --git a/plugins/system/cache/services/provider.php b/plugins/system/cache/services/provider.php
new file mode 100644
index 0000000000000..6cf09d9211bb5
--- /dev/null
+++ b/plugins/system/cache/services/provider.php
@@ -0,0 +1,48 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+defined('_JEXEC') or die;
+
+use Joomla\CMS\Cache\CacheControllerFactoryInterface;
+use Joomla\CMS\Extension\PluginInterface;
+use Joomla\CMS\Plugin\PluginHelper;
+use Joomla\CMS\Profiler\Profiler;
+use Joomla\CMS\Router\SiteRouter;
+use Joomla\DI\Container;
+use Joomla\DI\ServiceProviderInterface;
+use Joomla\Event\DispatcherInterface;
+use Joomla\Plugin\System\Cache\Extension\Cache;
+
+return new class implements ServiceProviderInterface {
+ /**
+ * Registers the service provider with a DI container.
+ *
+ * @param Container $container The DI container.
+ *
+ * @return void
+ * @since __DEPLOY_VERSION__
+ */
+ public function register(Container $container)
+ {
+ $container->set(
+ PluginInterface::class,
+ function (Container $container)
+ {
+ $plugin = PluginHelper::getPlugin('system', 'cache');
+ $dispatcher = $container->get(DispatcherInterface::class);
+ $documentFactory = $container->get('document.factory');
+ $cacheControllerFactory = $container->get(CacheControllerFactoryInterface::class);
+ $profiler = (defined('JDEBUG') && JDEBUG) ? Profiler::getInstance('Application') : null;
+ $router = $container->has(SiteRouter::class) ? $container->get(SiteRouter::class) : null;
+
+ return new Cache($dispatcher, (array) $plugin, $documentFactory, $cacheControllerFactory, $profiler, $router);
+ }
+ );
+ }
+};
diff --git a/plugins/system/cache/src/Extension/Cache.php b/plugins/system/cache/src/Extension/Cache.php
new file mode 100644
index 0000000000000..705c445af7c4f
--- /dev/null
+++ b/plugins/system/cache/src/Extension/Cache.php
@@ -0,0 +1,401 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Plugin\System\Cache\Extension;
+
+defined('_JEXEC') or die;
+
+use Joomla\CMS\Application\CMSApplication;
+use Joomla\CMS\Application\CMSApplicationInterface;
+use Joomla\CMS\Cache\CacheController;
+use Joomla\CMS\Cache\CacheControllerFactoryInterface;
+use Joomla\CMS\Document\FactoryInterface as DocumentFactoryInterface;
+use Joomla\CMS\Plugin\CMSPlugin;
+use Joomla\CMS\Plugin\PluginHelper;
+use Joomla\CMS\Profiler\Profiler;
+use Joomla\CMS\Router\SiteRouter;
+use Joomla\CMS\Uri\Uri;
+use Joomla\Event\DispatcherInterface;
+use Joomla\Event\Event;
+use Joomla\Event\Priority;
+use Joomla\Event\SubscriberInterface;
+
+/**
+ * Joomla! Page Cache Plugin.
+ *
+ * @since 1.5
+ */
+final class Cache extends CMSPlugin implements SubscriberInterface
+{
+ /**
+ * Application object.
+ *
+ * @var CMSApplication
+ * @since 3.8.0
+ */
+ protected $app;
+
+ /**
+ * Cache instance.
+ *
+ * @var CacheController
+ * @since 1.5
+ */
+ private $cache;
+
+ /**
+ * The application's document factory interface
+ *
+ * @var DocumentFactoryInterface
+ * @since __DEPLOY_VERSION__
+ */
+ private $documentFactory;
+
+ /**
+ * Cache controller factory interface
+ *
+ * @var CacheControllerFactoryInterface
+ * @since __DEPLOY_VERSION__
+ */
+ private $cacheControllerFactory;
+
+ /**
+ * The application profiler, used when Debug Site is set to Yes in Global Configuration.
+ *
+ * @var Profiler|null
+ * @since __DEPLOY_VERSION__
+ */
+ private $profiler;
+
+ /**
+ * The frontend router, injected by the service provider.
+ *
+ * @var SiteRouter|null
+ * @since __DEPLOY_VERSION__
+ */
+ private $router;
+
+ /**
+ * Constructor
+ *
+ * @param DispatcherInterface $subject The object to observe
+ * @param array $config An optional associative
+ * array of configuration
+ * settings. Recognized key
+ * values include 'name',
+ * 'group', 'params',
+ * 'language'
+ * (this list is not meant
+ * to be comprehensive).
+ * @param DocumentFactoryInterface $documentFactory The application's
+ * document factory
+ * @param CacheControllerFactoryInterface $cacheControllerFactory Cache controller factory
+ * @param Profiler|null $profiler The application profiler
+ * @param SiteRouter|null $router The frontend router
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function __construct(
+ &$subject,
+ $config,
+ DocumentFactoryInterface $documentFactory,
+ CacheControllerFactoryInterface $cacheControllerFactory,
+ ?Profiler $profiler,
+ ?SiteRouter $router
+ )
+ {
+ parent::__construct($subject, $config);
+
+ $this->documentFactory = $documentFactory;
+ $this->cacheControllerFactory = $cacheControllerFactory;
+ $this->profiler = $profiler;
+ $this->router = $router;
+ }
+
+ /**
+ * Returns an array of CMS events this plugin will listen to and the respective handlers.
+ *
+ * @return array
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public static function getSubscribedEvents(): array
+ {
+ /**
+ * Note that onAfterRender and onAfterRespond must be the last handlers to run for this
+ * plugin to operate as expected. These handlers put pages into cache. We must make sure
+ * that a. the page SHOULD be cached and b. we are caching the complete page, as it's
+ * output to the browser.
+ */
+ return [
+ 'onAfterRoute' => 'onAfterRoute',
+ 'onAfterRender' => ['onAfterRender', Priority::LOW],
+ 'onAfterRespond' => ['onAfterRespond', Priority::LOW],
+ ];
+ }
+
+ /**
+ * Returns a cached page if the current URL exists in the cache.
+ *
+ * @param Event $event The Joomla event being handled
+ *
+ * @return void
+ *
+ * @since 4.0.0
+ */
+ public function onAfterRoute(Event $event)
+ {
+ if (!$this->appStateSupportsCaching())
+ {
+ return;
+ }
+
+ // If any `pagecache` plugins return false for onPageCacheSetCaching, do not use the cache.
+ PluginHelper::importPlugin('pagecache');
+
+ $results = $this->app->triggerEvent('onPageCacheSetCaching');
+
+ $this->getCacheController()->setCaching(!in_array(false, $results, true));
+
+ $data = $this->getCacheController()->get($this->getCacheKey());
+
+ if ($data === false)
+ {
+ // No cached data.
+ return;
+ }
+
+ // Set the page content from the cache and output it to the browser.
+ $this->app->setBody($data);
+
+ echo $this->app->toString((bool) $this->app->get('gzip'));
+
+ // Mark afterCache in debug and run debug onAfterRespond events, e.g. show Joomla Debug Console if debug is active.
+ if (JDEBUG)
+ {
+ // Create a document instance and load it into the application.
+ $document = $this->documentFactory
+ ->createDocument($this->app->input->get('format', 'html'));
+ $this->app->loadDocument($document);
+
+ if ($this->profiler)
+ {
+ $this->profiler->mark('afterCache');
+ }
+
+ $this->app->triggerEvent('onAfterRespond');
+ }
+
+ // Closes the application.
+ $this->app->close();
+ }
+
+ /**
+ * Does the current application state allow for caching?
+ *
+ * The following conditions must be met:
+ * * This is the frontend application. This plugin does not apply to other applications.
+ * * This is a GET request. This plugin does not apply to POST, PUT etc.
+ * * There is no currently logged in user (pages might have user–specific content).
+ * * The message queue is empty.
+ *
+ * The first two tests are cached to make early returns possible; these conditions cannot change
+ * throughout the lifetime of the request.
+ *
+ * The other two tests MUST NOT be cached because auto–login plugins may fire anytime within
+ * the application lifetime logging in a user and messages can be generated anytime within the
+ * application's lifetime.
+ *
+ * @return boolean
+ * @since __DEPLOY_VERSION__
+ */
+ private function appStateSupportsCaching(): bool
+ {
+ static $isSite = null;
+ static $isGET = null;
+
+ if ($isSite === null)
+ {
+ $isSite = ($this->app instanceof CMSApplicationInterface)
+ && $this->app->isClient('site');
+ $isGET = $this->app->input->getMethod() === 'GET';
+ }
+
+ // Boolean short–circuit evaluation means this returns fast false when $isSite is false.
+ return $isSite
+ && $isGET
+ && $this->app->getIdentity()->guest
+ && empty($this->app->getMessageQueue());
+ }
+
+ /**
+ * Get the cache controller
+ *
+ * @return CacheController
+ * @since __DEPLOY_VERSION__
+ */
+ private function getCacheController(): CacheController
+ {
+ if (!empty($this->cache))
+ {
+ return $this->cache;
+ }
+
+ // Set the cache options.
+ $options = [
+ 'defaultgroup' => 'page',
+ 'browsercache' => $this->params->get('browsercache', 0),
+ 'caching' => false,
+ ];
+
+ // Instantiate cache with previous options.
+ $this->cache = $this->cacheControllerFactory->createCacheController('page', $options);
+
+ return $this->cache;
+ }
+
+ /**
+ * Get a cache key for the current page based on the url and possible other factors.
+ *
+ * @return string
+ *
+ * @since 3.7
+ */
+ private function getCacheKey(): string
+ {
+ static $key;
+
+ if (!$key)
+ {
+ PluginHelper::importPlugin('pagecache');
+
+ $parts = $this->app->triggerEvent('onPageCacheGetKey');
+ $parts[] = Uri::getInstance()->toString();
+
+ $key = md5(serialize($parts));
+ }
+
+ return $key;
+ }
+
+ /**
+ * After Render Event. Check whether the current page is excluded from cache.
+ *
+ * @param Event $event The CMS event we are handling.
+ *
+ * @return void
+ *
+ * @since 3.9.12
+ */
+ public function onAfterRender(Event $event)
+ {
+ if (!$this->appStateSupportsCaching() || $this->getCacheController()->getCaching() === false)
+ {
+ return;
+ }
+
+ if ($this->isExcluded() === true)
+ {
+ $this->getCacheController()->setCaching(false);
+
+ return;
+ }
+
+ // Disable compression before caching the page.
+ $this->app->set('gzip', false);
+ }
+
+ /**
+ * Check if the page is excluded from the cache or not.
+ *
+ * @return boolean True if the page is excluded else false
+ *
+ * @since 3.5
+ */
+ private function isExcluded(): bool
+ {
+ // Check if menu items have been excluded.
+ $excludedMenuItems = $this->params->get('exclude_menu_items', []);
+
+ if ($excludedMenuItems)
+ {
+ // Get the current menu item.
+ $active = $this->app->getMenu()->getActive();
+
+ if ($active && $active->id && in_array((int) $active->id, (array) $excludedMenuItems))
+ {
+ return true;
+ }
+ }
+
+ // Check if regular expressions are being used.
+ $exclusions = $this->params->get('exclude', '');
+
+ if ($exclusions)
+ {
+ // Convert the exclusions into a normalised array
+ $exclusions = str_replace(["\r\n", "\r"], "\n", $exclusions);
+ $exclusions = explode("\n", $exclusions);
+ $filterExpression = function ($x)
+ {
+ return $x !== '';
+ };
+ $exclusions = array_filter($exclusions, $filterExpression);
+
+ // Gets the internal (non-SEF) and the external (possibly SEF) URIs.
+ $internalUrl = '/index.php?'
+ . Uri::getInstance()->buildQuery($this->router->getVars());
+ $externalUrl = Uri::getInstance()->toString();
+
+ $reduceCallback
+ = function (bool $carry, string $exclusion) use ($internalUrl, $externalUrl)
+ {
+ // Test both external and internal URIs
+ return $carry && preg_match(
+ '#' . $exclusion . '#i',
+ $externalUrl . ' ' . $internalUrl, $match
+ );
+ };
+ $excluded = array_reduce($exclusions, $reduceCallback, false);
+
+ if ($excluded)
+ {
+ return true;
+ }
+ }
+
+ // If any pagecache plugins return true for onPageCacheIsExcluded, exclude.
+ PluginHelper::importPlugin('pagecache');
+
+ $results = $this->app->triggerEvent('onPageCacheIsExcluded');
+
+ return in_array(true, $results, true);
+ }
+
+ /**
+ * After Respond Event. Stores page in cache.
+ *
+ * @param Event $event The application event we are handling.
+ *
+ * @return void
+ *
+ * @since 1.5
+ */
+ public function onAfterRespond(Event $event)
+ {
+ if (!$this->appStateSupportsCaching() || $this->getCacheController()->getCaching() === false)
+ {
+ return;
+ }
+
+ // Saves current page in cache.
+ $this->getCacheController()->store($this->app->getBody(), $this->getCacheKey());
+ }
+}