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

  1. Namespace everything: Use Vvveb\Plugins\{PluginName}\ namespace
  2. Use events, not overrides: Hook into events rather than modifying core files
  3. Cache aggressively: Set appropriate $cacheExpire on components
  4. Validate input: Always validate user input in controllers and check CSRF on POST
  5. Use the model layer: Define SQL procedures for database operations, one file per engine
  6. Keep public assets minimal: Only include what's needed
  7. Test with multiple sites: Ensure your plugin works with multi-site setups (settings are stored per site_id)