Extensions

Backend

Give an extension a service provider, routes, authorization, migrations, settings, and background work.

An extension's backend runs inside the Panel like a Laravel package. This page continues the Server Notes example from Building Extensions.

The Service Provider

The backend starts in a service provider. It works like a Laravel service provider and must extend Pterodactyl\Extensions\ExtensionProvider:

<?php

namespace ServerNotes;

use Pterodactyl\Extensions\ExtensionProvider;

class ServerNotesProvider extends ExtensionProvider
{
    public function boot(): void
    {
        $this->registerApiRoutes();
        $this->loadExtensionMigrations();
    }
}

Use register for container bindings and boot for everything else. The base class provides helpers for routes, migrations, settings, views, and translations.

The Panel loads your provider on every request. If it throws an exception, the Panel skips your extension, shows the error in the admin area, and keeps running.

Routes

Put your routes in the routes directory. registerApiRoutes loads each of these files that exists, with its own URL prefix and middleware:

FileURL prefixWho can call it
routes/client.php/api/client/extensions/{id}Any signed-in user, or a Client API key.
routes/server.php/api/client/servers/{server}/extensions/{id}Users with access to the server.
routes/admin.php/api/admin/extensions/{id}Root administrators.
routes/application.php/api/application/extensions/{id}Root administrators, including Application API keys.

These routes use the same authentication, two-factor, and rate-limit middleware as the Panel's own API, so your extension does not handle sign-in.

Server Notes adds two server routes:

<?php

use Illuminate\Support\Facades\Route;
use ServerNotes\Http\Controllers\NoteController;

// GET|PUT /api/client/servers/{server}/extensions/server-notes/note
Route::get('/note', [NoteController::class, 'show']);
Route::put('/note', [NoteController::class, 'update']);

In server routes, {server} is already resolved. Type-hint Pterodactyl\Models\Server in your controller to receive it:

public function show(Server $server): JsonResponse
{
    $note = Note::query()->where('server_id', $server->id)->first();

    return new JsonResponse([
        'body' => $note->body ?? '',
        'updated_at' => $note?->updated_at?->toAtomString(),
    ]);
}

For a web route instead of an API route, call registerWebRoutes or registerAuthenticatedWebRoutes with the path to a route file. Web routes are served under /extensions/{id}. See the Extension Reference.

Authorization

The Panel checks that the user may access the server before your server routes run. Users without access receive a 404 response.

To check a specific permission, call can with one of the Panel's subuser permissions. The server owner and root administrators pass every check. Server Notes lets any user read the notes, but only users who may edit files can change them:

public function update(Request $request, Server $server): JsonResponse
{
    abort_unless($request->user()->can('file.update', $server), 403, 'You do not have permission to edit notes.');

    // ...
}

To define permissions for your own features, call registerPermissions in boot:

$this->registerPermissions('Manage server notes.', [
    'read' => 'Read server notes.',
    'update' => 'Change server notes.',
]);

These keys become ext.server-notes.read and ext.server-notes.update and appear in the server's subuser permission editor. Use them with $user->can(...), useServerPermission(...), and a server screen's permission list. A wildcard such as ext.server-notes.* covers only that extension's permissions.

Database Migrations

Put your migrations in database/migrations and call loadExtensionMigrations in boot. The Panel runs them when an administrator enables your extension, before marking it enabled.

Start table names with ext_ and your extension's ID, using underscores instead of hyphens, to keep them apart from the Panel's own tables:

Schema::create('ext_server_notes_notes', function (Blueprint $table) {
    $table->id();
    $table->unsignedInteger('server_id')->unique();
    $table->text('body');
    $table->timestamps();

    $table->foreign('server_id')->references('id')->on('servers')->cascadeOnDelete();
});

Disabling or removing the extension keeps its tables, so reinstalling it brings its data back. Package recovery does not undo schema changes, so design migrations so you can identify and recover from a partially completed change.

Use Eloquent models for your tables as usual:

<?php

namespace ServerNotes\Models;

use Illuminate\Database\Eloquent\Model;

class Note extends Model
{
    protected $table = 'ext_server_notes_notes';

    protected $fillable = ['server_id', 'body'];
}

Settings

Define settings in your provider's boot method. Administrators change them from Admin → Extensions, in a form the Panel builds:

use Pterodactyl\Services\Extensions\ExtensionSettingDefinition;
use Pterodactyl\Services\Extensions\ExtensionSettingsDefinition;

public function boot(): void
{
    // ...

    $this->registerSettings(new ExtensionSettingsDefinition($this->settings(), [
        ExtensionSettingDefinition::make('max_length', 'max_length', 2000, ['required', 'integer', 'min:100', 'max:20000'])
            ->label('Maximum note length')
            ->help('The longest note, in characters, that users can save.')
            ->field('number')
            ->frontend()
            ->frontendType('number'),
    ]));
}

The make method takes the setting's storage key, form field name, default value, and validation rules. Give each setting rules that match the values you accept.

The field method chooses the form control: text, password, number, toggle, or select. For select, pass the options as the second argument:

ExtensionSettingDefinition::make('mode', 'mode', 'simple', ['required', 'in:simple,advanced'])
    ->field('select', [
        ['value' => 'simple', 'label' => 'Simple'],
        ['value' => 'advanced', 'label' => 'Advanced'],
    ]);

To read a setting in backend code, type-hint ExtensionSettingsRegistry $registry in your controller or job and get your definition from it. Unsaved settings return their default value:

use Pterodactyl\Services\Extensions\ExtensionSettingsRegistry;

$maxLength = $registry->get('server-notes')->get('max_length');

Server Notes uses the setting to validate new notes:

public function update(Request $request, Server $server, ExtensionSettingsRegistry $settings): JsonResponse
{
    abort_unless($request->user()->can('file.update', $server), 403, 'You do not have permission to edit notes.');

    $data = $request->validate([
        'body' => ['present', 'nullable', 'string', 'max:'.$settings->get('server-notes')->get('max_length')],
    ]);

    $note = Note::query()->updateOrCreate(['server_id' => $server->id], ['body' => $data['body'] ?? '']);

    return new JsonResponse([
        'body' => $note->body,
        'updated_at' => $note->updated_at->toAtomString(),
    ]);
}

The frontend method sends the setting's value to your frontend bundle as config. Every signed-in user can read frontend settings, so never mark a secret as a frontend setting. Pages shown before sign-in, such as the login page, still load your bundle, but with an empty config. frontendType declares the public value's type, such as number, boolean, or string, and the Panel checks it before exposing the value.

For values your extension manages itself and administrators should not edit, use the key-value store returned by $this->settings(). It has get, set, forget, and all methods.

Scoped Settings and Secrets

The store returned by settings() is global to your extension. Use forUser($user) or forServer($server) to keep separate values per persisted user or server:

$preferences = $this->settings()->forUser($user);
$preferences->set('show_preview', true);

$serverSettings = $this->settings()->forServer($server);
$serverSettings->set('summary_enabled', true);
$serverSettings->setSecret('webhook_token', $token);

Read a secret with get. Use setSecret every time you replace it so it stays encrypted. The settings definition also supports forUser and forServer when scoped values need defaults, normalization, and validation.

For a secret in the administrator's settings form, add secret() to its definition:

ExtensionSettingDefinition::make('webhook_token', 'webhook_token', '', ['nullable', 'string', 'max:2000'])
    ->label('Webhook token')
    ->secret();

A secret uses a password control, is encrypted in the database, and is returned blank to the admin form. An empty update keeps the saved secret. Secrets cannot use frontend(). field('password') on its own does not enable encryption.

Server Operation Events

To react to a completed server operation, register a listener with listenToServerOperations in your provider. It receives a Pterodactyl\Events\Server\OperationCompleted object with the server UUID, operation, success flag, and resource UUID.

The operations are provision, install, reinstall, and backup. Events are dispatched after the database transaction commits; provisioning is reported after Wings accepts the server. The values are read-only, so a listener does not depend on a model being available later.

See Server Operation Events for the event fields. If a listener throws, the Panel records the failure against the extension.

Job Progress

For long-running work, inject Pterodactyl\Services\Extensions\ExtensionJobProgress into your controller or job. Create a snapshot for the user who starts the work:

$job = $progress->begin('server-notes', $user, $server, 'ext.server-notes.update');

Return $job->id to the frontend and pass it to your worker, which updates it as the work proceeds:

$progress->update('server-notes', $jobId, 40, 'Reading server notes.');
$progress->update('server-notes', $jobId, 100, 'Summary ready.', 'completed');

Progress only moves forward, from 0 to 100. The status is running, completed, or failed; a finished snapshot cannot be changed. Omit the server and permission arguments for a user-only job. User jobs, and server jobs without a permission, are visible only to their creator and root administrators. A server job with a permission is visible to users who still have that permission and access to the server.

Web requests and workers must use the same cache store. Snapshots expire according to extensions.progress_retention_seconds, which defaults to one hour after the last update. Store results or audit history separately.

To show the snapshot in a screen or slot, use useExtensionJobProgress. Pass the server UUID for a server job:

import { Spinner, useExtensionJobProgress } from '@pterodactyl/sdk';

export function JobProgress({ id, serverUuid }: { id: string; serverUuid: string }) {
    const progress = useExtensionJobProgress(id, { serverUuid });

    if (progress.error) return <p>Progress is unavailable.</p>;
    if (!progress.data) return <Spinner />;

    return <p>{progress.data.message} ({progress.data.percent}%)</p>;
}

The hook polls while the job runs, reads the latest snapshot after reconnecting, and stops on completion, failure, or loss of access.

Views and Translations

For Blade views, put them in resources/views and call loadExtensionViews. For translations, put them in resources/lang and call loadExtensionTranslations. Both use the ext-{id} namespace, such as ext-server-notes::notes.title.

Edit on GitHub

Last updated on

On this page