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
| Path | Wired as |
|---|---|
routes/web.php | Routes in the web middleware group |
routes/api.php | Routes 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>.php | Merged 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
- Own your prefix. Use
<slug>/...paths and<slug>.names. - Use controller classes, not closures, so
route:cacheworks. - Use
whereNumber()on numeric segments to avoid collisions. - Register static segments before wildcards.
- Do not shadow a core route.
- Keep route files free of side effects. Defining routes is the only job.
- 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, notitems. - 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.
Option A - Static options (simple, recommended for a few values)
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
| Data | Use |
|---|---|
| API key, toggle, one rate | Static option |
| Default batch size, timeout | Config |
| Records with relationships, history, status | Table |
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

