🌐 Language: 🇻🇳 Tiếng Việt · 🇬🇧 English (current)
Creating a plugin for GP247
Introduction
This document is the starting point for PHP/Laravel developers who want to write a GP247 plugin (an add-on feature package you can enable, disable and remove from the admin). This page walks through 6 steps to build a plugin with a settings screen that installs and updates to new versions without losing the store owner's settings. Deeper topics live on their own pages, listed right below. After reading, you have a working plugin and know which page to read next for your kind of plugin.
Pick the right guide
| You are building… | Read |
|---|---|
| An admin feature, a display block, an ordinary plugin | This page |
| A payment method (bank transfer, online gateway) | Creating a payment plugin |
| A shipping method (price table, carrier with an API) | Creating a shipping plugin |
| Coupons / loyalty points, an own storefront page, layout blocks, per-store settings, secret keys | Advanced plugins |
| Own data tables, data migration on a new version | Update-safe plugins |
| Upgrading a plugin written for GP247 1.x | Converting a plugin from v1 to v2 |
| A storefront look (template), not a plugin | Creating a template |
💡 Working with an AI agent? Use GP247's skills from the gp247-skills repository (copy the skill folder into your agent's skills folder, for example
.claude/skills/for Claude Code):gp247-plugin-create(ordinary plugins),gp247-payment-plugin-create(payment),gp247-shipping-plugin-create(shipping). Each skill probes the site's version and follows the steps below.
Roadmap
flowchart LR
S1["1 · Scaffold<br/>gp247:make-plugin"] --> S2["2 · Declare<br/>gp247.json"]
S2 --> S3["3 · Store settings<br/>in the database"]
S3 --> S4["4 · Admin screen<br/>+ routes"]
S4 --> S5["5 · Install<br/>and test"]
S5 --> S6["6 · Release<br/>a new version"]
Three things to keep in mind all the way:
- An update overwrites every file of the plugin and keeps the database. Everything a store owner changes must live in the database.
- Every release increases
version;configKeynever changes. - Never edit
vendor/gp247/*. A plugin only "plugs in" through the existing seams.
Prerequisites
-
A running GP247 3.x website, and permission to run
php artisan(a terminal on your development machine or server). -
Check the core version — you need it in Step 2:
php artisan gp247:infoExpected result: a
coreline such as3.1.
| Term | Meaning |
|---|---|
| Plugin (extension) | An add-on package, located in app/GP247/Plugins/<Name> |
configKey |
The plugin's unique id, equal to its folder name |
admin_config |
The configuration table in the database — where settings survive updates |
| Livewire | Laravel's way of building dynamic screens; the GP247 admin uses Livewire + Tailwind (TailAdmin), no jQuery |
Step 1 — Scaffold the plugin
-
Open a terminal in the website's root folder (where
artisanis) and run (replaceMyPluginwith your name, written as one word, each word capitalised):php artisan gp247:make-plugin --name=MyPlugin --download=0Expected result: a
Successline, and the folderapp/GP247/Plugins/MyPluginis created. (--download=1builds a.zipinstorage/tmpfor distribution instead.) -
Look at the generated files:
| File | What it is for | What you do |
|---|---|---|
gp247.json |
Declares the plugin (name, version, compatibility) | Edit — Step 2 |
AppConfig.php |
Install, uninstall, enable, disable, update | Edit — Step 3 |
Livewire/AdminLivewire.php |
Admin screen | Edit — Step 4 |
Route.php, Provider.php |
Register routes, languages, views | Edit — Step 4 |
Lang/vi/lang.php, Lang/en/lang.php |
Display text | Edit — Step 4 |
Admin/AdminController.php, Views/Admin.blade.php |
Old-style admin screen (backward compatibility) | Delete once you use Livewire |
Controllers/FrontController.php, Views/Front.blade.php, Seo.php |
Public page and sitemap | Delete if the plugin is admin-only — see Advanced plugins |
Models/ExtensionModel.php |
Create/drop the plugin's own tables | Keep if you have own tables — see Update-safe plugins |
config.php, function.php |
Defaults, helper functions | Never let the store owner edit config.php |
Step 2 — Declare gp247.json
{
"name": "My plugin",
"image": "images/logo.jpg",
"auth": "Your name",
"email": "you@example.com",
"link": "https://example.com",
"configGroup": "Plugins",
"configCode": "MyPlugin",
"configKey": "MyPlugin",
"version": "1.0",
"requireCore": ["3.1"],
"requireUpdateFrom": "1.0",
"requireComposerPackages": [],
"requireGp247Extensions": [],
"requireLivewire": false,
"storeScope": "global"
}
| Field | Meaning |
|---|---|
configKey |
Equal to the folder name. Never changes after release. |
configCode |
The plugin's role: "Payment", "Shipping", "Promotion" (coupons); any other plugin uses the same value as configKey |
version |
1.0, 1.1, 2.0… Every release must be greater than the previous one |
requireCore |
Core versions it runs on: write the core line of gp247:info, e.g. ["3.1"] |
requireUpdateFrom |
The lowest installed version allowed to update straight to this one; "1.0" blocks nothing |
requireComposerPackages |
Required Composer packages, e.g. ["gp247/shop"] for a selling plugin |
requireGp247Extensions |
Other GP247 plugins that must be installed first (by configKey) |
storeScope |
"global" (one shared setting) or "store" (one setting per store) |
⚠️
requireCoreis a range per major version."3.1"means "from 3.1 upward, within the 3.x line" — it installs on 3.1, 3.2…, but is refused on 4.x. Writing"2.1"makes a 3.x core refuse to install the plugin. To support several major lines, write one value per line, e.g.["3.1", "4.0"]; never write a patch number such as"3.1.2".
Step 3 — Store settings in the database
An update overwrites every file (including config.php) but keeps the database. So each setting the store owner
may change is one row in the admin_config table, keyed <configKey>_<name>, created on install and topped up on
update. Replace the whole AppConfig.php:
<?php
namespace App\GP247\Plugins\MyPlugin;
use GP247\Core\ExtensionConfigDefault;
use GP247\Core\Models\AdminConfig;
use GP247\Core\Models\AdminHome;
class AppConfig extends ExtensionConfigDefault
{
/** Settings the site owner edits: key => default. */
public const SETTINGS = [
'enabled_on_home' => '1',
'items_per_page' => '20',
];
public function __construct()
{
$config = json_decode(file_get_contents(__DIR__.'/gp247.json'), true);
$this->configGroup = $config['configGroup'];
$this->configKey = $config['configKey'];
$this->configCode = $config['configCode'];
$this->requireCore = $config['requireCore'] ?? [];
$this->requireComposerPackages = $config['requireComposerPackages'] ?? [];
$this->requireGp247Extensions = $config['requireGp247Extensions'] ?? [];
$this->appPath = $this->configGroup.'/'.$this->configKey;
$this->title = trans($this->appPath.'::lang.title');
$this->image = $this->appPath.'/'.$config['image'];
$this->version = $config['version'];
$this->auth = $config['auth'];
$this->link = $config['link'];
}
/**
* Every admin_config row this plugin owns: the on/off row + one row per setting.
*/
private function seedRows(): array
{
$rows = [[
'group' => $this->configGroup, 'code' => $this->configCode, 'key' => $this->configKey,
'sort' => 0, 'store_id' => GP247_STORE_ID_GLOBAL, 'value' => self::ON,
'security' => 0, 'detail' => $this->appPath.'::lang.title',
]];
$sort = 0;
foreach (self::SETTINGS as $key => $default) {
$rows[] = [
'group' => $this->configGroup, 'code' => $this->configKey.'_config',
'key' => $this->configKey.'_'.$key, 'sort' => ++$sort,
'store_id' => GP247_STORE_ID_GLOBAL, 'value' => $default,
'security' => 0, 'detail' => $this->appPath.'::lang.'.$key,
];
}
return $rows;
}
public function install()
{
if (AdminConfig::where('group', $this->configGroup)->where('key', $this->configKey)->exists()) {
return ['error' => 1, 'msg' => gp247_language_render('admin.extension.plugin_exist')];
}
try {
AdminConfig::insert($this->seedRows());
return ['error' => 0, 'msg' => gp247_language_render('admin.extension.install_success')];
} catch (\Throwable $e) {
return ['error' => 1, 'msg' => $e->getMessage()];
}
}
/**
* Add the rows a newer version introduced; never touch a value the site owner saved.
*/
public function update(?string $fromVersion = null)
{
try {
foreach ($this->seedRows() as $row) {
$exists = AdminConfig::where('group', $row['group'])->where('key', $row['key'])
->where('store_id', (string) $row['store_id'])->exists();
if (!$exists) {
AdminConfig::insert($row);
}
}
return ['error' => 0, 'msg' => ''];
} catch (\Throwable $e) {
return ['error' => 1, 'msg' => $e->getMessage()];
}
}
public function uninstall()
{
try {
AdminConfig::where('group', $this->configGroup)->where(function ($q) {
$q->where('key', $this->configKey)
->orWhere('key', 'like', $this->configKey.'\_%')
->orWhere('code', $this->configKey.'_config');
})->delete();
AdminHome::where('extension', $this->appPath)->delete();
return ['error' => 0, 'msg' => gp247_language_render('admin.extension.uninstall_success')];
} catch (\Throwable $e) {
return ['error' => 1, 'msg' => $e->getMessage()];
}
}
public function enable()
{
AdminConfig::where('group', $this->configGroup)->where('key', $this->configKey)->update(['value' => self::ON]);
return ['error' => 0, 'msg' => gp247_language_render('admin.extension.enable_success')];
}
public function disable()
{
AdminConfig::where('group', $this->configGroup)->where('key', $this->configKey)->update(['value' => self::OFF]);
return ['error' => 0, 'msg' => ''];
}
// The ⚙ (Config) button on the Plugins list appears only when this returns a redirect.
public function clickApp()
{
return redirect(gp247_route_admin('admin_myplugin.index'));
}
public function setupStore($storeId = null)
{
}
public function removeStore($storeId = null)
{
}
/**
* A setting for a store (falls back to the shared value).
*/
public function setting(string $key, $storeId = null): string
{
$storeId ??= gp247_plugin_store_id();
return (string) gp247_config($this->configKey.'_'.$key, $storeId, self::SETTINGS[$key] ?? '');
}
public function getInfo()
{
return [
'title' => $this->title,
'key' => $this->configKey,
'code' => $this->configCode,
'image' => $this->image,
'permission' => self::ALLOW,
'version' => $this->version,
'auth' => $this->auth,
'link' => $this->link,
'appPath' => $this->appPath,
];
}
}
| Method | When it runs | What it does |
|---|---|---|
install() |
The store owner clicks Install | Writes the on/off row + one row per setting |
update(?string $fromVersion) |
After the files were replaced by a newer version (1-click, git pull, Composer) |
Only adds missing rows; safe to run again; may receive null |
uninstall() |
The store owner clicks Uninstall | Deletes every row of the plugin |
enable() / disable() |
Enable / disable | Switches the flag, deletes nothing |
clickApp() |
The Plugins list draws the ⚙ button | Returns a redirect to the settings screen — returning null means no button |
setting() |
Your code needs a value | Reads it for a store, falling back to the shared value |
A setting is a secret (API key, password)? Create its row with 'security' => 1 and declare the field as password in
Step 4 — the core encrypts it and never sends it back to the browser. Details: Advanced plugins.
Step 4 — Admin screen and routes
Settings screen — extending the core's ConfigForm gives you the layout, permissions, Save button and store picker.
Replace Livewire/AdminLivewire.php:
<?php
namespace App\GP247\Plugins\MyPlugin\Livewire;
use App\GP247\Plugins\MyPlugin\AppConfig;
use GP247\Core\AdminShell\Infrastructure\ConfigForm;
/**
* Settings screen: one field per row seeded by AppConfig::install().
*/
class AdminLivewire extends ConfigForm
{
protected ?string $permission = null;
protected function group(): string
{
return 'Plugins';
}
protected function heading(): string
{
return trans('Plugins/MyPlugin::lang.title');
}
// Required: without keys() the screen would list — and save — nothing of yours.
protected function keys(): array
{
return array_map(fn ($key) => 'MyPlugin_'.$key, array_keys(AppConfig::SETTINGS));
}
protected function fieldTypes(): array
{
return [
'MyPlugin_enabled_on_home' => 'bool',
'MyPlugin_items_per_page' => 'number',
];
}
}
Field types: bool (switch), number, select (with fieldOptions()), password, text (default). You can add
fieldHints() (a hint next to a field) and sections() (titled blocks). A plugin that needs a screen other than settings
extends GP247\Core\AdminShell\Infrastructure\GP247AdminComponent and writes its own view.
Routes — replace Route.php; point the admin path straight at the Livewire screen:
<?php
use Illuminate\Support\Facades\Route;
$config = json_decode(file_get_contents(__DIR__.'/gp247.json'), true);
if (gp247_extension_check_active($config['configGroup'], $config['configKey'])) {
Route::group(
[
'prefix' => GP247_ADMIN_PREFIX.'/myplugin',
'middleware' => GP247_ADMIN_MIDDLEWARE,
],
function () {
Route::get('/', \App\GP247\Plugins\MyPlugin\Livewire\AdminLivewire::class)
->name('admin_myplugin.index');
}
);
}
Then delete Admin/AdminController.php and Views/Admin.blade.php (the old-style screen).
Provider — Provider.php loads languages and views and registers the plugin's Livewire name:
<?php
$config = json_decode(file_get_contents(__DIR__.'/gp247.json'), true);
$extensionPath = $config['configGroup'].'/'.$config['configKey'];
$this->loadTranslationsFrom(__DIR__.'/Lang', $extensionPath);
if (gp247_extension_check_active($config['configGroup'], $config['configKey'])) {
$this->loadViewsFrom(__DIR__.'/Views', $extensionPath);
if (class_exists(\Livewire\Livewire::class)) {
\Livewire\Livewire::addNamespace('MyPlugin', classNamespace: 'App\\GP247\\Plugins\\MyPlugin\\Livewire');
}
}
Languages — every displayed text goes through trans('Plugins/MyPlugin::lang.<key>'), in both vi and en. The label of
each settings field is the key with the setting's name:
<?php
return [
'title' => 'My plugin',
'enabled_on_home' => 'Show on the home page',
'items_per_page' => 'Items per page',
];
UI rules: only <x-gp247::*> components, Livewire and Alpine; no jQuery, AdminLTE or select2.
Step 5 — Install and test
-
Clear the caches to load the new files:
php artisan optimize:clear -
Install the plugin: Admin → Plugins → MyPlugin → Install, or with a command:
php artisan gp247:ext-install --type=plugin --key=MyPluginExpected result: the plugin shows as enabled.
-
Click ⚙ Config, change a value, click Save, reload the page — the value is still there.
-
Try enable / disable; open the admin screen in both light and dark mode.
-
Try uninstall then install again: the reinstall must create every settings row (and tables, menu if any).
⚠️ Uninstall a plugin you are developing with
--only-data:php artisan gp247:ext-uninstall --type=plugin --key=MyPlugin --only-dataA plain uninstall (or the Uninstall button without "only remove data") also deletes the plugin's source folder.
Step 6 — Release a new version
-
Increase
versioningp247.json(e.g.1.0→1.1). -
The new version adds a setting? Add the key to
SETTINGS—update()creates the missing row and keeps old values. -
The new version adds a column / table or changes data? Write it in
update()following Update-safe plugins. -
Test it the way a store owner updating with
git pullwould:php artisan gp247:ext-update --type=plugin --key=MyPlugin --local --dry-runExpected result: the plugin is listed as pending; drop
--dry-runto runupdate()for real.
Pre-release checklist
-
gp247.json:configKeyequals the folder name;requireCorefollowsgp247:info;versionincreased. - Every setting the store owner edits lives in
admin_config;config.phponly holds the author's defaults. -
install()/uninstall()are symmetric;update()is safe to re-run and works withnull. -
clickApp()opens the settings screen;keys()lists every field; secrets arepasswordfields. - The admin screen uses Livewire +
<x-gp247::*>, no jQuery; text goes throughtrans()in bothvianden. - Ran
optimize:clear, then install / enable / disable / uninstall--only-data/ reinstall successfully. - Payment / shipping plugins: followed the dedicated guide for that kind.
Conditions & Rules (know before you act)
When installing
- The core refuses to install when
requireCoredoes not match the running core line (a range per major version) — so a plugin never runs on a core it was not tested on. - Installing while the on/off row is still in the database is refused ("plugin already exists") — uninstall first.
- A missing package in
requireComposerPackages/ plugin inrequireGp247Extensionsblocks the install.
When updating
- The new
versionmust be greater than the installed one, and the installed one not lower thanrequireUpdateFrom— otherwise the 1-click update refuses. - When
update()fails, the old version is restored (1-click update); withgit pullthe files are not restored, the update just stays "pending" so it can run again.
Q&A
Q1: Do I have to write every plugin file by hand?
→ No. php artisan gp247:make-plugin --name=YourPlugin --download=0 generates the skeleton; you only edit the files of Steps 2–4.
With an AI agent, use the gp247-plugin-create skill.
Q2: Must configKey equal the folder name?
→ Yes. It is the id used to match the old and the new version on update, so it must not change after release either.
Q3: The store owner edited config.php and lost everything after an update — why?
→ An update overwrites every file. Settings must live in the database as in Step 3.
Q4: The core says "not compatible" when I install the plugin?
→ requireCore does not match. Run php artisan gp247:info and write its core line (e.g. ["3.1"]). The old value "2.1" cannot be installed on a 3.x core.
Q5: No ⚙ Config button on the Plugins list?
→ clickApp() returns null (the skeleton's default). Make it return a redirect to the settings screen as in Step 3.
Q6: The settings screen shows no field, or Save saves nothing?
→ keys() is missing in the settings screen, or install() did not create rows for those keys (uninstall with --only-data, then install again).
Q7: My new version adds a database column — where do I handle it?
→ In update(), checking the real state (Schema::hasColumn) rather than only the version number. See Update-safe plugins.
Q8: May I use jQuery or select2 on the admin screen?
→ No. The GP247 admin does not load jQuery; use Livewire / Alpine and <x-gp247::*> components.
Q9: Should version be 1.0.0 or 1.0?
→ Any numeric form works, as long as each release is greater than the previous one (compared with version_compare), e.g. 1.0 → 1.1 → 2.0.
Q10: After updating, the admin still shows the old version?
→ Run php artisan optimize:clear and reload — Laravel still holds old caches.
Change history
| Date | GP247 version | Change |
|---|---|---|
| 2026-10-18 | gp247/core 3.1 | Restructured into a 6-step main page; split out Update-safe plugins and Advanced plugins; added payment and shipping plugin guides. Fixed: requireCore follows gp247:info (the old "2.1" is refused by a 3.x core); settings default to one row per key with a ConfigForm screen (holds secrets); added clickApp() for the Config button |
| 2026-09-29 | The AppConfig::update($fromVersion) hook now also runs when the site owner replaces files via git pull/Composer/manual copy (through gp247:update, gp247:ext-update --local, the Apply data update button); the first time it may receive null. |
📅 Last updated: 2026-10-18 · ✍️ Author: GP247