Leantime/leantime · error · Exception

notifications.plugin_install_cant_find_composer

Error message

notifications.plugin_install_cant_find_composer

What it means

Plugins::createPluginFromComposer() builds an InstalledPlugin from a plugin on disk. It looks for composer.json in two places only: app/Plugins/{folder}/composer.json (folder format) or phar://app/Plugins/{folder}/{folder}.phar/composer.json (phar format - note the folder name is repeated inside the phar:// URL). When neither path exists it throws the translated 'Could not find composer.json' error. Every Leantime plugin, folder-based or marketplace phar, must carry a composer.json with name, description, and version at one of those exact locations.

Source

Thrown at app/Domain/Plugins/Services/Plugins.php:267

                    return null;
                }
            })
            ->filter()->all();

        return $newPlugins;
    }

    public function createPluginFromComposer(string $pluginFolder, string $license_key = ''): InstalledPlugin
    {
        $pluginPath = Str::finish($this->pluginDirectory, DIRECTORY_SEPARATOR).Str::finish($pluginFolder, DIRECTORY_SEPARATOR);

        if (file_exists($composerPath = $pluginPath.'composer.json')) {
            $format = 'folder';
        } elseif (file_exists($composerPath = "phar://{$pluginPath}{$pluginFolder}.phar".DIRECTORY_SEPARATOR.'composer.json')) {
            $format = 'phar';
        } else {
            throw new \Exception(__('notifications.plugin_install_cant_find_composer'));
        }

        $json = file_get_contents($composerPath);
        $pluginFile = json_decode($json, true);

        $plugin = build(new InstalledPlugin)
            ->set('name', $pluginFile['name'])
            ->set('enabled', 0)
            ->set('description', $pluginFile['description'])
            ->set('version', $pluginFile['version'])
            ->set('installdate', date('y-m-d'))
            ->set('foldername', $pluginFolder)
            ->set('license', $license_key)
            ->set('format', $format)
            ->set('homepage', $pluginFile['homepage'])
            ->set('authors', json_encode($pluginFile['authors']))
            ->get();

View on GitHub (pinned to 9a9f49f100)

Solutions

  1. ls app/Plugins/<folder> and confirm composer.json sits directly at the folder root (not one level deeper).
  2. If app/Plugins is empty in a git checkout, run git submodule update --init --recursive.
  3. Re-extract the plugin zip so composer.json is at app/Plugins/<folder>/composer.json, removing any nested wrapper directory.
  4. For phar plugins, ensure the file inside is named <folder>.phar (the code appends the folder name inside the phar:// URL).
  5. Verify the folder name string passed in matches the on-disk directory exactly, including casing.

Example fix

; before: extraction produced a nested wrapper - lookup fails
app/Plugins/
└── MyPlugin/
    └── MyPlugin/
        └── composer.json

; after: composer.json at the root of the folder the foldername points at
app/Plugins/
└── MyPlugin/
    └── composer.json
Defensive patterns

Strategy: validation

Validate before calling

$base = rtrim(app/Plugins path, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.$folder.DIRECTORY_SEPARATOR;
$folderOk = is_file($base.'composer.json');
$pharOk = is_file("phar://{$base}{$folder}.phar".DIRECTORY_SEPARATOR.'composer.json');
if (! $folderOk && ! $pharOk) {
    throw new InvalidArgumentException("Not a valid plugin folder '{$folder}': no composer.json in folder or phar layout");
}

Type guard

/** True when $folder is an installable plugin (composer.json in folder or phar layout). */
function isValidPluginFolder(string $pluginsDir, string $folder): bool
{
    $base = rtrim($pluginsDir, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.$folder.DIRECTORY_SEPARATOR;

    return is_file($base.'composer.json')
        || is_file("phar://{$base}{$folder}.phar/composer.json");
}

Try / catch

try {
    $plugin = $plugins->createPluginFromComposer($folder);
} catch (\Exception $e) {
    // message is the localized 'Could not find composer.json'
    Log::warning("Plugin install aborted for '{$folder}': {$e->getMessage()}");
    return back()->with('error', $e->getMessage());
}

Prevention

When it happens

Trigger: Calling createPluginFromComposer($folder): (1) the folder does not exist under app/Plugins at all; (2) a zip was extracted with an extra nesting level (MyPlugin/MyPlugin/composer.json); (3) a phar plugin whose inner file is not named {folder}.phar; (4) app/Plugins is the private git submodule and was never initialized, so the tree is empty; (5) case mismatch between the Studly-cased folder name generated from the download filename and the actual directory.

Common situations: Fresh clones of the OSS repo where app/Plugins is an uninitialized submodule ('git submodule update --init' missing); manual plugin uploads where the zip contains a wrapper directory; marketplace installs whose Content-Disposition filename produced a foldername that differs from the phar's internal name; renaming plugin folders by hand after install.

Related errors


AI-assisted analysis of Leantime/leantime@9a9f49f100 (2026-08-21). Data as JSON: /api/errors/fc6120ad0a1913bd. Report an issue: GitHub.