Xgenious/ docs

Creating a Plugin

This page builds a working plugin from scratch. By the end you will have a plugin that changes a checkout price, renders a small panel on the dashboard, and adds an admin menu item.

Requirements

  • PHP 8.3 or newer.
  • Xilancer 4.3.0 or newer with plugins/framework present.
  • Write access to the plugins/ directory.
  • A terminal for the first activation. After that, the admin uploader works.

Step 1 - Copy the scaffold

cp -r plugins/example-hello-world plugins/my-plugin

The scaffold is intentionally small:

plugins/my-plugin/
├── plugin.json
├── src/
│   └── HelloWorldPlugin.php
└── README.md

Rename the directory and edit the manifest and class.

Step 2 - Write the manifest

plugins/my-plugin/plugin.json:

{
    "name": "My Plugin",
    "slug": "my-plugin",
    "version": "1.0.0",
    "entry": {
        "file": "src/MyPlugin.php",
        "class": "MyPlugin\\MyPlugin"
    },
    "autoload": {
        "psr-4": {
            "MyPlugin\\": "src/"
        }
    },
    "requires": {
        "framework": ">=1.0",
        "php": ">=8.3",
        "plugins": {}
    },
    "provides": [
        "checkout.price.calculate",
        "dashboard.referral_panel",
        "admin.menu"
    ],
    "capabilities": [],
    "xilancerMetaData": {
        "plugin_type": "external"
    }
}

Rules the validator enforces:

  • slug is lowercase letters, numbers, and dashes, starting with a letter or number.
  • version is numeric dotted, for example 1.0.0.
  • entry.file exists relative to the plugin directory.
  • entry.class exists and implements PluginInterface.
  • The constructor's first parameter is typed PluginContext.

See Manifest Reference for every optional field.

Step 3 - Write the entry class

plugins/my-plugin/src/MyPlugin.php:

<?php

declare(strict_types=1);

namespace MyPlugin;

use PluginsFramework\Plugins\PluginContext;
use PluginsFramework\Plugins\PluginInterface;

final class MyPlugin implements PluginInterface
{
    public function __construct(private readonly PluginContext $ctx)
    {
    }

    public function boot(): void
    {
        // 1. Filter: transform a value.
        $this->ctx->hooks->addFilter(
            'checkout.price.calculate',
            function (float $price, array $context = []): float {
                if (($context['order_kind'] ?? '') !== 'standard') {
                    return $price;
                }

                return round($price * 0.99, 2);
            }
        );

        // 2. Action: react after a committed change.
        $this->ctx->hooks->addAction(
            'order.paid',
            function (int $orderId, array $meta = []): void {
                // Write your own record, notify an external service, etc.
            }
        );

        // 3. Slot: render HTML. Escape everything you output.
        $this->ctx->slots->register(
            'dashboard.referral_panel',
            'my-plugin.panel',
            function (array $context): string {
                $userId = (int) ($context['user_id'] ?? 0);
                $url = htmlspecialchars('/my-plugin?user=' . $userId, ENT_QUOTES);

                return '<a class="btn btn-primary" href="' . $url . '">Open</a>';
            }
        );

        // 4. Registry: structured contribution.
        $this->ctx->registry->register('admin.menu', 'my-plugin', [
            'label' => 'My Plugin',
            'route' => 'admin.my-plugin.index',
            'icon' => 'fa-solid fa-star',
            'permissions' => 'my-plugin-manage',
        ]);
    }
}

The framework registers your PSR-4 namespace before boot(), so sibling classes autoload normally. Do not require_once classes that live under a mapped namespace. If you keep a file outside PSR-4, require it explicitly.

Step 4 - Validate and activate

php core/artisan plugins list
php core/artisan plugins validate my-plugin
php core/artisan plugins activate my-plugin

validate checks the directory, manifest, entry file, PHP syntax in every file, PHP and framework versions, plugin dependencies, and a sandboxed boot test. activate runs the same checks, then runs migrations, writes plugins/statuses.json, and clears the route cache.

If validation fails, the command prints every error and warning. Nothing is activated.

Step 5 - Verify

  1. Open the site and complete a checkout. The price should be 1% lower for standard orders.
  2. Open a user dashboard. The panel should render.
  3. Open the admin sidebar. The menu item should appear if the route exists.

If the menu item is missing, check the route name. The sidebar skips entries whose route is not defined.

Step 6 - Add plugin-owned MVC

Create these directories as needed:

plugins/my-plugin/
├── routes/
│   ├── web.php
│   └── api.php
├── views/
├── database/migrations/
├── lang/
├── config/
│   └── my-plugin.php
└── src/

The bridge auto-wires them for active, successfully booted plugins:

PathWired as
routes/web.phpRoutes with web middleware
routes/api.phpRoutes with api middleware
views/Blade namespace equal to the slug
database/migrations/Picked up by artisan migrate
lang/Translation namespace equal to the slug
config/my-plugin.phpMerged as plugins.my-plugin

Read Routes, Views & Settings for examples.

Step 7 - Add a migration

plugins/my-plugin/database/migrations/2026_10_21_000001_create_my_plugin_items.php:

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
    public function up(): void
    {
        Schema::create('my_plugin_items', function (Blueprint $table): void {
            $table->id();
            $table->unsignedBigInteger('user_id')->index();
            $table->string('label');
            $table->json('payload')->nullable();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('my_plugin_items');
    }
};

Rules:

  • Prefix table names with the plugin slug.
  • Always provide down().
  • Do not modify core tables. If you need a column, ask core for a seam or store a side table keyed by the core ID.
  • Migrations run in the core database.

Step 8 - Run your tests

core/vendor/bin/phpunit -c plugins/my-plugin/phpunit.xml

If you do not have tests yet, copy the structure from plugins/coupons/phpunit.xml. See Testing & Packaging.

Complete example

plugins/my-plugin/
├── plugin.json
├── README.md
├── phpunit.xml
├── src/
│   ├── MyPlugin.php
│   ├── Item.php
│   ├── ItemStore.php
│   └── Http/
│       └── ItemController.php
├── routes/
│   └── web.php
├── views/
│   └── panel.blade.php
├── database/
│   └── migrations/
│       └── 2026_10_21_000001_create_my_plugin_items.php
├── config/
│   └── my-plugin.php
└── tests/
    └── PluginTest.php

Common mistakes

MistakeSymptomFix
Class namespace does not match autoload.psr-4Entry class not foundAlign namespace and path
Constructor has no PluginContext typeValidation errorType the first parameter
plugin.json has a trailing commaInvalid JSONValidate the JSON
Menu route does not existItem silently skippedDefine the route first
Slot outputs unescaped inputXSS riskUse htmlspecialchars
Filter returns null for an array contractCore ignores or errorsAlways return the expected type
Boot makes an HTTP callSlow every requestMove it to an action or CLI

Next steps


Last updated: October 21, 2026

Still stuck?
Our support team is ready to help you get set up.
Get support