Leantime/leantime · error · Exception

notification.plugin_cant_remove

Error message

notification.plugin_cant_remove

What it means

During marketplace plugin install/update, the code first removes an existing installation: if app/Plugins/{Folder} is a directory and Laravel's File::deleteDirectory() returns false, it throws the translated 'Could not remove existing plugin'. The marketplace download already succeeded at this point; the failure is purely a filesystem deletion failure on the previous plugin version's directory.

Source

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

        $filename = $response->header('Content-Disposition');
        $filename = substr($filename, strpos($filename, 'filename=') + 9);
        $foldername = Str::studly(basename($filename, '.zip'));
        $filename = Str::finish($foldername, '.zip');

        if (
            ! file_put_contents(
                $temporaryFile = Str::finish(sys_get_temp_dir(), '/').$filename,
                $response->body()
            )
        ) {
            throw new \Exception(__('notification.plugin_cant_download'));
        }

        if (
            is_dir($pluginDir = "{$this->pluginDirectory}{$foldername}")
            && ! File::deleteDirectory($pluginDir)
        ) {
            throw new \Exception(__('notification.plugin_cant_remove'));
        }

        if (! mkdir($pluginDir) && ! is_dir($pluginDir)) {
            throw new \RuntimeException(sprintf('Directory "%s" was not created', $pluginDir));
        }

        $zip = new \ZipArchive;

        match ($zip->open($temporaryFile)) {
            \ZipArchive::ER_EXISTS => throw new \Exception(__('notification.plugin_zip_exists')),
            \ZipArchive::ER_INCONS => throw new \Exception(__('notification.plugin_zip_inconsistent')),
            \ZipArchive::ER_INVAL => throw new \Exception(__('notification.plugin_zip_invalid_arg')),
            \ZipArchive::ER_MEMORY => throw new \Exception(__('notification.plugin_zip_malloc')),
            \ZipArchive::ER_NOENT => throw new \Exception(__('notification.plugin_zip_no_file')),
            \ZipArchive::ER_NOZIP => throw new \Exception(__('notification.plugin_zip_not_zip')),
            \ZipArchive::ER_OPEN => throw new \Exception(__('notification.plugin_zip_cant_open')),
            \ZipArchive::ER_READ => throw new \Exception(__('notification.plugin_zip_read_err')),
            \ZipArchive::ER_SEEK => throw new \Exception(__('notification.plugin_zip_seek_err')),

View on GitHub (pinned to 9a9f49f100)

Solutions

  1. Fix ownership: chown -R <webuser>: app/Plugins/<Folder> (and the parent), ensure the directory itself is writable (chmod u+w).
  2. Remove the stale directory manually: rm -rf app/Plugins/<Folder>, then retry the install from the marketplace UI.
  3. Ensure the app/Plugins mount is writable (remove ':ro' from the docker volume).
  4. Disable the plugin in Leantime before updating so no process holds files open.
Defensive patterns

Strategy: validation

Validate before calling

// before updating, confirm we can actually remove the previous install
$dir = $pluginDirectory.$foldername;
if (is_dir($dir) && ! is_writable($dir) && ! chmod($dir, 0755)) {
    throw new RuntimeException("Cannot remove previous install at {$dir}: fix ownership first");
}

Try / catch

try {
    $plugins->installMarketplacePlugin($plugin, $version);
} catch (\Exception $e) {
    if (str_contains($e->getMessage(), 'remove')) {
        // previous install is stuck: surface an actionable ops message
        return back()->with('error', "Remove app/Plugins/{$plugin->identifier} manually (permissions), then retry");
    }
    throw $e;
}

Prevention

When it happens

Trigger: installMarketplacePlugin() for a plugin whose folder already exists under app/Plugins AND deletion fails: files owned by a different user than the PHP process (e.g. created by root, web server runs as www-data); the app/Plugins volume mounted read-only in a container; restrictive directory permissions (directory not writable, so child files cannot be unlinked); an NFS/bind mount with broken delete semantics.

Common situations: Plugin folders created by a CLI/artisan run as root, then updated from the web UI; docker-compose volume for app/Plugins mounted ':ro'; permissions tightened after initial install.

Related errors


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