Plugin Structure
A plugin lives in plugins/{plugin-name}/ and has this structure:
plugins/my-plugin/
├── plugin.php # Entry point (required)
├── install.php # Optional: setup code run on first activation
├── admin/ # Admin back-end context
│ ├── controller/ # Admin controllers
│ ├── template/ # Admin .tpl templates
│ └── validate/ # Validation rules
├── app/ # Front-end context
│ ├── controller/ # Front-end controllers
│ ├── template/ # Front-end .tpl templates
│ └── validate/ # Validation rules
├── component/
│ └── my-widget.php # Custom data component
├── sql/ # SQL query models, one folder per engine
│ ├── mysqli/
│ ├── pgsql/
│ └── sqlite/
├── install/sql/ # Schema run on activation, one folder per engine
│ ├── mysqli/schema/
│ ├── pgsql/schema/
│ └── sqlite/schema/
├── public/ # Assets copied/symlinked to public/plugins/{slug}/ on activation
└── vendor/ # Plugin-specific dependencies (optional)
The folder (admin/ vs app/) selects the context in which a file is used; both use the same PHP namespace.
Reference plugins: plugins/insert-scripts (minimal), plugins/contact-form (component + database tables).
plugin.php Header
The entry point declares metadata in a block comment:
<?php
/*
Name: My Plugin
Slug: my-plugin
Category: utility
Url: https://example.com/my-plugin
Description: A brief description of what the plugin does.
Thumb: screenshot.svg
Author: Your Name
Version: 1.0
Author url: https://example.com
Settings: /admin/index.php?module=plugins/my-plugin/settings
*/
use Vvveb\System\Event;
if (! defined('V_VERSION')) {
die('Invalid request!');
}
class MyPluginPlugin {
public function admin() {
// admin-only initialization (runs only in the admin app)
}
public function app() {
// front-end initialization (runs only in the public app)
}
public function __construct() {
if (APP == 'app') {
$this->app();
} else if (APP == 'admin') {
$this->admin();
}
}
}
new MyPluginPlugin();
Register events inside admin() or app() so they only run in the relevant context; register context-independent events directly in the constructor.
Adding Routes
Plugins load before routes are matched, so you can register routes directly from the plugin's app() method:
use Vvveb\System\Routes;
public function app(): void {
Routes::addRoute('/my-page', [
'module' => 'plugins/my-plugin/index/index',
]);
// routes with an "edit" key can be opened in the page builder
Routes::addRoute('/my-page/{slug}', [
'module' => 'plugins/my-plugin/index/index',
'edit' => '/admin/?module=plugins/my-plugin/index',
]);
}
To add or remove routes defined elsewhere, hook into the Routes::init event instead:
public function app(): void {
Event::on('Vvveb\System\Routes', 'init', __CLASS__, function ($routes) {
unset($routes['/cart']); // remove an existing route
//listeners must return their parameters wrapped in an array
return [$routes];
});
}
Adding Admin Menu Items
Register admin menu items via the Base::init-menu event:
public function admin(): void {
$admin_path = \Vvveb\adminPath();
Event::on('Vvveb\Controller\Base', 'init-menu', __CLASS__, function ($menu) use ($admin_path) {
//entries are keyed by slug; child pages go into "items"
$menu['plugins']['items']['my-plugin'] = [
'name' => __('My Plugin'),
'url' => $admin_path . 'index.php?module=plugins/my-plugin/settings',
'icon' => 'iconoir-settings',
//'icon-img' => PUBLIC_PATH . 'plugins/my-plugin/my-plugin.svg',
'module' => 'plugins/my-plugin/settings',
'action' => 'index',
];
//listeners must return their parameters wrapped in an array
return [$menu];
});
}
Creating Controllers
Plugin controllers use the namespace Vvveb\Plugins\{Name}\Controller for both admin and front-end - the folder (admin/controller/ vs app/controller/) selects the context.
Admin Controller
// plugins/my-plugin/admin/controller/settings.php
namespace Vvveb\Plugins\MyPlugin\Controller;
use Vvveb\Controller\Base;
class Settings extends Base {
public function index() {
$this->view->settings = \Vvveb\getSetting('my-plugin');
}
public function save() {
if ($this->checkCsrf()) {
\Vvveb\setSetting('my-plugin', 'api_key', $this->request->post['api_key'] ?? '');
$this->view->success[] = __('Settings saved');
}
}
}
Access it at /admin/?module=plugins/my-plugin/settings (module path is relative to the plugin's context folders).
Front-End Controller
// plugins/my-plugin/app/controller/index.php
namespace Vvveb\Plugins\MyPlugin\Controller;
use Vvveb\Controller\Base;
class Index extends Base {
public function index() {
$this->view->data = 'Hello from my plugin';
}
}
Front-end controllers are reachable through routes added as shown above.
Creating Components
// plugins/my-plugin/component/my-widget.php
namespace Vvveb\Plugins\MyPlugin\Component;
use Vvveb\System\Component\ComponentBase;
class MyWidget extends ComponentBase {
public int $cacheExpire = 3600;
public static array $defaultOptions = [
'limit' => 5,
];
public function results(): array {
// Fetch data and return it
return ['items' => []];
}
}
Use in themes with the data-v-component-plugin-{plugin}-{component} naming convention (same single-dash style as the bundled example data-v-component-plugin-contact-form-form for plugin contact-form, component form):
<div data-v-component-plugin-my-plugin-widget data-v-limit="3"></div>
The name is parsed by splitting at the last dash (Vvveb\System\Component\Component::pluginComponentDetails()): everything between plugin- and the last dash is the plugin slug, the rest is the component path (- becomes /, so sub-widget maps to component/sub/widget.php). Keep names dash-light and never include the word plugin in either part - e.g. plugin-my-plugin-my-widget misparses because of the extra dashes.
The results are available in .tpl templates via $this->_component['plugin_my_plugin_widget'][$index].
Hooking into Events
Modify Component Results
Event::on('Vvveb\Component\Products', 'results', __CLASS__, function($results) {
foreach ($results['product'] ?? [] as &$product) {
$product['custom_field'] = 'added by plugin';
}
return [$results];
}, 1000);
Listeners must return all received parameters wrapped in an array (
return [$results];), otherwise the modifications are lost.
Before/After Controller Execution
// After any controller action, before rendering
Event::on('Vvveb\System\Core\FrontController', 'call', __CLASS__, function($template, $controller, $actionName) {
// Modify template or response
return [$template, $controller, $actionName];
}, 1000);
Inject Template Commands
Use the View::compile:after event to append .tpl commands when a template is compiled, for example to render your component inside existing themes:
Event::on('Vvveb\System\Core\View', 'compile:after', __CLASS__,
function ($template, $htmlFile, $tplFile, $vTpl, $view) {
$vTpl->loadTemplateFile(__DIR__ . '/template/widget.tpl');
return [$template, $htmlFile, $tplFile, $vTpl, $view];
});
See Events introduction and the events list for all available hooks.
Plugin Lifecycle
| Phase | Method | When |
|---|---|---|
| Activation | Plugins::activate() |
Sets config/plugins.php status to active, copies/symlinks public/ dir, fires activate event |
| First activation | setup event |
Fired on first activation only (Vvveb\System\Extensions\Plugins, setup, parameters $pluginName, $site_id) - use it to create tables |
| Loading | Plugins::loadPlugins() |
Includes plugin.php for each active plugin on every request |
| Deactivation | Plugins::deactivate() |
Sets status to inactive |
| Uninstall | Plugins::uninstall() |
Removes files, unsets config, fires uninstall event |
To create your tables on first activation, either ship an install.php that runs your installer class, or listen to the setup event:
Event::on('Vvveb\System\Extensions\Plugins', 'setup', __CLASS__, function ($pluginName, $siteId) {
if ($pluginName == 'my-plugin') {
(new \Vvveb\Plugins\MyPlugin\Install())->run();
}
return [$pluginName, $siteId];
});
Schema files placed in install/sql/{engine}/schema/*.sql are executed against the database on activation.
Validation Rules
Add custom validation rules in validate/ (under admin/validate/ or app/validate/ depending on context):
// plugins/my-plugin/app/validate/my-form.php
return [
'name' => [
'NotEmpty' => ['message' => __('Name is required')],
'MaxLength' => ['max' => 100, 'message' => __('Name too long')],
],
'email' => [
'NotEmpty' => ['message' => __('Email is required')],
'Email' => ['message' => __('Invalid email')],
],
];
Use in controllers:
$validator = new \Vvveb\System\Validator(['plugins/my-plugin/my-form']);
$errors = $validator->validate($this->request->post);
if ($errors !== true) {
$this->view->errors = $errors;
return;
}
Public Assets
Files in plugin/public/ are copied/symlinked to public/plugins/{plugin-name}/ on activation:
<!-- In templates -->
<link rel="stylesheet" href="/plugins/my-plugin/css/style.css">
<script src="/plugins/my-plugin/js/script.js"></script>
SQL Models
Define queries in sql/{engine}/ using the SqlP stored-procedure DSL (see the SqlP guide):
-- plugins/my-plugin/sql/mysqli/message.sql
-- table names are plain: the SqlP parser applies the DB_PREFIX automatically
CREATE PROCEDURE getAll(
IN site_id INT,
IN start INT,
IN limit INT,
OUT fetch_all,
OUT fetch_one
)
BEGIN
SELECT * FROM my_plugin_message
WHERE site_id = :site_id
LIMIT :start, :limit;
SELECT count(*) FROM my_plugin_message
WHERE site_id = :site_id;
END
Write one file per engine (mysqli, pgsql, sqlite); backtick identifiers for mysqli/sqlite and double quotes for pgsql. The parser generates a class usable through the model helper:
$message = model('Plugins\MyPlugin\Message'); // Vvveb\Sql\Plugins\MyPlugin\MessageSQL
$rows = $message->getAll(['site_id' => SITE_ID, 'start' => 0, 'limit' => 20]);
Plugin Settings
Store plugin settings using the Settings system:
// Save
\Vvveb\setSetting('my-plugin', 'api_key', 'abc123');
// Read all settings of the plugin
$settings = \Vvveb\getSetting('my-plugin');
// Read one setting with default
$key = \Vvveb\getSetting('my-plugin', 'api_key', '');
Best Practices
- Namespace everything: Use
Vvveb\Plugins\{PluginName}\namespace - Use events, not overrides: Hook into events rather than modifying core files
- Cache aggressively: Set appropriate
$cacheExpireon components - Validate input: Always validate user input in controllers and check CSRF on POST
- Use the model layer: Define SQL procedures for database operations, one file per engine
- Keep public assets minimal: Only include what's needed
- Test with multiple sites: Ensure your plugin works with multi-site setups (settings are stored per
site_id)