diff --git a/app/Http/Controllers/DiscordIntegrationController.php b/app/Http/Controllers/DiscordIntegrationController.php
index 87a2fe05..330013b2 100644
--- a/app/Http/Controllers/DiscordIntegrationController.php
+++ b/app/Http/Controllers/DiscordIntegrationController.php
@@ -123,6 +123,10 @@ public function disconnect(): RedirectResponse
if ($user->discord_early_adopter_role_granted_at) {
$discord->removeEarlyAdopterRole($user->discord_id);
}
+
+ if ($user->discord_plugin_developer_role_granted_at) {
+ $discord->removePluginDeveloperRole($user->discord_id);
+ }
}
$user->update([
@@ -130,6 +134,7 @@ public function disconnect(): RedirectResponse
'discord_username' => null,
'discord_role_granted_at' => null,
'discord_early_adopter_role_granted_at' => null,
+ 'discord_plugin_developer_role_granted_at' => null,
]);
return back()->with('success', 'Discord account disconnected successfully.');
diff --git a/app/Livewire/DiscordAccessBanner.php b/app/Livewire/DiscordAccessBanner.php
index cd2a1df0..f8f32c26 100644
--- a/app/Livewire/DiscordAccessBanner.php
+++ b/app/Livewire/DiscordAccessBanner.php
@@ -14,6 +14,8 @@ class DiscordAccessBanner extends Component
public bool $hasEarlyAdopterRole = false;
+ public bool $hasPluginDeveloperRole = false;
+
public bool $isGuildMember = false;
public function mount(bool $inline = false): void
@@ -29,6 +31,7 @@ public function checkRoleStatus(): void
if (! $user || ! $user->discord_id) {
$this->hasUltraRole = false;
$this->hasEarlyAdopterRole = false;
+ $this->hasPluginDeveloperRole = false;
$this->isGuildMember = false;
return;
@@ -43,12 +46,14 @@ public function checkRoleStatus(): void
'isGuildMember' => $discord->isGuildMember($user->discord_id),
'hasUltraRole' => $discord->hasUltraRole($user->discord_id),
'hasEarlyAdopterRole' => $discord->hasEarlyAdopterRole($user->discord_id),
+ 'hasPluginDeveloperRole' => $discord->hasPluginDeveloperRole($user->discord_id),
];
});
$this->isGuildMember = $status['isGuildMember'];
$this->hasUltraRole = $status['hasUltraRole'];
$this->hasEarlyAdopterRole = $status['hasEarlyAdopterRole'];
+ $this->hasPluginDeveloperRole = $status['hasPluginDeveloperRole'] ?? false;
if ($this->hasUltraRole && ! $user->discord_role_granted_at) {
$user->update(['discord_role_granted_at' => now()]);
@@ -57,6 +62,10 @@ public function checkRoleStatus(): void
if ($this->hasEarlyAdopterRole && ! $user->discord_early_adopter_role_granted_at) {
$user->update(['discord_early_adopter_role_granted_at' => now()]);
}
+
+ if ($this->hasPluginDeveloperRole && ! $user->discord_plugin_developer_role_granted_at) {
+ $user->update(['discord_plugin_developer_role_granted_at' => now()]);
+ }
}
public function refreshStatus(): void
@@ -142,6 +151,42 @@ public function requestEarlyAdopterRole(): void
}
}
+ public function requestPluginDeveloperRole(): void
+ {
+ $user = auth()->user();
+
+ if (! $user || ! $user->discord_id) {
+ session()->flash('error', 'Please connect your Discord account first.');
+
+ return;
+ }
+
+ if (! $user->hasHadPluginApproved()) {
+ session()->flash('error', 'The Plugin Developer role is for developers who have had a plugin approved for the Marketplace.');
+
+ return;
+ }
+
+ $discord = DiscordApi::make();
+
+ if (! $discord->isGuildMember($user->discord_id)) {
+ session()->flash('error', 'Please join the NativePHP Discord server first.');
+
+ return;
+ }
+
+ $success = $discord->assignPluginDeveloperRole($user->discord_id);
+
+ if ($success) {
+ $user->update(['discord_plugin_developer_role_granted_at' => now()]);
+ Cache::forget("discord_role_status_{$user->id}");
+ $this->checkRoleStatus();
+ session()->flash('success', 'Plugin Developer role assigned successfully!');
+ } else {
+ session()->flash('error', 'Failed to assign Plugin Developer role. Please try again later.');
+ }
+ }
+
public function render()
{
return view('livewire.discord-access-banner');
diff --git a/app/Models/User.php b/app/Models/User.php
index ba4679fe..c54d183e 100644
--- a/app/Models/User.php
+++ b/app/Models/User.php
@@ -3,6 +3,8 @@
namespace App\Models;
use App\Enums\GitHubAuthType;
+use App\Enums\PluginActivityType;
+use App\Enums\PluginStatus;
use App\Enums\PriceTier;
use App\Enums\Subscription;
use App\Enums\TeamUserStatus;
@@ -11,6 +13,7 @@
use Filament\Models\Contracts\HasName;
use Filament\Panel;
use Illuminate\Contracts\Auth\MustVerifyEmail;
+use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -402,6 +405,22 @@ public function getEligiblePriceTiers(): array
return $tiers;
}
+ /**
+ * Whether any of the user's plugins has ever been approved for the Marketplace,
+ * even if it has since been rejected, withdrawn or deactivated.
+ */
+ public function hasHadPluginApproved(): bool
+ {
+ return $this->plugins()
+ ->where(function (Builder $query): void {
+ $query->where('status', PluginStatus::Approved)
+ ->orWhereHas('activities', function (Builder $query): void {
+ $query->where('type', PluginActivityType::Approved);
+ });
+ })
+ ->exists();
+ }
+
public function hasDiscordConnected(): bool
{
return ! empty($this->discord_id);
@@ -709,6 +728,7 @@ protected function casts(): array
'claude_plugins_repo_access_granted_at' => 'datetime',
'discord_role_granted_at' => 'datetime',
'discord_early_adopter_role_granted_at' => 'datetime',
+ 'discord_plugin_developer_role_granted_at' => 'datetime',
'github_auth_type' => GitHubAuthType::class,
'github_token_expires_at' => 'datetime',
'github_app_migration_notified_at' => 'datetime',
diff --git a/app/Support/DiscordApi.php b/app/Support/DiscordApi.php
index ebb835f2..b9d729c7 100644
--- a/app/Support/DiscordApi.php
+++ b/app/Support/DiscordApi.php
@@ -13,7 +13,8 @@ public function __construct(
private ?string $botToken,
private ?string $guildId,
private ?string $ultraRoleId,
- private ?string $earlyAdopterRoleId
+ private ?string $earlyAdopterRoleId,
+ private ?string $pluginDeveloperRoleId
) {}
public static function make(): static
@@ -22,7 +23,8 @@ public static function make(): static
config('services.discord.bot_token', ''),
config('services.discord.guild_id', ''),
config('services.discord.ultra_role_id', ''),
- config('services.discord.early_adopter_role_id', '')
+ config('services.discord.early_adopter_role_id', ''),
+ config('services.discord.plugin_developer_role_id', '')
);
}
@@ -101,6 +103,21 @@ public function hasEarlyAdopterRole(string $discordUserId): bool
return $this->hasRole($discordUserId, $this->earlyAdopterRoleId);
}
+ public function assignPluginDeveloperRole(string $discordUserId): bool
+ {
+ return $this->assignRole($discordUserId, $this->pluginDeveloperRoleId, 'Plugin Developer');
+ }
+
+ public function removePluginDeveloperRole(string $discordUserId): bool
+ {
+ return $this->removeRole($discordUserId, $this->pluginDeveloperRoleId, 'Plugin Developer');
+ }
+
+ public function hasPluginDeveloperRole(string $discordUserId): bool
+ {
+ return $this->hasRole($discordUserId, $this->pluginDeveloperRoleId);
+ }
+
private function assignRole(string $discordUserId, ?string $roleId, string $roleName): bool
{
$response = Http::withToken($this->botToken, 'Bot')
diff --git a/config/services.php b/config/services.php
index 0d74bbf0..903e6f38 100644
--- a/config/services.php
+++ b/config/services.php
@@ -88,6 +88,7 @@
'guild_id' => env('DISCORD_GUILD_ID'),
'ultra_role_id' => env('DISCORD_ULTRA_ROLE_ID'),
'early_adopter_role_id' => env('DISCORD_EARLY_ADOPTER_ROLE_ID'),
+ 'plugin_developer_role_id' => env('DISCORD_PLUGIN_DEVELOPER_ROLE_ID'),
],
'turnstile' => [
diff --git a/database/migrations/2026_10_02_105146_add_discord_plugin_developer_role_granted_at_to_users_table.php b/database/migrations/2026_10_02_105146_add_discord_plugin_developer_role_granted_at_to_users_table.php
new file mode 100644
index 00000000..8907be37
--- /dev/null
+++ b/database/migrations/2026_10_02_105146_add_discord_plugin_developer_role_granted_at_to_users_table.php
@@ -0,0 +1,25 @@
+timestamp('discord_plugin_developer_role_granted_at')->nullable()->after('discord_early_adopter_role_granted_at');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('users', function (Blueprint $table) {
+ $table->dropColumn('discord_plugin_developer_role_granted_at');
+ });
+ }
+};
diff --git a/resources/views/livewire/customer/integrations.blade.php b/resources/views/livewire/customer/integrations.blade.php
index 0eb2a98b..b036c1aa 100644
--- a/resources/views/livewire/customer/integrations.blade.php
+++ b/resources/views/livewire/customer/integrations.blade.php
@@ -32,7 +32,7 @@
GitHub: Max license holders can access the private nativephp/mobile repository. Plugin Dev Kit license holders and Ultra subscribers can access nativephp/claude-code.
-
Discord: Max license holders and Ultra subscribers receive a special "Ultra" role in the NativePHP Discord server. Early Access Program customers receive the "Early Adopter" role.
+
Discord: Max license holders and Ultra subscribers receive a special "Ultra" role in the NativePHP Discord server. Early Access Program customers receive the "Early Adopter" role. Developers who have had a plugin approved for the Marketplace can claim the "Plugin Developer" role, which unlocks a private channel just for plugin developers.