BookStackApp/BookStack · error · ThemeModuleException

Failed to load extract files from module ZIP with error

Error message

Failed to load extract files from module ZIP with error: {$exception->getMessage()}

What it means

ThemeModuleManager::addFromZip wraps failures from $zip->extractTo(): if extraction of a module ZIP throws, the partially extracted folder is removed and a new ThemeModuleException is thrown embedding the original error message. It indicates the ZIP contents could not be written to disk under the themes modules folder.

Solutions

  1. Check storage permissions on the themes modules folder (web-server user needs write access) and retry the upload
  2. Re-download or rebuild the ZIP and verify it opens with unzip -t
  3. Free disk space if the volume is full
  4. Read the embedded {$exception->getMessage()} in the message — it names the underlying cause (e.g. specific entry or path)
  5. Confirm the PHP zip extension is installed and current

Example fix

// before (debugging)
// message: Failed to load extract files from module ZIP with error: ...
// after (server check)
sudo -u www-data test -w themes/modules && echo writable
unzip -t my-module.zip
Defensive patterns

Strategy: try-catch

Validate before calling

$zipPath = $uploadedFile->getRealPath();
$zipArchive = new ZipArchive();
if ($zipArchive->open($zipPath) !== true || $zipArchive->numFiles === 0) {
    throw new InvalidArgumentException('ZIP is corrupt or empty');
}
if (disk_free_space(dirname($modulesFolderPath)) < 10 * 1024 * 1024) {
    throw new RuntimeException('Insufficient disk space');
}
if (!is_writable($modulesFolderPath)) {
    throw new RuntimeException('Modules folder not writable');
}

Type guard

function isUsableZip(string $path): bool {
    $z = new ZipArchive();
    return $z->open($path) === true && $z->numFiles > 0;
}

Try / catch

try {
    $module = $manager->addFromZip($name, $zip);
} catch (ThemeModuleException $e) {
    Log::error('Module ZIP extraction failed', ['reason' => $e->getMessage()]);
    // surface $e->getMessage() to the uploader; cleanup already handled by manager
}

Prevention

When it happens

Trigger: Installing a module via ThemeModuleZip::extractTo where the zip is corrupt/encrypted, an entry name is unsafe, the target path isn't writable, or disk space is exhausted. Raised through the 'handle' upload flow.

Common situations: Truncated or re-saved ZIP downloads; read-only storage directory after deployment/permission changes; full disk; malicious or malformed zip entries rejected by the extractor; PHP zip extension limitations.

Related errors


AI-assisted analysis of BookStackApp/BookStack@18f8469a1c (2026-09-02). Data as JSON: /api/errors/15a67c944ab74bb3. Report an issue: GitHub.

Appendix: source

Thrown at app/Theming/ThemeModuleManager.php:60

    /**
     * @throws ThemeModuleException
     */
    public function addFromZip(string $name, ThemeModuleZip $zip): ThemeModule
    {
        $baseFolderName = Str::limit(Str::slug($name), 40, '');
        $folderName = $baseFolderName;
        while (!$baseFolderName || file_exists($this->modulesFolderPath . DIRECTORY_SEPARATOR . $folderName)) {
            $folderName = ($baseFolderName ?: 'mod') . '-' . Str::random(4);
        }

        $folderPath = $this->modulesFolderPath . DIRECTORY_SEPARATOR . $folderName;
        try {
            $zip->extractTo($folderPath);
        } catch (ThemeModuleException $exception) {
            if (is_dir($folderPath)) {
                $this->deleteDirectoryRecursively($folderPath);
            }
            throw new ThemeModuleException("Failed to load extract files from module ZIP with error: {$exception->getMessage()}");
        }

        $module = $this->loadFromFolder($folderName);
        if (!$module) {
            throw new ThemeModuleException("Failed to load module from zip file after extraction");
        }

        return $module;
    }

    protected function deleteDirectoryRecursively(string $path): void
    {
        $items = array_diff(scandir($path), ['.', '..']);
        foreach ($items as $item) {
            $itemPath = $path . DIRECTORY_SEPARATOR . $item;
            if (is_dir($itemPath)) {
                $this->deleteDirectoryRecursively($itemPath);
            } else {

View on GitHub (pinned to 18f8469a1c)