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. for($model) does the same for any user, server, node, egg, location, mount, or database host. Deleting the model deletes its scoped settings:

$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.

Form Fields

An extension may add its own fields to the Panel's admin create and edit forms for users, servers, nodes, eggs, locations, mounts, and database hosts. The Panel validates the values with the form, saves them in the same database transaction, and returns them on the resource. Declare the fields in a class that extends Pterodactyl\Extensions\Fields, and register it in your provider with registerFields, once for each model.

As an example extension: Server Notes lets administrators edit a server's note from the server's admin pages:

<?php

namespace ServerNotes;

use Pterodactyl\Extensions\Fields;
use Pterodactyl\Models\Server;
use ServerNotes\Models\Note;

final class ServerNoteFields extends Fields
{
    public function rules(): array
    {
        return ['body' => ['nullable', 'string', 'max:2000']];
    }

    public function values(Server $server): array
    {
        return ['body' => Note::query()->where('server_id', $server->id)->value('body') ?? ''];
    }

    public function save(Server $server, array $values): void
    {
        Note::query()->updateOrCreate(['server_id' => $server->id], ['body' => $values['body'] ?? '']);
    }
}
use Pterodactyl\Models\Server;

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

    $this->registerFields(Server::class, ServerNoteFields::class);
}

Every method is optional. The Panel calls them through the service container, like the methods of a form request, so each one may type-hint the model it extends and any other dependency. A model parameter always receives the model being changed. Every method except values and save also runs while the model is being created, so a model parameter in rules, authorize, secrets, attributes, or messages must be nullable, such as ?Server $server. It is null on create.

MethodPurpose
rules()Laravel validation rules, keyed by field name. They apply to your values alone, so required_if:mode,custom refers to your own mode field.
values($model)The current values, keyed by field name.
save($model, array $values)Stores the validated values. The second parameter must be named $values.
authorize()Whether the signed-in administrator may see and change the values.
secrets()The names of fields that hold credentials.
attributes(), messages()Field names and messages for validation errors, as on a form request.

A field's value must be a string, a number, a boolean, null, or a list of those.

If you leave out both values and save, the Panel stores the values for you. Read them in your backend code with $this->settings()->fields($model). They are kept apart from your other scoped settings, so for, forUser, and forServer never return them. Implement both methods to keep the values in your own tables instead, where you can index and query them. Implementing only one of them stops your extension from loading.

Validation

When a model is created, the rules of every extension the administrator may change run, whether or not the request includes its values, so a required field always holds. When a model is updated, the rules run for each extension the request includes, even with no values. An update that leaves your extension out does not run your rules or your save method, and your stored values stay as they are.

The Panel's admin forms always send every extension they show, edited or not. In the admin area, required therefore works like it does for the Panel's own fields, and save always receives your complete values. Validation errors are reported as extensions.{id}.{field}.

Saving

Your save method runs inside the transaction that writes the model, after its row is written and before Wings is contacted. If it throws, the whole change is rolled back and the request fails, so nothing is half saved. Put work that must wait for the commit, such as calling an external service, in DB::afterCommit().

While your extension is disabled, or fails to load, any values sent for it are ignored, and its stored values stay as they are.

Authorization and Secrets

Only root administrators reach the admin endpoints, and $user->can() passes every check for a root administrator. To limit who may change your fields, decide on something else in authorize, such as the administrator's ID or a role your extension keeps. Type-hint #[CurrentUser] User $admin to get the signed-in administrator. A request that sends values authorize refuses gets a 403 response, and the values are left out of the resource, so the forms do not show them.

Fields named by secrets are encrypted when the Panel stores them, and returned as ********. A save that sends the mask, or leaves the field out, keeps the stored value, and save receives it. Sending null or an empty string clears it.

The API

The Admin API's create and update endpoints for each of these resources accept an extensions object, keyed by extension ID and then by field. For example, alongside the other fields of a server details update:

{
    "name": "Survival",
    "extensions": {
        "server-notes": { "body": "Restart nightly at 04:00." }
    }
}

Responses about one resource include the values as attributes.extensions. Lists leave them out. If your values method throws, the failure is recorded against your extension in Admin → Extensions, and your values are left out of the response.

To accept and return your values on the Application API as well, add the #[ApplicationApi] attribute (Pterodactyl\Extensions\Attributes\ApplicationApi) to your class. This covers the Application API's user, node, location, and server endpoints, so a billing system that provisions through it can set your fields. The create rule applies there too: if a marked class has a required field, creating a resource through the Application API fails until the request includes it.

To draw the fields in the admin forms, see Native Forms.

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