Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,15 @@ All notable changes to the Pollora framework will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased](https://github.com/Pollora/framework/compare/v13.40.0...develop)
## [Unreleased](https://github.com/Pollora/framework/compare/v13.40.1...develop)

## [v13.40.1](https://github.com/Pollora/framework/compare/v13.40.0...v13.40.1) - 2026-11-05

### Added
- **`pollora:plugin:activate`**: activates WordPress plugins, given as paths relative to the plugins directory (`woocommerce/woocommerce.php`), and refuses to run when WordPress is not fully loaded

### Fixed
- **`pollora new` no longer crashes when a theme template requires a WordPress plugin and you activate it** (`arsort(): Argument #1 ($array) must be of type array, null given`). The E-commerce template (`pollora/theme-apiary`) always hit it: it requires WooCommerce. On a first install `pollora:make:theme` runs in the process that has just installed WordPress, which is only partly loaded (`SHORTINIT`), and `activate_plugin()` failed there before the theme build and the theme activation. The plugins are now activated by a fresh `pollora:plugin:activate` process, where WordPress loads completely and the activation hooks run. If that process fails, the install carries on and prints the `wp plugin activate <slug>` to run afterwards (Pollora/pollora#274)

## [v13.40.0](https://github.com/Pollora/framework/compare/v13.39.3...v13.40.0) - 2026-11-04

Expand Down
3 changes: 3 additions & 0 deletions src/Plugin/Infrastructure/Providers/PluginServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use Pollora\Plugin\Infrastructure\Repositories\PluginRepository;
use Pollora\Plugin\Infrastructure\Services\PluginAutoloader;
use Pollora\Plugin\Infrastructure\Services\WordPressPluginParser;
use Pollora\Plugin\UI\Console\Commands\PluginActivateCommand;
use Pollora\Plugin\UI\Console\Commands\PluginListCommand;
use Pollora\Plugin\UI\Console\Commands\PluginStatusCommand;
use Pollora\Plugin\UI\Console\MakePluginCommand;
Expand Down Expand Up @@ -115,6 +116,7 @@ protected function registerPluginCommands(): void
if ($this->app->runningInConsole()) {
$this->commands([
MakePluginCommand::class,
PluginActivateCommand::class,
PluginStatusCommand::class,
PluginListCommand::class,
]);
Expand Down Expand Up @@ -163,6 +165,7 @@ public function provides(): array
'plugin.manager',
'plugin.registrar',
MakePluginCommand::class,
PluginActivateCommand::class,
PluginStatusCommand::class,
PluginListCommand::class,
];
Expand Down
60 changes: 60 additions & 0 deletions src/Plugin/UI/Console/Commands/PluginActivateCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php

declare(strict_types=1);

namespace Pollora\Plugin\UI\Console\Commands;

use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;

/**
* Activate WordPress plugins, with WordPress fully loaded.
*
* Meant to run in its own process: during a first install the process that
* installs WordPress only has it partly loaded (SHORTINIT), which cannot
* activate a plugin and run its activation hook.
*/
#[Description('Activate WordPress plugins')]
#[Signature('pollora:plugin:activate {plugins* : Plugin files relative to the plugins directory, e.g. woocommerce/woocommerce.php}')]
class PluginActivateCommand extends Command
{
public function handle(): int
{
if (! $this->isWordPressFullyLoaded()) {
$this->error('WordPress is not fully loaded: the plugins cannot be activated. Run "wp plugin activate" instead.');

return self::FAILURE;
}

$status = self::SUCCESS;

foreach ((array) $this->argument('plugins') as $plugin) {
$result = $this->activatePlugin((string) $plugin);

if (is_wp_error($result)) {
$this->error(sprintf('Failed to activate "%s": %s', $plugin, $result->get_error_message()));
$status = self::FAILURE;

continue;
}

$this->info(sprintf('Plugin "%s" activated.', $plugin));
}

return $status;
}

protected function isWordPressFullyLoaded(): bool
{
return function_exists('activate_plugin') && did_action('plugins_loaded') > 0;
}

/**
* @return \WP_Error|null
*/
protected function activatePlugin(string $pluginFile): mixed
{
return activate_plugin($pluginFile);
}
}
73 changes: 69 additions & 4 deletions src/Theme/UI/Console/MakeThemeCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ protected function installRequirementsIfNeeded(): void
*/
protected function activateWordPressPlugins(array $packages): void
{
if (! function_exists('activate_plugin')) {
if (! $this->wordPressCanActivatePlugins()) {
return;
}

Expand All @@ -284,6 +284,12 @@ protected function activateWordPressPlugins(array $packages): void
return;
}

if (! did_action('plugins_loaded')) {
$this->activatePluginsInNewProcess($plugins);

return;
}

foreach ($plugins as $slug) {
$pluginFile = $this->findPluginEntryFile($slug);

Expand All @@ -293,7 +299,7 @@ protected function activateWordPressPlugins(array $packages): void
continue;
}

$result = activate_plugin($pluginFile);
$result = $this->activatePlugin($pluginFile);

if (is_wp_error($result)) {
$this->error(sprintf('Failed to activate "%s": %s', $slug, $result->get_error_message()));
Expand All @@ -303,6 +309,65 @@ protected function activateWordPressPlugins(array $packages): void
}
}

protected function wordPressCanActivatePlugins(): bool
{
return function_exists('activate_plugin');
}

/**
* Activate one plugin in this process (WordPress fully loaded).
*
* @return \WP_Error|null
*/
protected function activatePlugin(string $pluginFile): mixed
{
return activate_plugin($pluginFile);
}

/**
* Activate plugins from a fresh process, when WordPress is only partly loaded here.
*
* A first install runs this command with WordPress in SHORTINIT: activate_plugin()
* exists but crashes, and an activation hook would run without plugins_loaded or
* init. A new process loads WordPress fully, now that it is installed.
*
* @param array<string, string> $plugins Map of package name => plugin directory slug.
*/
protected function activatePluginsInNewProcess(array $plugins): void
{
$files = [];

foreach ($plugins as $slug) {
$file = $this->findPluginEntryFile($slug);

if ($file === null) {
$this->warn(sprintf('Could not find entry file for plugin "%s".', $slug));

continue;
}

$files[$slug] = $file;
}

if ($files === []) {
return;
}

$process = new Process([PHP_BINARY, 'artisan', 'pollora:plugin:activate', ...array_values($files)], base_path());
$process->setTimeout(120);
$process->run(function ($type, string|iterable $buffer): void {
$this->getOutput()->write($buffer);
});

if (! $process->isSuccessful()) {
$this->warn('The plugins could not be activated now. Activate them once the installation is done:');

foreach (array_keys($files) as $slug) {
$this->line(sprintf(' wp plugin activate %s', $slug));
}
}
}

/**
* Filter a list of Composer packages to only those of type `wordpress-plugin`.
*
Expand All @@ -313,7 +378,7 @@ protected function activateWordPressPlugins(array $packages): void
* @param array<int, string> $packages Composer package names.
* @return array<string, string> Map of package name => plugin directory slug.
*/
private function resolveWordPressPlugins(array $packages): array
protected function resolveWordPressPlugins(array $packages): array
{
$installedPlugins = InstalledVersions::getInstalledPackagesByType('wordpress-plugin');
$plugins = [];
Expand All @@ -337,7 +402,7 @@ private function resolveWordPressPlugins(array $packages): array
* @param string $slug The plugin directory name (e.g. "woocommerce").
* @return string|null The plugin-relative path (e.g. "woocommerce/woocommerce.php"), or null if not found.
*/
private function findPluginEntryFile(string $slug): ?string
protected function findPluginEntryFile(string $slug): ?string
{
$pluginDir = defined('WP_PLUGIN_DIR')
? WP_PLUGIN_DIR.'/'.$slug
Expand Down
54 changes: 54 additions & 0 deletions tests/Feature/Plugin/PluginActivateCommandTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

declare(strict_types=1);

use Illuminate\Console\OutputStyle;
use Pollora\Plugin\UI\Console\Commands\PluginActivateCommand;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputDefinition;
use Symfony\Component\Console\Output\BufferedOutput;

function runPluginActivate(array $plugins, bool $fullyLoaded): array
{
$command = new class extends PluginActivateCommand
{
public bool $fullyLoaded = true;

/** @var array<int, string> */
public array $activated = [];

protected function isWordPressFullyLoaded(): bool
{
return $this->fullyLoaded;
}

protected function activatePlugin(string $pluginFile): mixed
{
$this->activated[] = $pluginFile;

return null;
}
};
$command->fullyLoaded = $fullyLoaded;
$command->setLaravel(app());

$input = new ArrayInput(['plugins' => $plugins], new InputDefinition([new InputArgument('plugins', InputArgument::IS_ARRAY)]));
$output = new BufferedOutput;
$command->setInput($input);
$command->setOutput(new OutputStyle($input, $output));

return [$command->handle(), $output->fetch(), $command->activated];
}

it('activates the given plugins', function (): void {
[$status, , $activated] = runPluginActivate(['woocommerce/woocommerce.php'], fullyLoaded: true);

expect($status)->toBe(0)->and($activated)->toBe(['woocommerce/woocommerce.php']);
});

it('fails instead of activating when WordPress is not fully loaded', function (): void {
[$status, $output, $activated] = runPluginActivate(['woocommerce/woocommerce.php'], fullyLoaded: false);

expect($status)->toBe(1)->and($activated)->toBe([])->and($output)->toContain('not fully loaded');
});
77 changes: 77 additions & 0 deletions tests/Feature/Theme/MakeThemeActivationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,15 @@
use Illuminate\Console\OutputStyle;
use Illuminate\Container\Container;
use Illuminate\Filesystem\Filesystem;
use Laravel\Prompts\ConfirmPrompt;
use Laravel\Prompts\Prompt;
use Pollora\Theme\Application\Services\ThemeAvailability;
use Pollora\Theme\Domain\Contracts\ThemeModuleInterface;
use Pollora\Theme\Domain\Contracts\ThemeRegistrarInterface;
use Pollora\Theme\Domain\Models\ThemeMetadata;
use Pollora\Theme\UI\Console\MakeThemeCommand;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputDefinition;
use Symfony\Component\Console\Output\BufferedOutput;

/**
Expand Down Expand Up @@ -88,3 +91,77 @@ function activates(MakeThemeCommand $command): bool

expect(activates(activationCommand(['--activate' => true], themeMissing: false)))->toBeFalse();
});

/**
* A first install runs the command with WordPress only partly loaded: activate_plugin() crashed
* there in plugin_basename(), so the activation moves to a fresh process.
*
* @return array{0: array<int, string>, 1: array<int, string>} Plugins activated here, plugins handed to a new process.
*/
function pluginActivation(int $pluginsLoaded): array
{
Functions\when('did_action')->justReturn($pluginsLoaded);
Prompt::fallbackWhen(true);
ConfirmPrompt::fallbackUsing(fn (): bool => true);

$command = new class(new Repository, new Filesystem) extends MakeThemeCommand
{
/** @var array<int, string> */
public array $handedOver = [];

/** @var array<int, string> */
public array $activated = [];

protected function wordPressCanActivatePlugins(): bool
{
return true;
}

protected function activatePlugin(string $pluginFile): mixed
{
$this->activated[] = $pluginFile;

return null;
}

protected function resolveWordPressPlugins(array $packages): array
{
return ['wp-plugin/woocommerce' => 'woocommerce'];
}

protected function findPluginEntryFile(string $slug): string
{
return $slug.'/'.$slug.'.php';
}

protected function activatePluginsInNewProcess(array $plugins): void
{
$this->handedOver = array_values($plugins);
}
};
$command->setLaravel(app());

$input = new ArrayInput([], new InputDefinition);
$input->setInteractive(false);

$command->setInput($input);
$command->setOutput(new OutputStyle($input, new BufferedOutput));

(fn () => $this->activateWordPressPlugins(['wp-plugin/woocommerce']))->call($command);

return [$command->activated, $command->handedOver];
}

it('hands plugin activation to a new process while WordPress is not fully loaded', function (): void {
[$activated, $handedOver] = pluginActivation(pluginsLoaded: 0);

expect($activated)->toBe([])
->and($handedOver)->toBe(['woocommerce']);
});

it('activates plugins in place once WordPress is fully loaded', function (): void {
[$activated, $handedOver] = pluginActivation(pluginsLoaded: 1);

expect($activated)->toBe(['woocommerce/woocommerce.php'])
->and($handedOver)->toBe([]);
});
Loading