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:
- Browser-installed — Full tracking (provider, version, mod ID, download URL, update detection)
- 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 fromplugin.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
- "Update Available" badge in Installed tab
- Console page widget: "Mod Updates Available — Update Now"
- Latest Version column shows new version
Performing Update
- Click Update next to the mod
- New version downloaded from platform
- Old version auto-removed (if enabled)
- Notification: "Mod installation started"
- 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.jar → sodium-1.0.jar-bak |
| Enable | 1. Read -bak file 2. Write to original name 3. Delete -bak |
sodium-1.0.jar-bak → sodium-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
- Click Uninstall → confirm warning
- JAR file deleted from
/mods/ - Database record removed
- Notification: "Mod uninstalled"
- 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
-bakextensions on disabled mods - Finding duplicate/conflicting files
ZIP Export
Process
- Click Download All as ZIP
- Notification: "ZIP download started"
- Server compresses all files in
/mods/ - JWT-signed URL generated (expires in 30 minutes)
- 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
- Check console crash report — names the problematic mod
- Disable (don't delete!) the mod + restart to confirm
- Verify dependencies are installed
- Check mod loader compatibility (Fabric on Forge = crash)
- Try older stable version
- Check Java version (17 for MC 1.18+, 21 for MC 1.21+)
- 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 |