Skip to content

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 methods

Plugin 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:

json
{
    "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:

typeDescription
paymentPayment method
shippingShipping method
priceProduct price
orderfeeOrder fee
marketingMarketing / promotions
serviceExternal service
featureFeature 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 typeMaps to
billingpayment
feeorderfee
discountorderfee
translatorservice
intelliservice
socialfeature
languagefeature

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.php

panel_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.

json
// 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
<?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

TypeDescription
boolBoolean toggle switch
checkboxCheckbox, for multiple selection scenarios (requires options)
imageImage upload, saves the uploaded image path
multi-rich-textMulti-language rich text editor
multi-stringMulti-language single-line text input
multi-textareaMulti-language multi-line text input
rich-textRich text editor
selectDropdown selection
stringSingle-line text input
textareaMulti-line text input

Common Field Parameters

ParameterTypeRequiredDescription
namestringYesField name, used for form submission and storage
labelstringNo*Display label (use label_key for i18n instead)
label_keystringNo*Language key for the label, auto-resolved from Lang/{locale}/
typestringYesField type (see above)
requiredbooleanNoWhether the field is required
rulesstringNoLaravel validation rules
descriptionstringNoHelp text below the field
optionsarrayNoOptions for select/checkbox fields
recommend_sizestringNoRecommended 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:

php
[
    '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:

php
// 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 bool for simple on/off toggles (no options needed).
  • Use checkbox for multi-select choices (requires options array).
php
// 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:

php
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
<?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 nameTrigger locationParameter type
panel.component.sidebar.plugin.routesPanel sidebar menu registrationarray
service.payment.mobile_pay.dataMobile payment dataarray
service.checkout.fee.methodsCheckout fee calculationarray
checkout.confirm.beforeBefore checkout confirmationarray
service.state_machine.machinesState machine handlingarray

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 namePath prefixMiddleware group
Routes/panel.php{panel}/panel, admin_auth
Routes/panel-api.phpapi/panel/panel_api
Routes/front.php/{locale}/front
Routes/front-api.phpapi/api, auth:sanctum
Routes/root.php/front
Routes/main.php/front

Front-end Routes (Routes/front.php)

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
<?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

php
// 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
<?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
<?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

blade
{{-- 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:

php
// 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.js

Language 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.php

Using Translations

php
// 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:

blade
{{ trans('PartnerLink::common/plugin_name') }}
{{ trans('common/button_submit') }}
@lang('PartnerLink::common/message_save_success')

Development Standards

Naming Conventions

TypeRuleExample
Plugin directoryPascalCaseAddToAny, BankTransfer
Controller filePascalCase + ControllerPluginController.php
Model filePascalCasePlugin.php
View directorylowercasefront/, panel/
Language filelowercase + underscorecommon.php, front.php
Route filelowercase + hyphenfront-api.php, panel.php

All PHP files must include the standard header:

php
<?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

php
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()  // Delete

Error Handling in Controllers

Always use try-catch in action methods and always return a view:

php
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.

javascript
// 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.

You must use @push('footer'), NOT @push('scripts'). InnoShop's layout system renders deferred JavaScript via @stack('footer').

blade
{{-- 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>
@endpush

Injecting 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):

php
// 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.

php
// 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).

php
// 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 routes

4. fields.php Format Errors

4a. Using nested groups

php
// Wrong: InnoShop does not support nested group format
return [
    'general' => [
        ['name' => 'enabled', ...],
    ],
];

// Correct: flat array
return [
    ['name' => 'enabled', ...],
];

4b. checkbox type missing options

php
// 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

php
// 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

blade
{{-- 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

php
// 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=redis

Also make sure to kill any lingering php artisan queue:work redis processes, otherwise they will continuously log Class "Redis" not found errors:

bash
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: en and zh-cn)
  • [ ] config.json uses correct type (see Plugin Type)
  • [ ] config.json locale keys use hyphen format (zh-cn, not zh_cn)
  • [ ] panel_route is 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 (not admin::layouts.master)
  • [ ] JavaScript uses @push('footer') (not @push('scripts'))
  • [ ] Panel axios callbacks access res.success directly (not res.data.success)
  • [ ] Static assets use plugin_asset() helper