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/frameworkpresent. - 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:
slugis lowercase letters, numbers, and dashes, starting with a letter or number.versionis numeric dotted, for example1.0.0.entry.fileexists relative to the plugin directory.entry.classexists and implementsPluginInterface.- 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
- Open the site and complete a checkout. The price should be 1% lower for standard orders.
- Open a user dashboard. The panel should render.
- 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:
| Path | Wired as |
|---|---|
routes/web.php | Routes with web middleware |
routes/api.php | Routes 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.php | Merged 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
| Mistake | Symptom | Fix |
|---|---|---|
Class namespace does not match autoload.psr-4 | Entry class not found | Align namespace and path |
Constructor has no PluginContext type | Validation error | Type the first parameter |
plugin.json has a trailing comma | Invalid JSON | Validate the JSON |
| Menu route does not exist | Item silently skipped | Define the route first |
| Slot outputs unescaped input | XSS risk | Use htmlspecialchars |
Filter returns null for an array contract | Core ignores or errors | Always return the expected type |
| Boot makes an HTTP call | Slow every request | Move it to an action or CLI |
Next steps
- Manifest Reference - every field and default.
- Lifecycle & Discovery - why a plugin did not boot.
- Hooks, Slots & Registry - the full contract.
Last updated: October 21, 2026

