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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

## [1.2.0] - 2026-07-04

### Added
- Installing a plugin now installs the plugin's own declared dependencies into the host application, so packages the plugin relies on are available at runtime instead of failing with class-not-found
- Local directory paths are a supported install source: `plugin:install ./path/to/plugin` copies the plugin into place, alongside the existing package-name and repository-URL sources
- Dependency installation integrates with `wikimedia/composer-merge-plugin` when the host enables it, and otherwise installs the plugin's requirements directly

## [1.1.0] - 2026-06-07

### Added
Expand Down
29 changes: 28 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,38 @@ independent of the application's logging:
- `plugin:info {id}` - Show information about a plugin
- `plugin:enable {id}` - Enable a plugin
- `plugin:disable {id}` - Disable a plugin
- `plugin:install {package}` - Install a plugin
- `plugin:install {package}` - Install a plugin from a Composer package, a repository URL, or a local path
- `plugin:discover` - Discover and register all available plugins
- `plugin:cache` - Compile discovered plugins into a cache file
- `plugin:clear` - Remove the plugin cache file

### Plugin dependencies

When a plugin is installed from a URL or a local path, the engine reads the
plugin's own `composer.json` and installs its `require` dependencies into the
host application, so the plugin's classes are available at runtime.

For hosts that embed several plugins, enable
[`wikimedia/composer-merge-plugin`](https://github.com/wikimedia/composer-merge-plugin)
so each plugin keeps owning its dependencies while Composer resolves them
together into the single host `vendor/`:

```jsonc
// host composer.json
"extra": {
"merge-plugin": {
"include": ["plugins/*/composer.json"]
}
},
"config": {
"allow-plugins": { "wikimedia/composer-merge-plugin": true }
}
```

With this configured, installing a plugin runs a targeted `composer update`;
otherwise the engine falls back to `composer require` for the plugin's
dependencies.

### Caching

By default, plugins are discovered by scanning the plugins directory and
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"description": "A flexible plugin engine for Laravel applications",
"type": "library",
"license": "proprietary",
"package-version": "1.1.0",
"version": "1.2.0",
"autoload": {
"psr-4": {
"WhileSmart\\LaravelPluginEngine\\": "src/"
Expand Down
2 changes: 1 addition & 1 deletion composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

188 changes: 180 additions & 8 deletions src/Console/Commands/InstallCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@

namespace WhileSmart\LaravelPluginEngine\Console\Commands;

use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use Symfony\Component\Process\Process;
use WhileSmart\LaravelPluginEngine\Services\ComposerRunner;
use WhileSmart\LaravelPluginEngine\Services\PluginManager;

class InstallCommand extends PluginCommand
{
protected $signature = 'plugin:install
{package : The package name (vendor/name) or URL of the plugin to install}
{package : The package name (vendor/name), URL, or local path of the plugin to install}
{--dev : Install development dependencies}'
.'{--no-dev : Do not install development dependencies}'
.'{--no-scripts : Skip running installation scripts}'
Expand All @@ -22,19 +25,29 @@ class InstallCommand extends PluginCommand

protected $description = 'Install a plugin';

public function __construct(PluginManager $pluginManager, protected ComposerRunner $composer)
{
parent::__construct($pluginManager);
}

public function handle()
{
$package = $this->argument('package');

try {
$this->info("Installing plugin: {$package}");

// Check if it's a URL or a package name
// A remote repository URL: clone it, then install from the clone.
if (filter_var($package, FILTER_VALIDATE_URL)) {
return $this->installFromUrl($package);
}

// Otherwise, treat as a Composer package
// A local directory holding the plugin source: copy it into place.
if (is_dir($package)) {
return $this->installFromLocalPath($package);
}

// Otherwise, treat as a Composer package.
return $this->installWithComposer($package);

} catch (\Exception $e) {
Expand Down Expand Up @@ -72,7 +85,7 @@ protected function installFromUrl(string $url): int
throw new \RuntimeException($process->getErrorOutput());
}

// Install the plugin
// Move the clone into the plugins directory and finish the install.
return $this->installFromPath($tempDir, $pluginName);

} finally {
Expand All @@ -81,6 +94,21 @@ protected function installFromUrl(string $url): int
}
}

protected function installFromLocalPath(string $path): int
{
$path = rtrim($path, '/');
$pluginName = basename($path);

$this->info("Installing plugin from path: {$path}");

$targetPath = $this->resolveTargetPath($pluginName);

// Copy rather than move, so the user's source directory is left intact.
File::copyDirectory($path, $targetPath);

return $this->finalizeInstall($targetPath);
}

protected function installWithComposer(string $package): int
{
$this->info("Installing plugin using Composer: {$package}");
Expand All @@ -106,31 +134,175 @@ protected function installWithComposer(string $package): int
}

protected function installFromPath(string $path, string $pluginName): int
{
$targetPath = $this->resolveTargetPath($pluginName);

// Move the plugin to the plugins directory
rename($path, $targetPath);

return $this->finalizeInstall($targetPath);
}

/**
* Resolve and validate the destination directory for a plugin, creating
* the plugins directory when it does not yet exist.
*/
protected function resolveTargetPath(string $pluginName): string
{
$pluginsPath = config('plugins.path', base_path('plugins'));
$targetPath = rtrim($pluginsPath, '/').'/'.$pluginName;

// Create plugins directory if it doesn't exist
if (! is_dir($pluginsPath)) {
mkdir($pluginsPath, 0755, true);
}

// Check if plugin already exists
if (is_dir($targetPath)) {
throw new \RuntimeException("Plugin directory already exists: {$targetPath}");
}

// Move the plugin to the plugins directory
rename($path, $targetPath);
return $targetPath;
}

/**
* Shared tail of every non-Composer install: install the plugin's own
* dependencies, then discover it.
*/
protected function finalizeInstall(string $targetPath): int
{
$this->info("Plugin installed successfully to: {$targetPath}");

$this->installPluginDependencies($targetPath);

// Run plugin discovery
$this->call('plugin:discover');

return 0;
}

/**
* Install the dependencies a plugin declares in its own composer.json.
*
* The webservice loads a plugin by reading plugin.json and PSR-4
* registering its namespace; it never reads the plugin's composer.json.
* So without this step the plugin's `require` is never resolved and its
* classes are missing at runtime.
*/
protected function installPluginDependencies(string $targetPath): void
{
$composerFile = $targetPath.'/composer.json';

if (! is_file($composerFile)) {
return;
}

$manifest = json_decode((string) file_get_contents($composerFile), true);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When decoding JSON, you should check if json_decode returned null and if a JSON error occurred to handle malformed files gracefully.

Suggested change
$manifest = json_decode((string) file_get_contents($composerFile), true);
$manifest = json_decode((string) file_get_contents($composerFile), true);
if (json_last_error() !== JSON_ERROR_NONE || ! is_array($manifest)) {


if (! is_array($manifest)) {
$this->warn('Plugin composer.json is not valid JSON; skipping dependency install.');

return;
}

$packages = $this->resolvablePackages($manifest['require'] ?? []);

if (empty($packages)) {
return;
}

// Register any repositories the plugin declares (VCS, path, ...) so its
// requires can be resolved against them.
foreach (($manifest['repositories'] ?? []) as $name => $repository) {
$repoName = is_string($name) ? $name : 'plugin-'.substr(md5(json_encode($repository)), 0, 8);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using json_encode without JSON_THROW_ON_ERROR can return false on failure. It is safer to ensure it succeeds before using it in a hash or command line.

Suggested change
$repoName = is_string($name) ? $name : 'plugin-'.substr(md5(json_encode($repository)), 0, 8);
$repoJson = json_encode($repository, JSON_THROW_ON_ERROR);
$repoName = is_string($name) ? $name : 'plugin-'.substr(md5($repoJson), 0, 8);
$this->composer->run(
['composer', 'config', '--no-interaction', 'repositories.'.$repoName, $repoJson],


$this->composer->run(
['composer', 'config', '--no-interaction', 'repositories.'.$repoName, json_encode($repository)],
base_path()
);
}

// When the host merges plugin composer.json files (the wikimedia
// composer-merge-plugin pattern), the plugin's `require` is already
// part of the resolved set, so a targeted `composer update` installs
// it while leaving ownership with the plugin. Otherwise fall back to
// `composer require`, which resolves the packages against the host's
// existing constraints and records them in the root composer.json.
$verb = $this->hostMergesPluginManifests() ? 'update' : 'require';

$command = array_merge(
['composer', $verb, '--no-interaction'],
$packages,
$this->getComposerOptions()
);

$this->info('Installing plugin dependencies: '.implode(', ', $packages));

$ok = $this->composer->run($command, base_path(), function ($type, $buffer) {
$this->output->write($buffer);
});

if (! $ok) {
throw new \RuntimeException('Failed to install plugin dependencies.');
}
}

/**
* Reduce a composer `require` map to the real packages to install,
* dropping platform constraints (php, ext-*, lib-*) that Composer
* checks but never downloads.
*
* @param array<string, string> $require
* @return array<int, string>
*/
protected function resolvablePackages(array $require): array
{
$packages = [];

foreach ($require as $name => $constraint) {
if ($name === 'php' || Str::startsWith($name, ['ext-', 'lib-'])) {
continue;
}

$packages[] = $name.':'.$constraint;
}

return $packages;
}

/**
* Whether the host application merges plugin composer.json files into its
* own dependency resolution (wikimedia/composer-merge-plugin configured to
* include the plugins path).
*/
protected function hostMergesPluginManifests(): bool
{
$rootComposer = base_path('composer.json');

if (! is_file($rootComposer)) {
return false;
}

$config = json_decode((string) file_get_contents($rootComposer), true);
$includes = $config['extra']['merge-plugin']['include'] ?? [];
$includes = is_array($includes) ? $includes : [$includes];

if (empty($includes)) {
return false;
}

$pluginsPath = config('plugins.path', base_path('plugins'));
$relative = trim(str_replace('\\', '/', Str::after($pluginsPath, base_path())), '/');

foreach ($includes as $pattern) {
$normalizedPattern = ltrim(str_replace('\\', '/', $pattern), './');

if ($relative !== '' && Str::startsWith($normalizedPattern, $relative)) {
return true;
}
}

return false;
}

protected function getComposerOptions(): array
{
$options = [];
Expand Down
4 changes: 4 additions & 0 deletions src/Providers/PluginServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@
use WhileSmart\LaravelPluginEngine\Console\Commands\InstallCommand;
use WhileSmart\LaravelPluginEngine\Console\Commands\ListCommand;
use WhileSmart\LaravelPluginEngine\Logging\LevelFilteringLogger;
use WhileSmart\LaravelPluginEngine\Services\ComposerRunner;
use WhileSmart\LaravelPluginEngine\Services\PluginManager;
use WhileSmart\LaravelPluginEngine\Services\SymfonyComposerRunner;

class PluginServiceProvider extends ServiceProvider
{
Expand Down Expand Up @@ -47,6 +49,8 @@ public function register(): void

$this->registerPluginManager();
$this->registerCommands();

$this->app->bind(ComposerRunner::class, SymfonyComposerRunner::class);
}

/**
Expand Down
19 changes: 19 additions & 0 deletions src/Services/ComposerRunner.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

namespace WhileSmart\LaravelPluginEngine\Services;

/**
* Runs Composer commands for the plugin engine. Abstracted behind an
* interface so installs can be exercised in tests without shelling out.
*/
interface ComposerRunner
{
/**
* Run a Composer command in the given working directory.
*
* @param array<int, string> $command Full command, e.g. ['composer', 'require', 'vendor/pkg:^1.0'].
* @param callable|null $onOutput Optional output handler receiving (string $type, string $buffer).
* @return bool Whether the command succeeded.
*/
public function run(array $command, string $workingDirectory, ?callable $onOutput = null): bool;
}
17 changes: 17 additions & 0 deletions src/Services/SymfonyComposerRunner.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

namespace WhileSmart\LaravelPluginEngine\Services;

use Symfony\Component\Process\Process;

class SymfonyComposerRunner implements ComposerRunner
{
public function run(array $command, string $workingDirectory, ?callable $onOutput = null): bool
{
$process = new Process($command, $workingDirectory, null, null, 300);

$process->run($onOutput);

return $process->isSuccessful();
}
}
Loading
Loading