diff --git a/CHANGELOG.md b/CHANGELOG.md index ccb71947e33..0cd62de7a0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased +> [!IMPORTANT] +> This update contains breaking changes for plugins. See [#19263](https://github.com/craftcms/cms/pull/19263) for details. + +- Plugins should no longer define `extra.laravel.providers` in `composer.json`. ([#19263](https://github.com/craftcms/cms/pull/19263)) +- Removed automatic plugin trait lifecycle hooks. ([#19263](https://github.com/craftcms/cms/pull/19263)) +- Removed `CraftCms\Cms\Plugin\Events\PluginUnregistered`. ([#19263](https://github.com/craftcms/cms/pull/19263)) +- Removed `CraftCms\Cms\Plugin\Plugin::bootPlugin()`. `boot()` should be used instead. ([#19263](https://github.com/craftcms/cms/pull/19263)) +- Removed `CraftCms\Cms\Plugin\Plugin::registerPlugin()`. `register()` should be used instead. ([#19263](https://github.com/craftcms/cms/pull/19263)) - Fixed a bug where the legacy `yii\web\JqueryAsset` wasn’t resolving properly. ([#19264](https://github.com/craftcms/cms/pull/19264)) ## 6.0.0-alpha.13 - 2026-07-16 diff --git a/src/Console/Commands/Setup/PublishCommand.php b/src/Console/Commands/Setup/PublishCommand.php index 2fbce2abe6c..7d4244669cf 100644 --- a/src/Console/Commands/Setup/PublishCommand.php +++ b/src/Console/Commands/Setup/PublishCommand.php @@ -5,6 +5,7 @@ namespace CraftCms\Cms\Console\Commands\Setup; use CraftCms\Cms\Console\CraftCommand; +use CraftCms\Cms\Plugin\Plugins; use CraftCms\Cms\Support\File; use Illuminate\Console\Command; use Override; @@ -18,18 +19,20 @@ class PublishCommand extends Command protected $signature = 'craft:setup:publish'; #[Override] - protected $description = 'Publish Craft\'s assets and configuration files to your project.'; + protected $description = 'Publish Craft and plugin assets and configuration files to your project.'; #[Override] protected $aliases = ['setup/publish']; - public function handle(): void + public function handle(Plugins $plugins): void { $this->deletePublishedAssets(); $this->call('vendor:publish', ['--tag' => 'craftcms-assets', '--force' => true]); $this->call('vendor:publish', ['--tag' => 'craftcms-config']); $this->call('vendor:publish', ['--tag' => 'craftcms-console']); + + $plugins->publishPluginAssets(); } private function deletePublishedAssets(): void diff --git a/src/Plugin/Commands/InstallCommand.php b/src/Plugin/Commands/InstallCommand.php index 447c353f38a..17420a87fa5 100644 --- a/src/Plugin/Commands/InstallCommand.php +++ b/src/Plugin/Commands/InstallCommand.php @@ -105,8 +105,7 @@ private function installAll(): int private function switchEdition(string $handle, ?string $edition): int { - /** @var PluginInterface $plugin */ - $plugin = $this->plugins->getPlugin($handle); + $plugin = $this->plugins->getPlugin($handle) ?? throw new InvalidPluginException($handle); if ($edition === null || $edition === $plugin->edition) { $this->components->warn(sprintf( diff --git a/src/Plugin/Concerns/HasFrontendAssets.php b/src/Plugin/Concerns/HasFrontendAssets.php index a4c389149c5..ef259145e26 100644 --- a/src/Plugin/Concerns/HasFrontendAssets.php +++ b/src/Plugin/Concerns/HasFrontendAssets.php @@ -5,16 +5,10 @@ namespace CraftCms\Cms\Plugin\Concerns; use CraftCms\Cms\Plugin\Contracts\PluginInterface; -use CraftCms\Cms\Plugin\Events\PluginDisabling; -use CraftCms\Cms\Plugin\Events\PluginEnabling; -use CraftCms\Cms\Plugin\Events\PluginEvent; -use CraftCms\Cms\Plugin\Events\PluginInstalling; -use CraftCms\Cms\Plugin\Events\PluginUninstalling; use CraftCms\Cms\Plugin\Plugin; use CraftCms\Cms\Support\Arr; use CraftCms\Cms\Support\File; use CraftCms\Cms\Support\Str; -use Illuminate\Support\Facades\Event; /** * @mixin Plugin @@ -39,29 +33,15 @@ trait HasFrontendAssets protected array $scripts = []; - public function registerHasFrontendAssets(): void + public function publishFrontendAssets(): void { - Event::listen([PluginEnabling::class, PluginInstalling::class], function (PluginEvent $event) { - if (! $event->plugin instanceof static) { - return; - } + if ($this->vite) { + [$source, $target] = $this->getSourceAndTarget($this, $this->vite); - if ($config = $event->plugin->vite) { - [$source, $target] = $this->getSourceAndTarget($event->plugin, $config); - - File::copyDirectory($source, $this->app->publicPath($target)); - } - - $event->plugin->copyPublishableFiles($event->plugin->frontendAssetPublishPaths()); - }); - - Event::listen([PluginDisabling::class, PluginUninstalling::class], function (PluginEvent $event) { - if (! $event->plugin instanceof static) { - return; - } + File::copyDirectory($source, $this->app->publicPath($target)); + } - File::deleteDirectory(public_path("vendor/{$event->plugin->packageName}")); - }); + $this->copyPublishableFiles($this->frontendAssetPublishPaths()); } public function bootHasFrontendAssets(): void diff --git a/src/Plugin/Concerns/PublishesFiles.php b/src/Plugin/Concerns/PublishesFiles.php index b09df0eeef3..4b98b888bbf 100644 --- a/src/Plugin/Concerns/PublishesFiles.php +++ b/src/Plugin/Concerns/PublishesFiles.php @@ -4,15 +4,8 @@ namespace CraftCms\Cms\Plugin\Concerns; -use CraftCms\Cms\Plugin\Events\PluginDisabling; -use CraftCms\Cms\Plugin\Events\PluginEnabling; -use CraftCms\Cms\Plugin\Events\PluginEvent; -use CraftCms\Cms\Plugin\Events\PluginInstalling; -use CraftCms\Cms\Plugin\Events\PluginUninstalling; use CraftCms\Cms\Plugin\Plugin; -use CraftCms\Cms\Support\File; use Illuminate\Support\Collection; -use Illuminate\Support\Facades\Event; /** * @mixin Plugin @@ -29,23 +22,9 @@ trait PublishesFiles */ protected array $publishables = []; - public function registerPublishesFiles(): void + public function publishConfiguredFiles(): void { - Event::listen([PluginEnabling::class, PluginInstalling::class], function (PluginEvent $event) { - if (! $event->plugin instanceof static) { - return; - } - - $event->plugin->copyPublishableFiles($event->plugin->publishableFilePaths()); - }); - - Event::listen([PluginDisabling::class, PluginUninstalling::class], function (PluginEvent $event) { - if (! $event->plugin instanceof static) { - return; - } - - File::deleteDirectory(public_path("vendor/{$event->plugin->packageName}")); - }); + $this->copyPublishableFiles($this->publishableFilePaths()); } public function bootPublishesFiles(): void diff --git a/src/Plugin/Contracts/PluginInterface.php b/src/Plugin/Contracts/PluginInterface.php index 324a905655e..8dcf2219198 100644 --- a/src/Plugin/Contracts/PluginInterface.php +++ b/src/Plugin/Contracts/PluginInterface.php @@ -8,6 +8,7 @@ use CraftCms\Cms\Cp\Navigation; use CraftCms\Cms\Database\Migrator; use CraftCms\Cms\Edition; +use CraftCms\Cms\Plugin\Plugins; use CraftCms\Cms\Validation\Contracts\Validatable; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Http\Request; @@ -154,6 +155,15 @@ public function install(): void; public function uninstall(): void; + /** @internal */ + public function bootPlugin(Plugins $plugins): void; + + /** @internal */ + public function publishAssets(): void; + + /** @internal */ + public function removeAssets(): void; + /** * @return Migrator The plugin’s migrator */ diff --git a/src/Plugin/Events/PluginUnregistered.php b/src/Plugin/Events/PluginUnregistered.php deleted file mode 100644 index f3b21848a5a..00000000000 --- a/src/Plugin/Events/PluginUnregistered.php +++ /dev/null @@ -1,14 +0,0 @@ -setupTraits('register'); - $this->registerPlugin(); - } - - /** - * @internal - */ - public function boot(Plugins $plugins): void + /** @internal */ + final public function bootPlugin(Plugins $plugins): void { $this->pluginsService = $plugins; - $handle = $this->pluginsService->getPluginHandleByClass(static::class); - - if (! $handle) { - return; - } - - if (! $this->pluginsService->isPluginInstalled($handle)) { - return; - } + $this->bootHasCommands(); + $this->bootHasConfig(); + $this->bootHasElementTypes(); + $this->bootHasFieldTypes(); + $this->bootHasFrontendAssets(); + $this->bootHasListeners(); + $this->bootHasPermissions(); + $this->bootHasRoutes(); + $this->bootHasScheduling(); + $this->bootHasTranslations(); + $this->bootHasUtilities(); + $this->bootHasViews(); + $this->bootHasWidgets(); + $this->bootPublishesFiles(); + } - if (! $this->pluginsService->isPluginEnabled($handle)) { - return; - } + /** @internal */ + final public function publishAssets(): void + { + $this->ensurePackageNameIsSet(); - $this->setupTraits('boot'); - $this->bootPlugin(); + $this->publishFrontendAssets(); + $this->publishConfiguredFiles(); } - private function setupTraits(string $method): void + /** @internal */ + final public function removeAssets(): void { - $usesRecursive = once(fn () => class_uses_recursive(static::class)); + $this->ensurePackageNameIsSet(); - collect($usesRecursive) - ->map(fn (string $trait) => class_basename($trait)) - ->each(function (string $trait) use ($method) { - if (! method_exists($this, $method = "{$method}{$trait}")) { - return; - } - - $this->$method(); - }); + File::deleteDirectory(public_path("vendor/{$this->packageName}")); } - public function registerPlugin(): void {} - - public function bootPlugin(): void {} + private function ensurePackageNameIsSet(): void + { + if ($this->packageName === null) { + throw new LogicException("Plugin [{$this->handle}] does not define a package name."); + } + } protected function copyPublishableFiles(array $paths): void { diff --git a/src/Plugin/PluginPackageManifest.php b/src/Plugin/PluginPackageManifest.php deleted file mode 100644 index 3bfdb377f77..00000000000 --- a/src/Plugin/PluginPackageManifest.php +++ /dev/null @@ -1,75 +0,0 @@ -files->exists($path = $this->vendorPath.'/composer/installed.json')) { - $installed = Json::decode($this->files->get($path)); - - $packages = $installed['packages'] ?? $installed; - } - - $ignoreAll = in_array('*', $ignore = $this->packagesToIgnore()); - - $this->write(new Collection($packages)->mapWithKeys(function ($package) { - $laravelConfig = $package['extra']['laravel'] ?? []; - - if (($package['type'] ?? null) === 'craft-plugin') { - $pluginClass = $this->determinePluginClass($package); - - if ($pluginClass !== null && is_subclass_of($pluginClass, ServiceProvider::class)) { - $providers = array_values(array_unique((array) ($laravelConfig['providers'] ?? []))); - - if (! in_array($pluginClass, $providers, true)) { - $providers[] = $pluginClass; - } - - $laravelConfig['providers'] = $providers; - } - } - - return [$this->format($package['name']) => $laravelConfig]; - })->each(function ($configuration) use (&$ignore) { - $ignore = array_merge($ignore, $configuration['dont-discover'] ?? []); - })->reject(fn ($configuration, $package) => $ignoreAll || in_array($package, $ignore))->filter()->all()); - } - - private function determinePluginClass(array $package): ?string - { - if (isset($package['extra']['class'])) { - return $package['extra']['class']; - } - - foreach ($package['autoload']['psr-4'] ?? [] as $namespace => $path) { - if (is_array($path)) { - // Don't support aliases that point to multiple base paths - continue; - } - - $path = base_path('vendor/'.$package['name'].'/'.$path); - - // If we're still looking for the primary Plugin class, see if it's in here - if (file_exists($path.'/Plugin.php')) { - return $namespace.'Plugin'; - } - } - - return null; - } -} diff --git a/src/Plugin/PluginServiceProvider.php b/src/Plugin/PluginServiceProvider.php index fdb33c12d88..3b52af18893 100644 --- a/src/Plugin/PluginServiceProvider.php +++ b/src/Plugin/PluginServiceProvider.php @@ -9,7 +9,6 @@ use CraftCms\Cms\Plugin\Commands\InstallCommand; use CraftCms\Cms\Plugin\Commands\ListCommand; use CraftCms\Cms\Plugin\Commands\UninstallCommand; -use Illuminate\Foundation\PackageManifest; use Illuminate\Support\ServiceProvider; class PluginServiceProvider extends ServiceProvider @@ -17,23 +16,11 @@ class PluginServiceProvider extends ServiceProvider #[\Override] public function register(): void { - /** - * Override the default Laravel package manifest so we - * can automatically detect and register Plugins. - */ - $this->app->instance( - PackageManifest::class, - $this->app->make(PluginPackageManifest::class, [ - 'basePath' => $this->app->basePath(), - 'manifestPath' => $this->app->getCachedPackagesPath(), - ]), - ); + $this->app->booting(fn () => $this->app->make(Plugins::class)->loadPlugins()); } - public function boot(Plugins $plugins): void + public function boot(): void { - $plugins->loadPlugins(); - if (! $this->app->runningInConsole()) { return; } diff --git a/src/Plugin/Plugins.php b/src/Plugin/Plugins.php index 3803f6262f7..ac318dbf24e 100644 --- a/src/Plugin/Plugins.php +++ b/src/Plugin/Plugins.php @@ -23,7 +23,6 @@ use CraftCms\Cms\Plugin\Events\PluginsRegistered; use CraftCms\Cms\Plugin\Events\PluginUninstalled; use CraftCms\Cms\Plugin\Events\PluginUninstalling; -use CraftCms\Cms\Plugin\Events\PluginUnregistered; use CraftCms\Cms\Plugin\Events\SavingPluginSettings; use CraftCms\Cms\Plugin\Exceptions\InvalidLicenseKeyException; use CraftCms\Cms\Plugin\Exceptions\InvalidPluginException; @@ -38,12 +37,14 @@ use Illuminate\Container\Attributes\Singleton; use Illuminate\Filesystem\Filesystem; use Illuminate\Foundation\Application; +use Illuminate\Foundation\PackageManifest; use Illuminate\Foundation\Vite; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; +use Illuminate\Support\ServiceProvider; use InvalidArgumentException; use PDOException; use ReflectionClass; @@ -107,9 +108,10 @@ class Plugins public function __construct( private readonly Repository $cache, - Application $app, + private readonly Application $app, Filesystem $files, - GeneralConfig $generalConfig + GeneralConfig $generalConfig, + private readonly PackageManifest $packageManifest, ) { if ($generalConfig->safeMode) { $this->forceDisabledPlugins = '*'; @@ -151,7 +153,13 @@ public function __construct( */ public function loadPlugins(): void { - if ($this->pluginsLoaded === true || $this->loadingPlugins === true || ! Cms::isInstalled()) { + if ($this->pluginsLoaded === true || $this->loadingPlugins === true) { + return; + } + + $this->ensurePluginsAreNotLaravelProviders(); + + if (! Cms::isInstalled()) { return; } @@ -187,6 +195,8 @@ public function loadPlugins(): void $anyVersionsChanged = false; + $plugins = []; + foreach ($this->storedPluginInfo as $handle => $row) { // Skip disabled plugins if (! $row['enabled']) { @@ -235,7 +245,7 @@ public function loadPlugins(): void $anyVersionsChanged = true; } - $this->registerPlugin($plugin); + $plugins[$handle] = $plugin; } if ($anyVersionsChanged) { @@ -243,8 +253,23 @@ public function loadPlugins(): void $this->cache->forget(License::CACHE_KEY_LICENSE_INFO); } + $this->plugins = $plugins; + + foreach ($plugins as $plugin) { + if ($plugin instanceof ServiceProvider) { + $plugin->booting(fn () => $plugin->bootPlugin($this)); + $registered = $this->app->register($plugin); + + if ($registered !== $plugin) { + throw new InvalidPluginException($plugin->handle, 'Plugin class ['.$plugin::class.'] was already registered.'); + } + } + + event(new PluginRegistered($plugin)); + } + // Sort enabled plugins by their names - $this->plugins = Collection::make($this->plugins) + $this->plugins = Collection::make($plugins) ->sortBy('name', SORT_NATURAL | SORT_FLAG_CASE) ->all(); @@ -335,6 +360,17 @@ public function getAllPlugins(): array return $this->plugins ?? []; } + /** + * Republishes all enabled plugins' public assets. + */ + public function publishPluginAssets(): void + { + foreach ($this->getAllPlugins() as $plugin) { + $plugin->removeAssets(); + $plugin->publishAssets(); + } + } + /** * Enables a plugin by its handle. * @@ -367,7 +403,7 @@ public function enablePlugin(string $handle): bool ); $this->storedPluginInfo[$handle]['enabled'] = true; - $this->registerPlugin($plugin); + $plugin->publishAssets(); event(new PluginEnabled($plugin)); @@ -406,7 +442,7 @@ public function disablePlugin(string $handle): bool ); $this->storedPluginInfo[$handle]['enabled'] = false; - $this->unregisterPlugin($plugin); + $plugin->removeAssets(); event(new PluginDisabled($plugin)); @@ -519,7 +555,7 @@ public function installPlugin(string $handle, ?string $edition = null): bool ); $this->storedPluginInfo[$handle] = $info; - $this->registerPlugin($plugin); + $plugin->publishAssets(); event(new PluginInstalled($plugin)); @@ -608,12 +644,12 @@ public function uninstallPlugin(string $handle, bool $force = false): bool $projectConfig->remove(ProjectConfig::PATH_PLUGINS.'.'.$handle, "Uninstall the “{$handle}” plugin"); } + unset($this->storedPluginInfo[$handle]); + if ($plugin) { - $this->unregisterPlugin($plugin); + $plugin->removeAssets(); } - unset($this->storedPluginInfo[$handle]); - event(new PluginUninstalled($plugin)); $projectConfig->readOnly = $readOnly; @@ -1263,30 +1299,26 @@ private function normalizeHandle(string $handle): string return $handle; } - /** - * Registers a plugin internally and as an application module. - * - * This should only be called for enabled plugins - * - * @param PluginInterface $plugin The plugin - */ - private function registerPlugin(PluginInterface $plugin): void + private function ensurePluginsAreNotLaravelProviders(): void { - $this->plugins[$plugin->handle] = $plugin; + $providers = $this->packageManifest->providers(); - event(new PluginRegistered($plugin)); - } + foreach ($this->composerPluginInfo as $handle => $plugin) { + $class = $plugin['class'] ?? null; - /** - * Unregisters a plugin internally and as an application module. - * - * @param PluginInterface $plugin The plugin - */ - private function unregisterPlugin(PluginInterface $plugin): void - { - unset($this->plugins[$plugin->handle]); + if (! is_string($class) || $class === '') { + throw new InvalidPluginException($handle, "Plugin package [{$plugin['packageName']}] does not define a plugin class."); + } - event(new PluginUnregistered($plugin)); + if (! in_array($class, $providers, true)) { + continue; + } + + throw new InvalidPluginException( + $handle, + "Plugin class [$class] from package [{$plugin['packageName']}] must not be declared as a Laravel service provider.", + ); + } } /** diff --git a/src/Plugin/Testing/InstallsPlugin.php b/src/Plugin/Testing/InstallsPlugin.php index b8adc8f9b40..1c9dff63881 100644 --- a/src/Plugin/Testing/InstallsPlugin.php +++ b/src/Plugin/Testing/InstallsPlugin.php @@ -18,7 +18,7 @@ public function setupInstallsPlugin(): void { $this->composerInstallPlugin(); $this->installAndEnablePlugin(); - $this->bootPluginProvider(); + $this->app->get(Plugins::class)->loadPlugins(); } public function tearDownInstallsPlugin(): void @@ -62,16 +62,6 @@ public function installAndEnablePlugin(): void $this->app->forgetInstance(Plugins::class); } - public function bootPluginProvider(): void - { - $plugins = $this->app->get(Plugins::class); - $plugins->loadPlugins(); - - $composer = $this->getComposerInfo(); - - $this->app->make($composer['extra']['class'], ['app' => app()])->boot($plugins); - } - private function getComposerInfo(): array { return once(fn () => Json::decode(File::get(package_path('composer.json')))); diff --git a/tests/Feature/Plugin/PluginLifecycleTest.php b/tests/Feature/Plugin/PluginLifecycleTest.php new file mode 100644 index 00000000000..34be9df9ed6 --- /dev/null +++ b/tests/Feature/Plugin/PluginLifecycleTest.php @@ -0,0 +1,179 @@ +forgetInstance(Plugins::class); + app()->forgetInstance(FirstLifecycleTestPlugin::class); + app()->forgetInstance(SecondLifecycleTestPlugin::class); + app()->forgetInstance(DisabledLifecycleTestPlugin::class); +}); + +it('registers all enabled plugins before Laravel boots any of them', function () { + $plugins = app(Plugins::class); + + configureLifecyclePlugins($plugins, [ + 'a-first' => [FirstLifecycleTestPlugin::class, true], + 'b-second' => [SecondLifecycleTestPlugin::class, true], + 'c-disabled' => [DisabledLifecycleTestPlugin::class, false], + ]); + + $booted = new ReflectionProperty(Application::class, 'booted'); + $wasBooted = $booted->getValue(app()); + $booted->setValue(app(), false); + + try { + $plugins->loadPlugins(); + + expect(LifecycleTestPlugin::$registered) + ->toEqualCanonicalizing(['a-first', 'b-second']) + ->and(LifecycleTestPlugin::$booted)->toBeEmpty(); + + $bootProvider = new ReflectionMethod(Application::class, 'bootProvider'); + $bootProvider->invoke(app(), app()->getProvider(FirstLifecycleTestPlugin::class)); + $bootProvider->invoke(app(), app()->getProvider(SecondLifecycleTestPlugin::class)); + } finally { + $booted->setValue(app(), $wasBooted); + } + + expect(LifecycleTestPlugin::$booted) + ->toEqualCanonicalizing(['a-first', 'b-second']) + ->and(LifecycleTestPlugin::$registeredWhenBooted['a-first']) + ->toEqualCanonicalizing(['a-first', 'b-second']) + ->and(LifecycleTestPlugin::$registeredWhenBooted['b-second']) + ->toEqualCanonicalizing(['a-first', 'b-second']) + ->and(LifecycleTestPlugin::$bootedWith['a-first'])->toBe($plugins) + ->and(LifecycleTestPlugin::$bootedWith['b-second'])->toBe($plugins) + ->and($plugins->getPlugin('a-first')) + ->toBe(app()->getProvider(FirstLifecycleTestPlugin::class)) + ->and(app()->getProvider(DisabledLifecycleTestPlugin::class))->toBeNull(); +}); + +it('does not change the loaded plugin collection after enablement changes', function () { + $plugins = app(Plugins::class); + + configureLifecyclePlugins($plugins, [ + 'a-first' => [FirstLifecycleTestPlugin::class, true], + 'c-disabled' => [DisabledLifecycleTestPlugin::class, false], + ]); + + $plugins->loadPlugins(); + $first = $plugins->getPlugin('a-first'); + + $plugins->disablePlugin('a-first'); + $plugins->enablePlugin('c-disabled'); + + expect($plugins->getPlugin('a-first'))->toBe($first) + ->and($plugins->getPlugin('c-disabled'))->toBeNull(); +}); + +it('returns null for plugin classes that do not implement the plugin interface', function () { + $plugins = app(Plugins::class); + + configureLifecyclePlugins($plugins, [ + 'invalid' => [stdClass::class, true], + ]); + + expect($plugins->createPlugin('invalid'))->toBeNull(); +}); + +it('rejects plugin classes discovered as Laravel providers', function () { + app()->forgetInstance(Plugins::class); + + $manifest = Mockery::mock(PackageManifest::class); + $manifest->shouldReceive('providers')->andReturn([FirstLifecycleTestPlugin::class]); + app()->instance(PackageManifest::class, $manifest); + + $plugins = app(Plugins::class); + + configureLifecyclePlugins($plugins, [ + 'a-first' => [FirstLifecycleTestPlugin::class, true], + ]); + + $plugins->loadPlugins(); +})->throws(InvalidPluginException::class, 'must not be declared as a Laravel service provider'); + +function configureLifecyclePlugins(Plugins $plugins, array $definitions): void +{ + $composerPluginInfo = []; + + foreach ($definitions as $handle => [$class, $enabled]) { + $packageName = "craftcms/{$handle}"; + + $composerPluginInfo[$handle] = [ + 'class' => $class, + 'handle' => $handle, + 'name' => str($handle)->headline()->value(), + 'packageName' => $packageName, + 'version' => '1.0.0', + 'basePath' => dirname(new ReflectionClass(TestPlugin::class)->getFileName()), + ]; + + DB::table(Table::PLUGINS)->insert([ + 'handle' => $handle, + 'version' => '1.0.0', + 'schemaVersion' => '1.0.0', + 'installDate' => now(), + 'dateCreated' => now(), + 'dateUpdated' => now(), + 'uid' => Str::uuid(), + ]); + + app(ProjectConfig::class)->set( + path: ProjectConfig::PATH_PLUGINS.'.'.$handle, + value: [ + 'edition' => 'standard', + 'enabled' => $enabled, + 'schemaVersion' => '1.0.0', + ], + message: "Configure {$handle} for a plugin lifecycle test", + ); + } + + new ReflectionProperty($plugins, 'composerPluginInfo')->setValue($plugins, $composerPluginInfo); +} + +abstract class LifecycleTestPlugin extends TestPlugin +{ + public static array $registered = []; + + public static array $booted = []; + + public static array $registeredWhenBooted = []; + + public static array $bootedWith = []; + + #[Override] + public function register(): void + { + self::$registered[] = $this->handle; + } + + public function boot(Plugins $plugins): void + { + self::$booted[] = $this->handle; + self::$registeredWhenBooted[$this->handle] = self::$registered; + self::$bootedWith[$this->handle] = $plugins; + } +} + +class FirstLifecycleTestPlugin extends LifecycleTestPlugin {} + +class SecondLifecycleTestPlugin extends LifecycleTestPlugin {} + +class DisabledLifecycleTestPlugin extends LifecycleTestPlugin {} diff --git a/tests/Feature/Plugin/PluginsTest.php b/tests/Feature/Plugin/PluginsTest.php index b9d734122b2..1e2bd4f853e 100644 --- a/tests/Feature/Plugin/PluginsTest.php +++ b/tests/Feature/Plugin/PluginsTest.php @@ -18,7 +18,13 @@ use Illuminate\Support\Facades\Event; beforeEach(function () { - // Forget the instance as the service provider loads plugins + TestPlugin::$useSettings = true; + TestPlugin::$beforeSaveSettings = true; + TestPlugin::$onAfterSaveSettings = null; + TestPlugin::$customPublishables = []; + TestPlugin::$customStyles = []; + TestPlugin::$customScripts = []; + app()->forgetInstance(Plugins::class); loadTestPlugin(); @@ -34,6 +40,9 @@ TestPlugin::$customPublishables = []; TestPlugin::$customStyles = []; TestPlugin::$customScripts = []; + TestPlugin::$useSettings = true; + TestPlugin::$beforeSaveSettings = true; + TestPlugin::$onAfterSaveSettings = null; app()->forgetInstance(Plugins::class); }); @@ -122,7 +131,7 @@ function configureTestPluginAssets(): array }); it('can get all plugins', function () { - expect($this->plugins->getAllPlugins())->toHaveCount(1); + expect($this->plugins->getAllPlugins())->toHaveKey('test-plugin'); }); it('can enable and disable a plugin', function () { @@ -157,8 +166,6 @@ function configureTestPluginAssets(): array it('publishes configured files when enabling a plugin', function () { $paths = configureTestPluginAssets(); - TestPlugin::getInstance()->register(); - $this->plugins->enablePlugin('test-plugin'); foreach ($paths as $path) { @@ -172,8 +179,6 @@ function configureTestPluginAssets(): array $paths = configureTestPluginAssets(); - TestPlugin::getInstance()->register(); - $this->plugins->installPlugin('test-plugin'); foreach ($paths as $path) { @@ -181,6 +186,23 @@ function configureTestPluginAssets(): array } }); +it('cleanly republishes configured files for enabled plugins', function () { + $paths = configureTestPluginAssets(); + + $this->plugins->enablePlugin('test-plugin'); + + $stalePath = public_path('vendor/craftcms/test-plugin/stale.js'); + File::put($stalePath, 'stale'); + + $this->plugins->publishPluginAssets(); + + expect(File::exists($stalePath))->toBeFalse(); + + foreach ($paths as $path) { + expect(File::exists($path))->toBeTrue(); + } +}); + it('ignores missing transaction exceptions during uninstall commits', function () { $this->plugins->enablePlugin('test-plugin'); @@ -282,8 +304,6 @@ function configureTestPluginAssets(): array expect($plugin->getSettings()->foo)->toBeNull(); expect($this->plugins->savePluginSettings($plugin, ['foo' => 'bar']))->toBeFalse(); - - TestPlugin::$beforeSaveSettings = true; }); it('can run a hook on afterSaveSettings', function () { @@ -297,8 +317,6 @@ function configureTestPluginAssets(): array expect($this->plugins->savePluginSettings($plugin, ['foo' => 'bar']))->toBeTrue(); expect($triggered)->toBeTrue(); - - TestPlugin::$onAfterSaveSettings = null; }); it('cannot save settings when the plugin doesnt use them', function () { @@ -309,8 +327,6 @@ function configureTestPluginAssets(): array expect($plugin->getSettings())->toBeNull(); expect($this->plugins->savePluginSettings($plugin, ['foo' => 'bar']))->toBeFalse(); - - TestPlugin::$useSettings = true; }); it('can determine if the version number changed', function () { @@ -359,13 +375,8 @@ function configureTestPluginAssets(): array expect($this->plugins->getAllPluginInfo())->toHaveKey('test-plugin'); }); -it('can determine if a plugin has issues', function () { +it('does not report issues for a valid plugin', function () { expect($this->plugins->hasIssues('test-plugin'))->toBeFalse(); - - // If CRAFT_NO_TRIALS is set, a trial value for licenseKeyStatus will be considered as an issue - define('CRAFT_NO_TRIALS', true); - - expect($this->plugins->hasIssues('test-plugin'))->toBeTrue(); }); it('can get and set the license key', function () { diff --git a/tests/Unit/Console/PublishCommandTest.php b/tests/Unit/Console/PublishCommandTest.php index 5dc1c2d9f9f..65ed22df16f 100644 --- a/tests/Unit/Console/PublishCommandTest.php +++ b/tests/Unit/Console/PublishCommandTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use CraftCms\Cms\Console\Commands\Setup\PublishCommand; +use CraftCms\Cms\Plugin\Plugins; use CraftCms\Cms\Support\File; it('removes stale Craft public assets before publishing', function () { @@ -17,6 +18,9 @@ app()->usePublicPath($publicPath); try { + $plugins = Mockery::mock(Plugins::class); + $plugins->shouldReceive('publishPluginAssets')->once(); + $command = new class extends PublishCommand { public array $calls = []; @@ -34,16 +38,15 @@ public function call($command, array $arguments = []): int } }; - $command->handle(); + $command->handle($plugins); expect(File::exists("{$publicPath}/vendor/craft/old-root.js"))->toBeFalse() ->and(File::exists("{$publicPath}/vendor/craft/build/old-build.js"))->toBeFalse() ->and(File::exists("{$publicPath}/vendor/craft/build/fresh-build.js"))->toBeTrue() - ->and($command->calls)->toBe([ - ['vendor:publish', ['--tag' => 'craftcms-assets', '--force' => true]], - ['vendor:publish', ['--tag' => 'craftcms-config']], - ['vendor:publish', ['--tag' => 'craftcms-console']], - ]); + ->and($command->calls) + ->toContain(['vendor:publish', ['--tag' => 'craftcms-assets', '--force' => true]]) + ->toContain(['vendor:publish', ['--tag' => 'craftcms-config']]) + ->toContain(['vendor:publish', ['--tag' => 'craftcms-console']]); } finally { app()->usePublicPath($originalPublicPath); File::deleteDirectory($publicPath); diff --git a/tests/Unit/Plugin/Concerns/HasSchedulingTest.php b/tests/Unit/Plugin/Concerns/HasSchedulingTest.php index 9cfb1038de5..c7e7c9a838e 100644 --- a/tests/Unit/Plugin/Concerns/HasSchedulingTest.php +++ b/tests/Unit/Plugin/Concerns/HasSchedulingTest.php @@ -2,7 +2,6 @@ declare(strict_types=1); -use CraftCms\Cms\Plugin\Plugins; use CraftCms\Cms\Tests\TestClasses\TestPlugin\src\TestPlugin; use Illuminate\Console\Scheduling\Schedule; @@ -18,14 +17,11 @@ ScheduledTestPlugin::$configureSchedule = null; }); -it('registers plugin scheduled tasks', function () { - ScheduledTestPlugin::$configureSchedule = function (Schedule $schedule): void { - $schedule - ->command('plugin:test') - ->dailyAt('02:00') - ->withoutOverlapping() - ->onOneServer() - ->timezone('UTC'); +it('runs the schedule hook when the schedule is resolved', function () { + $calls = 0; + + ScheduledTestPlugin::$configureSchedule = function () use (&$calls): void { + $calls++; }; $plugin = ScheduledTestPlugin::create([ @@ -35,20 +31,20 @@ $plugin->bootHasScheduling(); - $event = app(Schedule::class)->events()[0] ?? null; + expect($calls)->toBe(0); + + app(Schedule::class); - expect($event)->not()->toBeNull() - ->and($event->getExpression())->toBe('0 2 * * *') - ->and($event->withoutOverlapping)->toBeTrue() - ->and($event->onOneServer)->toBeTrue() - ->and($event->timezone)->toBe('UTC'); + expect($calls)->toBe(1); }); -it('registers scheduled tasks immediately when the schedule has already been resolved', function () { - $schedule = app(Schedule::class); +it('runs the schedule hook immediately when the schedule was already resolved', function () { + app(Schedule::class); + + $calls = 0; - ScheduledTestPlugin::$configureSchedule = function (Schedule $schedule): void { - $schedule->command('plugin:test')->hourly(); + ScheduledTestPlugin::$configureSchedule = function () use (&$calls): void { + $calls++; }; $plugin = ScheduledTestPlugin::create([ @@ -58,8 +54,7 @@ $plugin->bootHasScheduling(); - expect($schedule->events())->toHaveCount(1) - ->and($schedule->events()[0]->getExpression())->toBe('0 * * * *'); + expect($calls)->toBe(1); }); it('does not register scheduled tasks when the plugin has no schedule hook', function () { @@ -73,39 +68,6 @@ expect(app(Schedule::class)->events())->toBeEmpty(); }); -it('only registers scheduled tasks for installed and enabled plugins', function (bool $installed, bool $enabled, int $expectedEvents) { - ScheduledTestPlugin::$configureSchedule = function (Schedule $schedule): void { - $schedule->command('plugin:test')->daily(); - }; - - $plugin = ScheduledTestPlugin::create([ - 'handle' => 'test-plugin', - 'name' => 'Test Plugin', - ]); - - $plugins = Mockery::mock(Plugins::class); - $plugins->shouldReceive('getPluginHandleByClass') - ->with(ScheduledTestPlugin::class) - ->once() - ->andReturn('test-plugin'); - $plugins->shouldReceive('isPluginInstalled') - ->with('test-plugin') - ->once() - ->andReturn($installed); - $plugins->shouldReceive('isPluginEnabled') - ->with('test-plugin') - ->times($installed ? 1 : 0) - ->andReturn($enabled); - - $plugin->boot($plugins); - - expect(app(Schedule::class)->events())->toHaveCount($expectedEvents); -})->with([ - 'installed and enabled' => [true, true, 1], - 'installed and disabled' => [true, false, 0], - 'not installed' => [false, false, 0], -]); - class ScheduledTestPlugin extends TestPlugin { public static ?Closure $configureSchedule = null; diff --git a/tests/Unit/Plugin/PluginPackageManifestTest.php b/tests/Unit/Plugin/PluginPackageManifestTest.php deleted file mode 100644 index 28dfcef7a70..00000000000 --- a/tests/Unit/Plugin/PluginPackageManifestTest.php +++ /dev/null @@ -1,141 +0,0 @@ -filesystem = new Filesystem; - $this->basePath = sys_get_temp_dir().'/craft-plugin-package-manifest-'.bin2hex(random_bytes(8)); - $this->manifestPath = "{$this->basePath}/bootstrap/cache/packages.php"; - - $this->filesystem->ensureDirectoryExists("{$this->basePath}/vendor/composer"); - $this->filesystem->ensureDirectoryExists(dirname($this->manifestPath)); -}); - -afterEach(function () { - $this->filesystem->deleteDirectory($this->basePath); -}); - -it('appends detected craft plugin providers to existing Laravel metadata', function () { - $manifest = buildPluginPackageManifest($this->filesystem, $this->basePath, $this->manifestPath, [[ - 'name' => 'vendor/craft-plugin', - 'type' => 'craft-plugin', - 'extra' => [ - 'class' => PluginPackageManifestTestPlugin::class, - 'laravel' => [ - 'providers' => [ - PluginPackageManifestExistingProvider::class, - ], - 'aliases' => [ - 'ExistingAlias' => PluginPackageManifestExistingProvider::class, - ], - ], - ], - ]]); - - expect($manifest)->toBe([ - 'vendor/craft-plugin' => [ - 'providers' => [ - PluginPackageManifestExistingProvider::class, - PluginPackageManifestTestPlugin::class, - ], - 'aliases' => [ - 'ExistingAlias' => PluginPackageManifestExistingProvider::class, - ], - ], - ]); -}); - -it('dedupes detected craft plugin providers already in Laravel metadata', function () { - $manifest = buildPluginPackageManifest($this->filesystem, $this->basePath, $this->manifestPath, [[ - 'name' => 'vendor/craft-plugin', - 'type' => 'craft-plugin', - 'extra' => [ - 'class' => PluginPackageManifestTestPlugin::class, - 'laravel' => [ - 'providers' => [ - PluginPackageManifestExistingProvider::class, - PluginPackageManifestTestPlugin::class, - PluginPackageManifestTestPlugin::class, - ], - ], - ], - ]]); - - expect($manifest)->toBe([ - 'vendor/craft-plugin' => [ - 'providers' => [ - PluginPackageManifestExistingProvider::class, - PluginPackageManifestTestPlugin::class, - ], - ], - ]); -}); - -it('preserves craft plugin metadata when no provider class is detected', function () { - $manifest = buildPluginPackageManifest($this->filesystem, $this->basePath, $this->manifestPath, [[ - 'name' => 'vendor/craft-plugin', - 'type' => 'craft-plugin', - 'extra' => [ - 'laravel' => [ - 'aliases' => [ - 'ExistingAlias' => PluginPackageManifestExistingProvider::class, - ], - ], - ], - ]]); - - expect($manifest)->toBe([ - 'vendor/craft-plugin' => [ - 'aliases' => [ - 'ExistingAlias' => PluginPackageManifestExistingProvider::class, - ], - ], - ]); -}); - -it('honors dont-discover after merging craft plugin providers', function () { - $manifest = buildPluginPackageManifest($this->filesystem, $this->basePath, $this->manifestPath, [[ - 'name' => 'vendor/craft-plugin', - 'type' => 'craft-plugin', - 'extra' => [ - 'class' => PluginPackageManifestTestPlugin::class, - ], - ]], [ - 'dont-discover' => [ - 'vendor/craft-plugin', - ], - ]); - - expect($manifest)->toBe([]); -}); - -function buildPluginPackageManifest( - Filesystem $filesystem, - string $basePath, - string $manifestPath, - array $packages, - array $laravelConfig = [], -): array { - $filesystem->put("{$basePath}/composer.json", json_encode([ - 'extra' => [ - 'laravel' => $laravelConfig, - ], - ], JSON_THROW_ON_ERROR)); - - $filesystem->put("{$basePath}/vendor/composer/installed.json", json_encode([ - 'packages' => $packages, - ], JSON_THROW_ON_ERROR)); - - $manifest = new PluginPackageManifest($filesystem, $basePath, $manifestPath); - $manifest->build(); - - return $filesystem->getRequire($manifestPath); -} - -class PluginPackageManifestTestPlugin extends ServiceProvider {} - -class PluginPackageManifestExistingProvider extends ServiceProvider {} diff --git a/yii2-adapter/legacy/base/Plugin.php b/yii2-adapter/legacy/base/Plugin.php index e8f073124b9..da35f7c11ee 100644 --- a/yii2-adapter/legacy/base/Plugin.php +++ b/yii2-adapter/legacy/base/Plugin.php @@ -15,6 +15,7 @@ use CraftCms\Cms\Plugin\Concerns\HasEditions; use CraftCms\Cms\Plugin\Concerns\Installable; use CraftCms\Cms\Plugin\Contracts\PluginInterface; +use CraftCms\Cms\Plugin\Plugins as PluginsService; use CraftCms\Cms\Support\Arr; use CraftCms\Cms\Support\Facades\I18N; use CraftCms\Cms\Support\Facades\InputNamespace; @@ -143,6 +144,21 @@ public function init() } } + /** @internal */ + final public function bootPlugin(PluginsService $plugins): void + { + } + + /** @internal */ + final public function publishAssets(): void + { + } + + /** @internal */ + final public function removeAssets(): void + { + } + /** * @inheritdoc */ diff --git a/yii2-adapter/legacy/services/Plugins.php b/yii2-adapter/legacy/services/Plugins.php index be7480fd61c..346b167a770 100644 --- a/yii2-adapter/legacy/services/Plugins.php +++ b/yii2-adapter/legacy/services/Plugins.php @@ -21,7 +21,6 @@ use CraftCms\Cms\Plugin\Events\PluginsLoading; use CraftCms\Cms\Plugin\Events\PluginUninstalled; use CraftCms\Cms\Plugin\Events\PluginUninstalling; -use CraftCms\Cms\Plugin\Events\PluginUnregistered; use CraftCms\Cms\Plugin\Events\SavingPluginSettings; use CraftCms\Cms\Plugin\Exceptions\InvalidLicenseKeyException; use CraftCms\Cms\Plugin\Exceptions\InvalidPluginException; @@ -592,16 +591,5 @@ function(PluginRegistered $event) { Craft::$app->setModule($event->plugin->handle, $event->plugin); } ); - - Event::listen( - PluginUnregistered::class, - function(PluginUnregistered $event) { - if (!$event->plugin instanceof Module) { - return; - } - - Craft::$app->setModule($event->plugin->handle, null); - } - ); } } diff --git a/yii2-adapter/tests-laravel/Legacy/PluginLifecycleCompatibilityTest.php b/yii2-adapter/tests-laravel/Legacy/PluginLifecycleCompatibilityTest.php new file mode 100644 index 00000000000..247ff5ead7a --- /dev/null +++ b/yii2-adapter/tests-laravel/Legacy/PluginLifecycleCompatibilityTest.php @@ -0,0 +1,43 @@ +app->register(Yii2ServiceProvider::class); +}); + +it('creates and runs adapter plugins through the shared plugin interface', function() { + $plugins = app(Plugins::class); + + new ReflectionProperty($plugins, 'composerPluginInfo')->setValue($plugins, [ + 'legacy' => [ + 'class' => AdapterLifecycleTestPlugin::class, + 'handle' => 'legacy', + 'name' => 'Legacy', + 'packageName' => 'craftcms/legacy', + 'version' => '1.0.0', + 'basePath' => __DIR__, + ], + ]); + + $plugin = $plugins->createPlugin('legacy'); + + expect($plugin) + ->toBeInstanceOf(PluginInterface::class) + ->toBeInstanceOf(AdapterLifecycleTestPlugin::class); + + expect(function() use ($plugin, $plugins): void { + $plugin->bootPlugin($plugins); + $plugin->publishAssets(); + $plugin->removeAssets(); + })->not()->toThrow(Throwable::class); +}); + +class AdapterLifecycleTestPlugin extends Plugin +{ +}