Managing Mods

Managing Mods

Complete guide to the Installed tab — viewing, filtering, updating, enabling/disabling, uninstalling, upload detection, ZIP export, and every error that can occur.


Installed Mods List

Two sources of mods:

  1. Browser-installed — Full tracking (provider, version, mod ID, download URL, update detection)
  2. Manually uploaded — Auto-detected by scanner, shown as "Manual / Upload" with limited tracking

Columns

Column Description
Icon Platform logo (blank for uploads)
Name From JAR metadata or filename (version regex stripped)
Status Active, Disabled, or Unknown
Provider Modrinth, CurseForge, Modtale, or Upload
Version Installed version number
Installed Date First installation timestamp
Latest Version Newest available on platform (if tracked)

Upload Detection

scanAndRegisterUploadedMods() runs automatically:

  • Compares files on disk (/mods/) with database records
  • New files: Registered with provider 'upload'. JAR opened to extract name from plugin.yml / fabric.mod.json / META-INF/mods.toml. Version parsed from filename via regex -(\d+\.\d+(?:\.\d+)?)
  • Deleted files: Removed from database (orphan cleanup)
  • Concurrent protection: Atomic lock with 10-second TTL
  • Limitations: Upload mods have no auto-update detection

Filtering

Filter Shows
Show All All mods
Active Enabled mods only
Disabled Disabled mods (with -bak suffix)
Available Updates Mods with newer platform versions

Mods with updates float to top, then alphabetical.


Updating Mods

How Updates Are Detected

  • Plugin queries source platform using stored mod_id and provider
  • Update metadata cached 24 hours per mod
  • 'upload' provider always skipped
  • Version comparison uses string comparison (not semantic versioning)

When Update Found

  1. "Update Available" badge in Installed tab
  2. Console page widget: "Mod Updates Available — Update Now"
  3. Latest Version column shows new version

Performing Update

  1. Click Update next to the mod
  2. New version downloaded from platform
  3. Old version auto-removed (if enabled)
  4. Notification: "Mod installation started"
  5. Restart server to load update

Console Widget (PluginsButtonWidget)

  • Syncs plugins on load
  • Update check with 15-minute cache
  • Warning badge with redirect URL when updates available

Enable / Disable Mods

Mechanism Detail

Uses file rename with -bak suffix via read+write+delete pattern (for Daemon API compatibility):

Action Steps Example
Disable 1. Read file content 2. Write to {name}-bak 3. Delete original sodium-1.0.jarsodium-1.0.jar-bak
Enable 1. Read -bak file 2. Write to original name 3. Delete -bak sodium-1.0.jar-baksodium-1.0.jar

Note: Three daemon API calls per toggle operation. Server may need to be stopped if file is locked.

Notifications

Action Success Failure
Disable "Mod disabled: [name]" "Disable failed" + error body
Enable "Mod enabled: [name]" "Enable failed" + error body

Use Cases

  • Crash diagnosis: Disable mods one-by-one to isolate crashes
  • Performance testing: Measure with/without specific mods
  • Conflict detection: Disable suspected conflicting mod
  • Pre-staging: Install disabled, enable all during maintenance

Uninstalling Mods

Process

  1. Click Uninstall → confirm warning
  2. JAR file deleted from /mods/
  3. Database record removed
  4. Notification: "Mod uninstalled"
  5. Restart server to fully unload

What Is NOT Removed

Not Removed Example Cleanup
Config files config/sodium.json File manager
World data Modified chunks/entities Cannot be reverted
Server-side data SQLite/JSON storage File manager
Log entries Mod output in logs Auto-rotated

Open Mods Folder

Jumps to /mods/ in the file manager. Useful for:

  • Verifying actual files on disk
  • Manual uploads
  • Cleaning config dirs after uninstall
  • Checking -bak extensions on disabled mods
  • Finding duplicate/conflicting files

ZIP Export

Process

  1. Click Download All as ZIP
  2. Notification: "ZIP download started"
  3. Server compresses all files in /mods/
  4. JWT-signed URL generated (expires in 30 minutes)
  5. Download begins automatically

Includes: All mods (active + disabled)

If Export Fails

  • Notification: "ZIP download failed"
  • Check temporary disk space
  • Large collections (100+ mods) may time out
  • Daemon must support file compression

Complete Error Reference

"Failed to read installed mods"

  • /mods/ directory doesn't exist → start server once
  • Directory not readable → check daemon permissions
  • Disk full → free space
  • Daemon connection interrupted

"Mod could not be disabled" / "Mod could not be enabled"

  • Permissions: Read + write + delete needed on /mods/
  • File locked: Running server locks files → stop server first
  • Concurrent access: Backup/file manager accessing file
  • File not found: Renamed or deleted externally
  • Daemon error: Any of 3 steps (read/write/delete) can fail

"Failed to delete old versions"

  • Old file locked by running server → stop server
  • File permissions issue
  • File already removed manually
  • Check storage/logs/laravel.log

Updates Not Showing

  • Update metadata cached 24 hours → click Clear Cache
  • 'upload' mods are always skipped
  • Version comparison is string-based, so unusual formats may not compare correctly
  • Platform may not have propagated the update yet

"Uninstall failed"

  • File already manually deleted
  • File locked by server process → stop and retry
  • Daemon connection issue
  • Check logs for specific error

Mod Crashes Server After Installation

  1. Check console crash report — names the problematic mod
  2. Disable (don't delete!) the mod + restart to confirm
  3. Verify dependencies are installed
  4. Check mod loader compatibility (Fabric on Forge = crash)
  5. Try older stable version
  6. Check Java version (17 for MC 1.18+, 21 for MC 1.21+)
  7. Check mod's issue tracker on platform

"Cache clear error"

  • Error appears in notification body with $e->getMessage()
  • SafeCacheService flush() failed to remove one or more keys
  • Check storage/framework/cache/ exists and is writable (0755)
  • Log: "Error clearing cache and scanning mods"

Provider Shows Wrong After Re-Install

  • If you uninstall and re-install from different platform, tracking updates
  • If you manually upload a previously browser-installed mod, scanner may match old record by filename
  • Solution: Uninstall old record first, then install fresh or let scanner register new 'upload' entry

Provider Internal Functions Reference

This section documents the internal PHP code that powers each provider integration. Understanding these patterns helps diagnose issues and explains how mods are searched, resolved, and installed.

CurseForge Provider — Complete Reference

API Endpoint Parameters — GET /v1/mods/search

Parameter Type Required Default Description
gameId int Yes Game identifier (432 = Minecraft, 70216 = Hytale)
classId int Yes 9137 Content class (9137 = Mods, 5 = Plugins, 4471 = Modpacks)
searchFilter string No Full-text search query
sortField int No 2 Sort field ID (1–12, see sort table above)
sortOrder string No desc asc or desc
modLoaderType int No Loader enum (1=Forge, 4=Fabric, 5=Quilt, 6=NeoForge)
gameVersion string No Minecraft version filter (e.g., 1.20.1)
pageSize int No 20 Results per page (max 50)
index int No 0 Offset for pagination

Response Schema — Search Results

Field Type Description
data[].id int Unique CurseForge mod ID
data[].name string Mod display name
data[].slug string URL-safe slug for mod page link
data[].summary string Short description
data[].downloadCount int Total download count
data[].logo.url string Mod icon URL
data[].categories[].name string Category labels
data[].latestFilesIndexes[].gameVersion string Minecraft version
data[].latestFilesIndexes[].fileId int Latest file for that version
data[].dateModified string ISO 8601 timestamp of last update
data[].authors[].name string Author display name
pagination.totalCount int Total results (max 10,000)
pagination.index int Current page offset

Rate Limits

Tier Limit Notes
Free key (default) ~1,000 req/hour Sufficient for normal usage
When exceeded HTTP 429 Retry after Retry-After header seconds
// Search request construction — the full internal flow
$response = Http::withHeaders(['x-api-key' => $apiKey])
    ->timeout($timeout)
    ->get('https://api.curseforge.com/v1/mods/search', [
        'gameId'        => $gameId,    // 432 for Minecraft, 70216 for Hytale
        'classId'       => 9137,       // Mods class (NOT 5=Plugins, NOT 4471=Modpacks!)
        'searchFilter'  => $query,
        'sortField'     => $query ? 1 : 2,  // Featured when searching, Popularity when browsing
        'sortOrder'     => 'desc',
        'modLoaderType' => $loaderEnum,
        'gameVersion'   => $mcVersion,
        'pageSize'      => $perPage,
        'index'         => ($page - 1) * $perPage,
    ]);
// Response processing — how results are transformed into the UI
$results = $response->json('data', []);
$mods = collect($results)->map(function ($mod) {
    // Extract supported MC versions from gameVersions using regex
    $versions = collect($mod['latestFilesIndexes'] ?? [])
        ->pluck('gameVersion')
        ->filter(fn ($v) => preg_match('/^\d+\.\d+/', $v))
        ->unique()->sort()->values();

    return [
        'id'            => $mod['id'],
        'name'          => $mod['name'],
        'slug'          => $mod['slug'],
        'summary'       => $mod['summary'],
        'author'        => $mod['authors'][0]['name'] ?? 'Unknown',
        'downloads'     => $mod['downloadCount'],
        'icon_url'      => $mod['logo']['url'] ?? null,
        'updated_at'    => $mod['dateModified'],
        'versions'      => $versions->toArray(),
        'provider'      => 'curseforge',
    ];
});
// Dependency resolution — recursive with relationType check
foreach ($file['dependencies'] as $dep) {
    if ($dep['relationType'] === 3) {  // 3 = Required dependency
        $depMod = $this->fetchMod($dep['modId']);
        $this->installMod($depMod);  // Recursive — resolves sub-dependencies too
    }
    // relationType 1 = EmbeddedLibrary (skip)
    // relationType 2 = OptionalDependency (skip)
    // relationType 4 = Tool (skip)
    // relationType 5 = Incompatible (skip)
    // relationType 6 = Include (skip)
}
// Manual download detection — CurseForge author restriction
if (empty($file['downloadUrl'])) {
    $mod['requires_manual_download'] = true;
    // User sees "Download on CurseForge" link instead of install button
    // This is a CurseForge author policy, not a plugin bug
}

CurseForge Provider Error Map

Error / Log Message Cause Impact Solution
"CurseForge API key not set" No key in settings or env CurseForge tab hidden Set CURSEFORGE_API_KEY or enter in plugin settings
HTTP 401 Unauthorized Key expired or revoked All CurseForge requests fail Regenerate at console.curseforge.com
HTTP 403 Forbidden Key lacks required scope Search works but actions may fail Create new key with full permissions
HTTP 429 Too Many Requests Rate limit exceeded Requests blocked temporarily Wait 60s, increase cache TTL, reduce frequency
Empty downloadUrl in response Author restricted direct download Cannot auto-install User must download from CurseForge website manually
"CurseForge request failed" Network timeout or DNS failure Provider unavailable Check firewall rules for api.curseforge.com
Dependency loop detected Circular relationType=3 chain Install hangs or crashes Report on mod page — this is a mod metadata error
Wrong classId results Searching mods but getting plugins Wrong content type displayed Verify classId=9137 for mods (not 5 or 4471)
gameVersions empty array Mod has no version-tagged files Version filter returns nothing Clear version filter, browse all

Modrinth Provider — Complete Reference

API Endpoint Parameters — GET /v2/search

Parameter Type Required Default Description
query string No Full-text search terms
facets string (JSON) No Filter array (see facets below)
limit int No 20 Results per page (max 100)
offset int No 0 Pagination offset
index string No relevance Sort: relevance, downloads, follows, newest, updated

Facets Structure

Facets are a JSON array of arrays. Inner arrays are OR-combined, outer arrays are AND-combined:

// How the plugin builds facets internally
$facets = [
    ['project_type:mod'],                  // MUST be a mod
    ['server_side:required', 'server_side:optional'],  // Server-compatible
];

// Optional facets added conditionally:
if ($mcVersion) {
    $facets[] = ["versions:$mcVersion"];   // Must support this MC version
}
if ($loader && !in_array($loader, ['all', 'any'])) {
    $facets[] = ["categories:$loader"];    // Must support this loader
}

// ⚠ 'all' and 'any' are excluded because Modrinth doesn't accept them as facets
$params['facets'] = json_encode($facets);

Response Schema — Search Results

Field Type Description
hits[].project_id string Unique Modrinth project ID (e.g., AANobbMI)
hits[].slug string URL-safe project slug
hits[].title string Project display name
hits[].description string Short description
hits[].downloads int Total download count
hits[].icon_url string Project icon URL
hits[].author string Author username
hits[].date_modified string ISO 8601 last update
hits[].versions string[] Supported Minecraft versions
hits[].categories string[] Tags: fabric, forge, quilt, etc.
total_hits int Total matching results
limit int Page size used
offset int Current offset

Rate Limits

Tier Limit Notes
Unauthenticated 300 req/minute per IP Plugin uses unauthenticated by default
With PAT token Higher limits Not used by plugin
When exceeded HTTP 429 Retry after ratelimit-reset header
// Response processing — how Modrinth results are mapped
$hits = $response->json('hits', []);
$mods = collect($hits)->map(function ($hit) {
    return [
        'id'            => $hit['project_id'],
        'name'          => $hit['title'],
        'slug'          => $hit['slug'],
        'summary'       => $hit['description'],
        'author'        => $hit['author'],
        'downloads'     => $hit['downloads'],
        'icon_url'      => $hit['icon_url'] ?? null,
        'updated_at'    => $hit['date_modified'],
        'versions'      => $hit['versions'] ?? [],
        'provider'      => 'modrinth',
    ];
});
// Version fetching — with loader and version filtering
$versions = Http::get("https://api.modrinth.com/v2/project/{$projectId}/version", [
    'loaders'       => json_encode([$loader]),      // e.g., ["fabric"]
    'game_versions' => json_encode([$mcVersion]),   // e.g., ["1.20.1"]
])->json();

// Each version contains:
// - id, version_number, name, date_published
// - files[0].url = direct CDN download link
// - files[0].hashes.sha1 = integrity hash
// - dependencies[] = required/optional deps
// Dependency resolution — with two strategies
foreach ($version['dependencies'] as $dep) {
    if ($dep['dependency_type'] === 'required') {
        if (!empty($dep['version_id'])) {
            // Strategy 1: Exact version specified
            $depVersion = Http::get("https://api.modrinth.com/v2/version/{$dep['version_id']}")->json();
        } else {
            // Strategy 2: Find latest compatible version for the project
            $depVersions = Http::get("https://api.modrinth.com/v2/project/{$dep['project_id']}/version", [
                'loaders'       => json_encode([$loader]),
                'game_versions' => json_encode([$mcVersion]),
            ])->json();
            $depVersion = $depVersions[0] ?? null;  // Latest compatible
        }
        if ($depVersion) {
            $this->installVersion($depVersion);
        }
    }
    // 'optional' and 'incompatible' types are ignored
}

Modrinth Provider Error Map

Error / Log Message Cause Impact Solution
HTTP 429 rate limited >300 req/min from your IP Requests blocked ~60s Reduce browsing speed, increase cache TTL
No results for MC version No server-side files for version+loader Empty browse page Try different version or loader filter
SHA-1 hash mismatch Download corrupted in transit File integrity check fails Clear cache and retry download
version_id not found (404) Dependency references deleted version Dependency install fails Try installing the dependency mod manually
Facet parse error Invalid JSON in facets parameter API returns 400 Clear cache — plugin may have cached bad facets
Download CDN timeout cdn.modrinth.com slow or down File download fails Increase MODS_REQUEST_TIMEOUT, retry later

Modtale Provider — Complete Reference

API Endpoint Parameters — GET /api/v1/mods

Parameter Type Required Default Description
page int No 0 0-based page index (unlike CurseForge/Modrinth!)
game string No Filter by game name

⚠ Critical difference: Modtale uses 0-based pagination. Page 0 = first page. The plugin converts from its internal 1-based system: max(0, $page - 1).

Response Schema

Field Type Description
data[].id int Unique Modtale mod ID
data[].name string Mod display name
data[].description string Short description
data[].thumbnail string Relative path → prepend cdn.modtale.net/
data[].download_count int Total downloads
data[].updated_at string Last update timestamp
data[].game string Associated game name
meta.total int Total results
meta.current_page int Current page (0-based)
meta.last_page int Last page number

Rate Limits

Tier Limit Notes
Without API key Unknown (undocumented) Lower threshold — may hit limits with heavy browsing
With X-MODTALE-KEY Extended limits Set via MODTALE_API_KEY env var
When exceeded HTTP 429 No documented Retry-After header
// Complete request + response processing
$response = Http::timeout($timeout)
    ->when($apiKey, fn ($http) => $http->withHeaders(['X-MODTALE-KEY' => $apiKey]))
    ->get('https://api.modtale.net/api/v1/mods', [
        'page' => max(0, $page - 1),  // Convert 1-based to 0-based!
        'game' => $gameName,
    ]);

$results = $response->json('data', []);
$mods = collect($results)->map(function ($mod) {
    return [
        'id'            => $mod['id'],
        'name'          => $mod['name'],
        'summary'       => $mod['description'] ?? '',
        'author'        => $mod['author'] ?? 'Unknown',
        'downloads'     => $mod['download_count'] ?? 0,
        'icon_url'      => 'https://cdn.modtale.net/' . ($mod['thumbnail'] ?? ''),
        'updated_at'    => $mod['updated_at'],
        'provider'      => 'modtale',
    ];
});
// CDN URL construction — all assets use the CDN prefix
$iconUrl     = 'https://cdn.modtale.net/' . $mod['thumbnail'];
$downloadUrl = 'https://cdn.modtale.net/' . $file['download_path'];

// ⚠ No dependency information — Modtale API does not provide dependency data
// Users must install dependencies manually for Modtale mods

Modtale Provider Error Map

Error / Log Message Cause Impact Solution
API returns empty data api.modtale.net down or changed No mods shown Check if modtale.net is accessible
CDN download 404 File removed or path changed Download fails Retry search — file listing may update
cdn.modtale.net timeout CDN unreachable from server Icons missing + downloads fail Check firewall rules, DNS resolution
No dependency information API limitation (not a bug) Deps must be installed manually User manually identifies and installs
0-based offset confusion Custom code using raw page numbers Wrong page of results Always use max(0, $page - 1) conversion
Rate limited without key Too many requests without API key HTTP 429 for a period Configure MODTALE_API_KEY in settings

Cross-Provider Patterns

These code patterns apply across all providers:

// Search query sanitisation — protects against injection across all providers
$query = preg_replace('/[<>{}\'"]+/', '', $query);
// Characters < > { } ' " are stripped before any API call
// Filename detection after download — 3 retries with backoff
for ($attempt = 0; $attempt < 3; $attempt++) {
    usleep([200000, 300000, 500000][$attempt]);  // 200ms → 300ms → 500ms
    $files = $this->fileRepository->getDirectory('/mods/');
    $newFile = $this->findNewFile($files, $knownFiles);
    if ($newFile) break;
}
// If no new file detected after 3 attempts → warning logged
// UUID prefix cleaning — server adds uuid_short to filenames
$cleanName = preg_replace('/^[a-f0-9]{8}_/', '', $filename);
// e.g., "a1b2c3d4_fabric-api-0.92.jar" → "fabric-api-0.92.jar"
// JAR metadata extraction — reads mod name from archive
$zip = new ZipArchive();
ini_set('memory_limit', '256M');  // JAR files can be large
// Checks in order: plugin.yml → fabric.mod.json → META-INF/mods.toml
// Max file size: 30MB for metadata scan
// Concurrent scan protection — atomic lock prevents duplicate scans
$lock = Cache::lock('mod_scan_' . $serverUuid, 10);  // 10-second TTL
if (!$lock->get()) {
    return;  // Another scan already running
}

Complete Notification Reference

Event Type Title Body
Version detected Success Detection "Minecraft version detected: X.X.X"
Version not detected Warning Detection "Could not detect Minecraft version"
Install started Info Installing "Mod installation started"
Install success Success Installed "Successfully installed mod" (logged)
Install failed Danger/Error Failed "Installation failed" + error detail
Dependency install Info Dependencies "Installing X additional library mods…"
Mod disabled Success Disabled "Mod disabled: [name]"
Mod enabled Success Enabled "Mod enabled: [name]"
Disable failed Danger Failed "Disable failed" + error
Enable failed Danger Failed "Enable failed" + error
Mod uninstalled Success Removed "Mod uninstalled"
Uninstall failed Danger Failed error detail
Cache cleared Success Cache "Cache cleared" + scan results
Cache error Danger Error "Error clearing cache and scanning mods" + $e->getMessage()
ZIP started Info Export "ZIP download started"
ZIP failed Danger Export "ZIP download failed"
CurseForge key missing Warning Config "CurseForge API key not set"
Manual download Warning Download "Manual download required" + link
Filename detect fail Warning Install "Failed to detect filename after download" (logged)
JAR metadata fail Warning Install "Failed to extract mod name from JAR" (logged)

HTTP Error Code Reference

HTTP Status Source Meaning Action
200 Any Success
400 CurseForge Bad request (invalid params) Check gameId, classId values
401 CurseForge API key invalid Replace key at console.curseforge.com
403 CurseForge Key lacks permissions Request additional scopes or regenerate
403 Plugin Feature flag mods missing on Egg Add ["mods"] to Egg Features
404 Any provider Resource not found Mod ID/slug may be wrong or mod was removed
429 CurseForge Rate limit exceeded Wait 60s, increase cache duration
429 Modrinth Rate limit (fair use) Reduce request frequency
500 Any provider Server error Retry later, check provider status page
502/503 Any Gateway/maintenance Provider is down, retry later
0 / timeout Any Connection timeout Increase MODS_REQUEST_TIMEOUT, check DNS/firewall