Xgenious/ docs

Routes, Views & Settings

For active plugins that boot successfully, the bridge auto-wires a conventional directory layout. You do not register service providers or edit core route files.

Directory conventions

PathWired as
routes/web.phpRoutes in the web middleware group
routes/api.phpRoutes in the api middleware group
views/Blade namespace equal to the slug
database/migrations/Loaded by artisan migrate
lang/Translation namespace equal to the slug
config/<slug>.phpMerged as plugins.<slug>

With legacyName, the bridge also registers the lowercased legacy view, translation, and config namespaces.

Routes

The bridge already wraps your file in web or api middleware. Do not add web again unless you need a different stack.

Web routes

<?php

use Illuminate\Support\Facades\Route;
use MyPlugin\Http\DashboardController;

Route::group([
    'prefix' => 'my-plugin',
    'as' => 'my-plugin.',
    'middleware' => ['auth', 'userEmailVerify', 'userPhoneVerify', 'Google2FA'],
], static function (): void {
    Route::get('/', [DashboardController::class, 'index'])->name('index');
    Route::post('items', [DashboardController::class, 'store'])->name('items.store');
});

Admin routes

Route::group([
    'prefix' => 'admin/my-plugin',
    'as' => 'admin.my-plugin.',
    'middleware' => ['auth:admin', 'setlang'],
], static function (): void {
    Route::get('/', [AdminController::class, 'index'])
        ->name('index')
        ->permission('my-plugin-manage');

    Route::post('settings', [AdminController::class, 'save'])
        ->name('settings.update')
        ->permission('my-plugin-manage');
});

->permission('slug') uses Spatie's permission middleware. The permission must exist in the seeder. The admin sidebar also hides the menu item when the permission is missing.

API routes

<?php

use Illuminate\Support\Facades\Route;
use MyPlugin\Http\ApiController;

Route::post('my-plugin/webhook', [ApiController::class, 'webhook'])
    ->name('my-plugin.webhook');

API routes have no session and no CSRF. Authenticate explicitly if the endpoint needs a user. Verify a signature for server-to-server callbacks.

Route rules

  1. Own your prefix. Use <slug>/... paths and <slug>. names.
  2. Use controller classes, not closures, so route:cache works.
  3. Use whereNumber() on numeric segments to avoid collisions.
  4. Register static segments before wildcards.
  5. Do not shadow a core route.
  6. Keep route files free of side effects. Defining routes is the only job.
  7. Add permission middleware to every admin route.

Route caching

php artisan route:cache

Closures cannot be serialized. Controllers can. If route:cache fails after installing a plugin, look for a closure route in that plugin.

Views

Place Blade files in views/ and render with the slug namespace:

return view('my-plugin::panel', ['items' => $items]);

Rendering from a slot

Slots receive a context array. Pass it to the view explicitly.

$this->ctx->slots->register(
    'order.details_after',
    'my-plugin.panel',
    static function (array $context): string {
        $orderId = (int) ($context['order_id'] ?? 0);
        if ($orderId === 0) {
            return '';
        }

        return view('my-plugin::order-panel', [
            'orderId' => $orderId,
            'role' => (string) ($context['role'] ?? 'client'),
        ])->render();
    }
);

Rendering without Blade

If you prefer plain PHP, buffer the output:

static function (array $context): string {
    ob_start();
    require __DIR__ . '/../views/panel.php';
    return (string) ob_get_clean();
}

Escape output yourself in that case. Blade's {{ }} escapes by default; raw PHP does not.

Legacy views

A ported plugin with legacyName: "HourlyJob" can render both:

view('hourly-job::panel');   // new namespace
view('hourlyjob::panel');    // legacy namespace

Use the new namespace in new code. Keep the legacy one only for blades you have not migrated.

Migrations

Place migrations in database/migrations/. They run in the core database.

<?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');
    }
};

Migration rules

  • Prefix tables with the slug: my_plugin_items, not items.
  • Always provide down().
  • Do not modify core tables. Store a side table keyed by the core ID.
  • Do not rely on core models. Use the query builder or your own model.
  • Keep migrations idempotent where possible. Activation runs migrate, which skips applied files.
  • Do not seed large datasets in a migration. Use a command or a settings page.
  • If a table already exists from an old module, mark the migration as applied instead of dropping data.

Running migrations

php core/artisan migrate --path=plugins/my-plugin/database/migrations --realpath --force

Activation does this automatically. The admin uploader does it too.

Translations

Place files in lang/:

lang/
├── en/
│   └── messages.php
└── fr/
    └── messages.php

Render with the slug namespace:

__('my-plugin::messages.welcome')

The bridge registers the namespace. The legacy namespace is also registered when legacyName is set.

Config

Place config/<slug>.php:

<?php

return [
    'enabled' => true,
    'batch_size' => 25,
    'timeout' => 10,
];

Read it with:

config('plugins.my-plugin.timeout', 10);

The file is merged, not replaced. A site owner can override a value in core/config/plugins.php or an environment-specific config without editing your file.

Do not put secrets in the config file. Use settings storage.

Settings storage

Choose one of three patterns.

Core's settings table is available through helpers:

$apiKey = get_static_option('my_plugin_api_key', '');
update_static_option('my_plugin_api_key', $value);

Pros: simple, cached, works with the admin settings UI.

Cons: no schema, no types, easy to create typos. Prefix every key with the plugin slug.

Option B - Plugin config (read-only defaults)

Use config/<slug>.php for defaults that rarely change. Do not write to it at runtime.

Option C - Plugin-owned tables (structured data)

Use a migration and your own model or query builder:

final class ItemStore
{
    public function allForUser(int $userId): array
    {
        return \Illuminate\Support\Facades\DB::table('my_plugin_items')
            ->where('user_id', $userId)
            ->orderByDesc('id')
            ->get()
            ->all();
    }
}

Pros: indexes, constraints, joins, pagination.

Cons: you own migrations, backups, and cleanup.

Choosing

DataUse
API key, toggle, one rateStatic option
Default batch size, timeoutConfig
Records with relationships, history, statusTable

Admin menu

Via plugin.json

"xilancerMetaData": {
    "admin_settings": {
        "menu_item": [
            {
                "id": "my-plugin-menu",
                "label": "My Plugin",
                "route": "admin.my-plugin.index",
                "parent": null,
                "icon": "fa-solid fa-star",
                "permissions": "my-plugin-manage",
                "class": "bg-success"
            }
        ]
    }
}

Use this for a static menu. It is read once and rendered by the sidebar.

Via registry

$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',
]);

Use this when the menu depends on runtime state. Both are valid; do not register the same item twice.

Dashboard menu

$this->ctx->registry->register('dashboard.menu', 'my-plugin', [
    'label' => 'My Items',
    'route' => 'my-plugin.index',
    'icon' => 'fa-solid fa-box',
    'roles' => ['freelancer', 'client'],
]);

Dashboard menus render in both sidebars, filtered by role.

Assets

Admin assets

Register a renderer on admin.assets:

$this->ctx->slots->register('admin.assets', 'my-plugin.assets', static function (): string {
    $url = htmlspecialchars(asset('plugins/my-plugin/assets/admin.js'), ENT_QUOTES);

    return '<script src="' . $url . '"></script>';
});

Frontend assets

Use a legacy Blade hook in plugin.json for global assets:

"xilancerMetaData": {
    "hooks": {
        "head_end": ["assets.head"],
        "body_end": ["assets.body"]
      }
}

Create views/assets/head.blade.php. The renderer resolves my-plugin::assets.head.

Prefer slots for page-specific assets. Do not load a large bundle on every page.

Permissions

Create permissions in a seeder or an install command:

\Spatie\Permission\Models\Permission::firstOrCreate([
    'name' => 'my-plugin-manage',
    'guard_name' => 'admin',
]);

Then assign them to roles from the admin role screen. The plugin should not grant permissions to roles automatically; that is an operator decision.

Complete example

plugins/my-plugin/
├── plugin.json
├── routes/
│   ├── web.php
│   └── api.php
├── views/
│   ├── panel.blade.php
│   └── assets/
│       └── head.blade.php
├── database/
│   └── migrations/
│       └── 2026_10_21_000001_create_my_plugin_items.php
├── lang/
│   └── en/
│       └── messages.php
├── config/
│   └── my-plugin.php
└── src/
    ├── MyPlugin.php
    ├── ItemStore.php
    └── Http/
        ├── DashboardController.php
        └── AdminController.php

Caching checklist

After changing routes, views, config, or translations:

php artisan route:clear
php artisan view:clear
php artisan config:clear
php artisan cache:clear

After changing PHP classes, opcache may need a reload:

php artisan optimize:clear

On production, restart php-fpm or the Octane worker pool after deploying a plugin.


Last updated: October 21, 2026

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