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
- ls app/Plugins/<folder> and confirm composer.json sits directly at the folder root (not one level deeper).
- If app/Plugins is empty in a git checkout, run git submodule update --init --recursive.
- Re-extract the plugin zip so composer.json is at app/Plugins/<folder>/composer.json, removing any nested wrapper directory.
- For phar plugins, ensure the file inside is named <folder>.phar (the code appends the folder name inside the phar:// URL).
- 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
- Run git submodule update --init --recursive after cloning so app/Plugins is populated.
- Validate uploaded plugin zips extract with composer.json at the folder root (no wrapper directory).
- Keep the on-disk folder name identical to the name derived from the download filename.
- For phar plugins, name the inner file <folder>.phar to match the expected phar:// layout.
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
- notification.plugin_cant_download
- notification.plugin_cant_remove
- Directory "%s" was not created
- notification.plugin_zip_no_file
- notification.plugin_zip_seek_err
AI-assisted analysis of Leantime/leantime@9a9f49f100 (2026-08-21).
Data as JSON: /api/errors/fc6120ad0a1913bd.
Report an issue: GitHub.