Plugin Development Guide
Overview
Plugin Principle
InnoShop plugins are based on Laravel's automatic discovery and bootstrap mechanism, ensuring that all components of the plugin can be recognized and loaded by the system. The PHP code of the plugin follows the PSR-4 autoloading standard, meaning that directory names and class names should use StudlyCaps (PascalCase naming convention).
Important: PSR-4 requires that the namespace casing matches the directory name exactly. For example, if the plugin directory is plugins/MambaSms/, the namespace must be Plugin\MambaSms -- not Plugin\MambaSMS.
Plugin Loading Flow
1. System starts
2. Read the list of enabled plugins
3. Register plugin service providers
4. Load plugin routes
5. Initialize plugins
6. Execute plugin Boot methodsPlugin Directory Structure
A typical plugin directory structure is as follows:
PluginName/
├── Boot.php # Plugin bootstrap class (required)
├── config.json # Plugin metadata (required)
├── fields.php # Configuration field definitions (optional)
├── composer.json # Composer dependencies (optional)
├── Controllers/ # Controllers (optional)
│ ├── Front/ # Front-end controllers
│ └── Panel/ # Back-end controllers
├── Lang/ # Language packs (required)
│ ├── en/ # English
│ │ ├── common.php
│ │ ├── front.php
│ │ └── panel.php
│ └── zh-cn/ # Simplified Chinese
│ ├── common.php
│ ├── front.php
│ └── panel.php
├── Models/ # Data models (optional)
├── Migrations/ # Database migrations (optional)
├── Routes/ # Route definitions (optional)
│ ├── front.php # Front-end routes
│ ├── front-api.php # Front-end API routes
│ ├── panel.php # Panel routes
│ └── panel-api.php # Panel API routes
├── Services/ # Service classes (optional)
├── Libraries/ # Utility classes (optional)
├── Views/ # Blade templates (optional)
│ ├── front/ # Front-end views
│ └── panel/ # Panel views
├── Public/ # Public assets (optional)
│ ├── css/
│ ├── js/
│ └── images/
├── Repositories/ # Data repositories (optional)
└── Middleware/ # Middleware (optional)Route file naming
In the Routes/ directory, file names are significant. The system auto-loads routes based on the file name. See Route File Auto-Loading Convention for details.
config.json
Basic Format
Every plugin must have a config.json in its root directory:
{
"code": "partner_link",
"name": {
"zh-cn": "友情链接",
"en": "Partner Links"
},
"description": {
"zh-cn": "友情链接",
"en": "Partner Links"
},
"type": "feature",
"version": "v1.0.0",
"icon": "/image/logo.png",
"author": {
"name": "InnoShop",
"email": "edward@innoshop.com"
},
"panel_route": "partner_links.index"
}Plugin Type (type)
The type field must be one of the following valid values, otherwise plugin installation will fail:
| type | Description |
|---|---|
payment | Payment method |
shipping | Shipping method |
price | Product price |
orderfee | Order fee |
marketing | Marketing / promotions |
service | External service |
feature | Feature extension |
Old types are remapped
The following legacy types are automatically mapped at load time, but you should use the canonical types in new plugins:
| Legacy type | Maps to |
|---|---|
billing | payment |
fee | orderfee |
discount | orderfee |
translator | service |
intelli | service |
social | feature |
language | feature |
If your plugin still uses billing, please update it to payment.
Language Locale Format
Language codes in config.json and Lang/ directories use hyphen format (e.g. zh-cn), not underscore format (e.g. ).zh_cn
# Correct
"name": { "zh-cn": "Alipay", "en": "Alipay" }
Lang/zh-cn/common.php
# Wrong
"name": { "zh_cn": "Alipay", "en": "Alipay" }
Lang/zh_cn/common.phppanel_route Field
panel_route is the route name that the admin panel navigates to when clicking the plugin in the plugin list. Without it, the admin panel cannot open the plugin's management page.
// Missing panel_route -- clicking the plugin goes nowhere
{
"code": "service_bot",
"name": {"en": "ServiceBot"}
}
// Correct -- includes panel_route
{
"code": "service_bot",
"name": {"en": "ServiceBot"},
"panel_route": "servicebot.conversations"
}fields.php -- Configuration Fields
A simple fields.php:
<?php
return [
[
'name' => 'type',
'label' => 'Type',
'label_key' => 'common.type',
'type' => 'select',
'options' => [
['value' => 'fixed', 'label_key' => 'common.fixed'],
['value' => 'percent', 'label_key' => 'common.percent'],
],
'required' => true,
'rules' => 'required',
],
[
'name' => 'value',
'label_key' => 'common.value',
'type' => 'string',
'required' => true,
'rules' => 'required',
],
];Field Types
| Type | Description |
|---|---|
bool | Boolean toggle switch |
checkbox | Checkbox, for multiple selection scenarios (requires options) |
image | Image upload, saves the uploaded image path |
multi-rich-text | Multi-language rich text editor |
multi-string | Multi-language single-line text input |
multi-textarea | Multi-language multi-line text input |
rich-text | Rich text editor |
select | Dropdown selection |
string | Single-line text input |
textarea | Multi-line text input |
Common Field Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Field name, used for form submission and storage |
label | string | No* | Display label (use label_key for i18n instead) |
label_key | string | No* | Language key for the label, auto-resolved from Lang/{locale}/ |
type | string | Yes | Field type (see above) |
required | boolean | No | Whether the field is required |
rules | string | No | Laravel validation rules |
description | string | No | Help text below the field |
options | array | No | Options for select/checkbox fields |
recommend_size | string | No | Recommended image dimensions (e.g. '200x100') |
* Use either label or label_key. Prefer label_key for multi-language support.
Multi-language Fields
Multi-language field types (multi-string, multi-textarea, multi-rich-text) require:
[
'name' => 'title',
'label_key' => 'panel.field_title',
'type' => 'multi-string',
'is-locales' => true,
'multiple' => true,
],options Format
For select and checkbox types, options must use the array-of-objects format:
// Wrong -- key-value format (deprecated)
'options' => [
'value1' => 'Label 1',
'value2' => 'Label 2',
]
// Correct -- array format
'options' => [
['value' => 'value1', 'label' => 'Label 1'],
['value' => 'value2', 'label' => 'Label 2'],
]bool vs checkbox
- Use
boolfor simple on/off toggles (nooptionsneeded). - Use
checkboxfor multi-select choices (requiresoptionsarray).
// On/off switch
['name' => 'enabled', 'label_key' => 'panel.enabled', 'type' => 'bool']
// Multi-select
['name' => 'features', 'type' => 'checkbox', 'options' => [
['value' => 'a', 'label' => 'Feature A'],
['value' => 'b', 'label' => 'Feature B'],
]]Reading Configuration Values
Whether in a model, view, or controller, you can use the plugin_setting() helper to retrieve configuration values:
plugin_setting('pluginName', 'configKey')Boot.php -- Plugin Bootstrap Class
The Boot.php file is the plugin's entry point. It contains an init() method where you register hooks, listeners, and other bootstrap logic.
Standard Template
<?php
namespace Plugin\PartnerLink;
use Plugin\PartnerLink\Services\PluginService;
class Boot
{
public function init(): void
{
// 1. Register a panel sidebar menu item
listen_hook_filter('panel.component.sidebar.plugin.routes', function ($data) {
$data[] = [
'route' => 'partner_links.index',
'title' => trans('PartnerLink::common.menu_title'),
];
return $data;
});
// 2. Register payment processing (for payment plugins)
listen_hook_filter('service.payment.mobile_pay.data', function ($data) {
$order = $data['order'];
if ($order->payment_method_code != 'partner_link') {
return $data;
}
$data['params'] = (new PluginService($order))->getPaymentData();
return $data;
});
// 3. Register a blade insert point
listen_blade_insert('checkout.confirm.before', function ($data) {
return view('PartnerLink::front.widget', $data);
});
// 4. Register fee calculation (for marketing/fee plugins)
listen_hook_filter('service.checkout.fee.methods', function ($classes) {
$classes[] = PluginFee::class;
return $classes;
});
}
}Common Hook Points
| Hook name | Trigger location | Parameter type |
|---|---|---|
panel.component.sidebar.plugin.routes | Panel sidebar menu registration | array |
service.payment.mobile_pay.data | Mobile payment data | array |
service.checkout.fee.methods | Checkout fee calculation | array |
checkout.confirm.before | Before checkout confirmation | array |
service.state_machine.machines | State machine handling | array |
Do not register routes in Boot.php
InnoShop plugin routes are auto-loaded from the Routes/ directory. Do not call $this->loadRoutesFrom() in Boot.php -- BaseBoot does not have this method (it belongs to ServiceProvider). See Route File Auto-Loading Convention.
Routes
Route File Auto-Loading Convention
Route files placed in the Routes/ directory are automatically discovered and loaded by the system. The file name determines the route group:
| File name | Path prefix | Middleware group |
|---|---|---|
Routes/panel.php | {panel}/ | panel, admin_auth |
Routes/panel-api.php | api/panel/ | panel_api |
Routes/front.php | /{locale}/ | front |
Routes/front-api.php | api/ | api, auth:sanctum |
Routes/root.php | / | front |
Routes/main.php | / | front |
Front-end Routes (Routes/front.php)
<?php
use Plugin\PartnerLink\Controllers\Front\PluginController;
use Illuminate\Support\Facades\Route;
Route::group(['prefix' => 'partner'], function () {
Route::get('/', [PluginController::class, 'index'])->name('partner.front.index');
Route::post('/submit', [PluginController::class, 'submit'])->name('partner.front.submit');
});Panel Routes (Routes/panel.php)
<?php
use Plugin\PartnerLink\Controllers\Panel\PluginController;
use Illuminate\Support\Facades\Route;
Route::group(['prefix' => 'partner'], function () {
Route::get('/', [PluginController::class, 'index'])->name('partner.panel.index');
Route::get('/create', [PluginController::class, 'create'])->name('partner.panel.create');
Route::post('/', [PluginController::class, 'store'])->name('partner.panel.store');
Route::get('/{id}/edit', [PluginController::class, 'edit'])->name('partner.panel.edit');
Route::put('/{id}', [PluginController::class, 'update'])->name('partner.panel.update');
Route::delete('/{id}', [PluginController::class, 'destroy'])->name('partner.panel.destroy');
});Route Helper Functions
// Correct
front_route('product.index') // Front-end route
panel_route('product.index') // Panel route
// Wrong
route('product.index') // Do NOT use bare route()Controllers
Front-end Controller
<?php
namespace Plugin\PartnerLink\Controllers\Front;
use App\Http\Controllers\Controller;
use Plugin\PartnerLink\Services\PluginService;
use Illuminate\Http\Request;
class PluginController extends Controller
{
protected PluginService $pluginService;
public function __construct(PluginService $pluginService)
{
$this->pluginService = $pluginService;
}
public function index()
{
$data = $this->pluginService->getFrontData();
return view('PartnerLink::front.index', $data);
}
public function submit(Request $request)
{
$validated = $request->validate([
'field' => 'required|string',
]);
$result = $this->pluginService->processSubmission($validated);
if ($request->ajax()) {
return json_success('Submitted successfully', $result);
}
return redirect()->back()->with('success', 'Submitted successfully');
}
}Panel Controller
<?php
namespace Plugin\PartnerLink\Controllers\Panel;
use App\Http\Controllers\Controller;
use Plugin\PartnerLink\Repositories\PluginRepo;
use Plugin\PartnerLink\Services\PluginService;
use Illuminate\Http\Request;
class PluginController extends Controller
{
protected PluginService $pluginService;
protected PluginRepo $pluginRepo;
public function __construct(PluginService $pluginService, PluginRepo $pluginRepo)
{
$this->pluginService = $pluginService;
$this->pluginRepo = $pluginRepo;
}
public function index()
{
$plugins = $this->pluginRepo->all();
return view('PartnerLink::panel.index', compact('plugins'));
}
public function store(Request $request)
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'content' => 'required|string',
]);
$this->pluginService->create($validated);
return redirect(panel_route('partner.panel.index'))
->with('success', 'Created successfully');
}
}Views
Layout Inheritance
{{-- Correct --}}
@extends('panel::layouts.app')
{{-- Wrong --}}
@extends('admin::layouts.master')Panel views must be placed in Views/panel/, not Views/admin/.
Blade Indentation
Blade files must use 2-space indentation. The project's .editorconfig already configures [*.blade.php] with 2-space indentation -- make sure your editor respects it.
Static Asset References
Use the plugin_asset() helper to reference static resources:
// Wrong -- direct asset() will 404
asset('plugins/PartnerLink/Public/js/widget.js')
// Correct -- plugin_asset() copies from Public/ to public/static/plugins/ automatically
plugin_asset('partner_link', 'js/widget.js')
// Outputs: http://domain.com/static/plugins/partner_link/js/widget.jsLanguage Packs
Directory Structure
Language packs are stored in the Lang/ directory using hyphen-format locale codes:
Lang/
├── en/
│ ├── common.php
│ ├── front.php
│ └── panel.php
└── zh-cn/
├── common.php
├── front.php
└── panel.phpUsing Translations
// Inside the plugin (note the double-colon syntax)
trans('PartnerLink::common.menu_title');
trans('PartnerLink::panel.field_title');
// System translations (slash syntax)
trans('common/button_submit');
trans('panel/menu_dashboard');
trans('front/product_detail');In Blade:
{{ trans('PartnerLink::common/plugin_name') }}
{{ trans('common/button_submit') }}
@lang('PartnerLink::common/message_save_success')Development Standards
Naming Conventions
| Type | Rule | Example |
|---|---|---|
| Plugin directory | PascalCase | AddToAny, BankTransfer |
| Controller file | PascalCase + Controller | PluginController.php |
| Model file | PascalCase | Plugin.php |
| View directory | lowercase | front/, panel/ |
| Language file | lowercase + underscore | common.php, front.php |
| Route file | lowercase + hyphen | front-api.php, panel.php |
Code Copyright Header
All PHP files must include the standard header:
<?php
/**
* Copyright (c) Since 2024 InnoShop - All Rights Reserved
*
* @link https://www.innoshop.com
* @author InnoShop <team@innoshop.com>
* @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0)
*/Controller Method Conventions
public function index() // List page
public function show() // Detail page
public function create() // Create form
public function store() // Save new
public function edit() // Edit form
public function update() // Save update
public function destroy() // DeleteError Handling in Controllers
Always use try-catch in action methods and always return a view:
public function index()
{
try {
$service = new PluginService;
$data = $service->getData();
return view('PluginName::index', compact('data'));
} catch (Exception $e) {
session()->flash('error', $e->getMessage());
return view('PluginName::index', ['data' => collect()]);
}
}Common Issues and Important Notes
Panel Axios Response Unwrapping
The Panel's axios instance is processed by the panel-http.js interceptor, which automatically unwraps one layer of the response. In the then callback, res is already the JSON object returned by the backend (e.g. {success: true, message: ...}), so you do not need to access it via res.data.
// Wrong -- extra .data layer
axios.post(url, params).then(function (res) {
if (res.data && res.data.success) { ... }
});
// Correct -- res is already response.data
axios.post(url, params).then(function (res) {
if (res && res.success) { ... }
});Error in catch is NOT unwrapped
The error in catch is still the raw axios error object. Use error.response.data to access the error message.
JavaScript Code Placement: Use @push('footer')
You must use @push('footer'), NOT @push('scripts'). InnoShop's layout system renders deferred JavaScript via @stack('footer').
{{-- Correct --}}
@push('footer')
<script>
$(document).ready(function() {
console.log('jQuery works correctly');
});
</script>
@endpush
{{-- Wrong -- code will NOT execute --}}
@push('scripts')
<script>
$(document).ready(function() {
console.log('This will never run');
});
</script>
@endpushInjecting Front-end Assets from Boot.php
When you need to inject JS/CSS into front-end pages from a plugin, use View::creator with the correct view name (layouts.app, not front::layouts.app):
// In Boot.php
private function registerFrontendWidget(): void
{
View::creator('layouts.app', function ($view) {
static $pushed = false;
if ($pushed) return;
$pushed = true;
$js = plugin_asset('service_bot', 'js/widget.js');
$css = plugin_asset('service_bot', 'css/widget.css');
$html = <<<HTML
<link rel="stylesheet" href="{$css}">
<script src="{$js}"></script>
<script>MyWidget.init({...});</script>
HTML;
$view->getFactory()->startPush('footer', $html);
});
}Note the static $pushed guard to prevent duplicate injection.
Pitfalls and Lessons Learned
The following issues were all encountered during real plugin development. They are documented here to help you avoid repeating the same mistakes.
1. PSR-4 Namespace Case Mismatch
Problem: The directory is named MambaSms, but the namespace uses Plugin\MambaSMS. This causes the autoloader to skip the entire plugin.
Class Plugin\MambaSMS\Boot located in ./plugins/MambaSms/Boot.php does not comply with psr-4 autoloading standard. Skipping.Rule: The namespace must match the directory name exactly. MambaSms directory -> Plugin\MambaSms namespace.
// Wrong: namespace case does not match directory
// Directory: plugins/MambaSms/
namespace Plugin\MambaSMS;
// Correct: matches directory exactly
namespace Plugin\MambaSms;2. Manually Registering Routes in Boot.php
Problem: Calling $this->loadRoutesFrom() in Boot.php causes a 500 error because BaseBoot does not have this method (it belongs to ServiceProvider).
// Wrong: BaseBoot does not have loadRoutesFrom
public function init(): void
{
$this->loadRoutesFrom(__DIR__ . '/Routes/admin.php');
}
// Correct: Boot.php does not need any route registration code
public function init(): void
{
// Routes are auto-loaded by PluginServiceProvider from the Routes/ directory.
// Only register event listeners, hooks, etc. here.
}3. Route File Naming Does Not Match Convention
Problem: Route files named admin.php or api.php are not recognized by InnoShop's auto-loader.
// Wrong -- not recognized
Routes/admin.php
Routes/api.php
// Correct
Routes/panel.php -> Panel routes
Routes/panel-api.php -> Panel API routes
Routes/front.php -> Front-end routes
Routes/front-api.php -> Front-end API routes4. fields.php Format Errors
4a. Using nested groups
// Wrong: InnoShop does not support nested group format
return [
'general' => [
['name' => 'enabled', ...],
],
];
// Correct: flat array
return [
['name' => 'enabled', ...],
];4b. checkbox type missing options
// Wrong: checkbox requires options array, otherwise "Undefined array key options"
['name' => 'enabled', 'type' => 'checkbox', 'value' => true]
// Correct option 1: use bool type for on/off (no options needed)
['name' => 'enabled', 'type' => 'bool']
// Correct option 2: checkbox with options
['name' => 'features', 'type' => 'checkbox', 'options' => [
['value' => 'a', 'label' => 'A'],
]]4c. Using label instead of label_key
// Wrong: hardcoded label, no i18n support
['name' => 'enabled', 'label' => 'Enable Plugin', 'type' => 'bool']
// Correct: use label_key to reference language file
['name' => 'enabled', 'label_key' => 'panel.enabled', 'type' => 'bool']label_key automatically resolves from Lang/{locale}/panel.php.
5. Panel View Directory and Layout Name
{{-- Wrong --}}
{{-- 1. Views placed in Views/admin/ instead of Views/panel/ --}}
{{-- 2. Using @extends('admin::layouts.master') --}}
@extends('admin::layouts.master')
{{-- Correct --}}
{{-- 1. Views placed in Views/panel/ --}}
{{-- 2. Using @extends('panel::layouts.app') --}}
@extends('panel::layouts.app')6. Plugin Static Asset Path Errors
// Wrong: direct asset() will 404
asset('plugins/ServiceBot/Public/js/widget.js')
// Correct: plugin_asset() copies to public/static/plugins/ automatically
plugin_asset('service_bot', 'js/widget.js')7. Local Development Queue Without Redis
If you do not have the Redis extension installed locally, set QUEUE_CONNECTION=sync in .env:
# Local development -- sync, no Redis needed
QUEUE_CONNECTION=sync
# Production
# QUEUE_CONNECTION=redisAlso make sure to kill any lingering php artisan queue:work redis processes, otherwise they will continuously log Class "Redis" not found errors:
ps aux | grep queue:work
kill <PID>Plugin Activation and Deactivation
Plugins are activated and deactivated through InnoShop's admin panel (Panel > Plugins).
Deployment Checklist
Before publishing a plugin, verify the following:
- [ ] All required files are present (
config.json,Boot.php) - [ ] Language packs are complete (at minimum:
enandzh-cn) - [ ]
config.jsonuses correct type (see Plugin Type) - [ ]
config.jsonlocale keys use hyphen format (zh-cn, notzh_cn) - [ ]
panel_routeis set if the plugin has a management page - [ ] All PHP files have the copyright header
- [ ] Routes use correct file names (
panel.php,front.php, etc.) - [ ] Views extend
panel::layouts.app(notadmin::layouts.master) - [ ] JavaScript uses
@push('footer')(not@push('scripts')) - [ ] Panel axios callbacks access
res.successdirectly (notres.data.success) - [ ] Static assets use
plugin_asset()helper