---
name: plugin-building
description: Guide for building Vvveb CMS plugins that live in plugins/<slug>/ with a plugin.php entry file — covers the plugin header, folder structure, admin/app controllers, events and hooks, admin menu, routes, components, settings, validation, SQL models and the plugin lifecycle. Use when creating a new plugin, adding settings pages or controllers to an existing plugin, hooking into Vvveb events, adding database tables, or debugging a plugin that isn't loading.
---

# Vvveb Plugin Building

Plugins live in their own folder `plugins/<slug>/`. The only required file is `plugins/<slug>/plugin.php` with an info comment header. **The slug must match the folder name.**

Reference implementations: `plugins/insert-scripts` (minimal), `plugins/contact-form` (component + tables), plus the suite under `plugins/*` built recently (email-marketing, abandoned-cart…). Copy patterns from these rather than inventing structure.

## Workflow

1. Scaffold `plugin.php` with the info header (below) and an empty class.
2. Add controllers/templates only for what you need — everything except `plugin.php` is optional.
3. Hook events inside `admin()` / `app()` methods (constructor dispatches by `APP` context).
4. Verify with `php -l` on every PHP file; activate from admin → Plugins; confirm menu entries render and settings save.

## Minimal plugin.php

```php
<?php

/*
Name: Insert Footer Header Scripts
Slug: insert-scripts
Category: tools
Url: https://www.vvveb.com
Description: Insert footer and header scripts such as analytics or widgets.
Thumb: insert-scripts.svg
Author: givanz
Version: 0.1
Author url: https://www.vvveb.com
Settings: /admin/index.php?module=plugins/insert-scripts/settings
*/

use function Vvveb\__;
use Vvveb\System\Core\View;
use Vvveb\System\Event;

if (! defined('V_VERSION')) {
    die('Invalid request!');
}

class InsertScriptsPlugin {
    public function admin() {
        // add admin menu item (see Admin Menu section)
    }

    public function app() {
        // frontend hooks
    }

    public function __construct() {
        if (APP == 'admin') {
            $this->admin();
        } elseif (APP == 'app') {
            $this->app();
        }
    }
}

new InsertScriptsPlugin();
```

Header fields: `Name`, `Slug` (must equal folder name), `Category`, `Url`, `Description`, `Thumb` (svg/png in plugin root), `Author`, `Version`, `Author url`, and optional `Settings:` pointing at the plugin's admin page (shown as a "Settings" link in the plugins list).

## Folder structure

Mirrors the CMS layout. Everything below is optional except `plugin.php`:

```
plugins/my-plugin/
├── plugin.php                  main entry file (required)
├── my-plugin.svg               thumbnail
├── DESCRIPTION.md              docs shown in admin
├── admin/
│   ├── controller/settings.php     admin controllers
│   ├── template/settings.tpl       admin templates (Vtpl)
│   └── validate/settings.php       validation rule files
├── app/
│   ├── controller/index.php        frontend controllers
│   ├── template/common.tpl         frontend templates
│   └── validate/plugin-form.php
├── public/
│   ├── admin/settings.html         html source for admin pages (page builder)
│   ├── app/index.html
│   ├── js/plugin.js
│   └── css/plugin.css
├── install/sql/                schema run on first activation (rare)
│   ├── mysqli/plugin.sql
│   ├── pgsql/plugin.sql
│   └── sqlite/plugin.sql
├── sql/                        query models used at runtime
│   ├── mysqli/*.sql
│   ├── pgsql/*.sql
│   └── sqlite/*.sql
├── component/my-widget.php     custom dynamic components
├── system/lib.php              helper classes/functions
└── vendor/                     bundled 3rd-party libraries
```

Engine folders are exactly `mysqli`, `pgsql`, `sqlite`.

## Controllers

Both admin and app controllers use the same namespace — the **folder** (`admin/controller/` vs `app/controller/`) decides the application context, not the namespace:

```php
// plugins/my-plugin/admin/controller/settings.php
namespace Vvveb\Plugins\MyPlugin\Controller;

use function Vvveb\__;
use Vvveb\Controller\Base;

class Settings extends Base {
    function index() {
        $this->view->settings = \Vvveb\getSetting('my-plugin', null, []);
    }

    function save() {
        \Vvveb\setMultiSetting('my-plugin', $this->request->post['settings'] ?? []);
        return $this->index();
    }
}
```

Admin templates are `.tpl` (Vtpl) compiled from the matching `public/admin/*.html` source. Controller actions are reached at `?module=plugins/my-plugin/settings&action=index`.

## Events & hooks

`Event::on($class, $event, $id, $callback, $priority = 1000)` — four required args; lower priority runs first. **Listeners must return their parameters as an array tuple**, otherwise values are lost:

```php
Event::on('Vvveb\System\Core\FrontController', 'call', __CLASS__,
    function ($template, $controller, $actionName) {
        // …
        return [$template, $controller, $actionName];
    });
```

Common hooks:
- `'Vvveb\Controller\Base', 'init-menu'` — inject admin menu items
- `'Vvveb\System\Routes', 'init'` — modify routes (receives `$routes` array, return `[$routes]`)
- `'Vvveb\Component\Products', 'results'` — filter component results
- `'Vvveb\System\Core\View', 'compile:after'` — inject tpl commands into any page

## Admin menu items

```php
public function admin() {
    $admin_path = \Vvveb\adminPath();

    Event::on('Vvveb\Controller\Base', 'init-menu', __CLASS__, function ($menu) use ($admin_path) {
        $menu['plugins']['items']['my-plugin'] = [
            'name'     => __('My Plugin'),
            'url'      => $admin_path . 'index.php?module=plugins/my-plugin/settings',
            'icon-img' => PUBLIC_PATH . 'plugins/my-plugin/my-plugin.svg',
        ];

        return [$menu];   // required
    });
}
```

## Custom routes

Either add directly during `app()` (fine for static routes):

```php
use Vvveb\System\Routes;

Routes::addRoute('/my-page', ['module' => 'plugins/my-plugin/index/index']);
```

Or react to route initialization when you need to inspect/remove existing routes:

```php
Event::on('Vvveb\System\Routes', 'init', __CLASS__, function ($routes) {
    unset($routes['/legacy']);                 // e.g. hide-ecommerce removes shop routes
    return [$routes];
});
```

Route modules map to controller files: `plugins/my-plugin/index/index` → `app/controller/index.php` action `index`.

## Components (dynamic data for themes)

```php
// 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 array $defaultOptions = [
        'limit' => 5,
    ];

    public function results(): mixed {
        return ['items' => []];
    }
}
```

Use in theme HTML — note the `plugin-<slug>-<name>` prefix:

```html
<div data-v-component-plugin-my-plugin-my-widget data-v-limit="3"></div>
```

Filter another component's results:

```php
Event::on('Vvveb\Component\Products', 'results', __CLASS__, function ($results) {
    foreach ($results['product'] ?? [] as &$product) {
        $product['custom_field'] = 'added by my plugin';
    }
    return [$results];
});
```

## Settings

```php
// read (returns array when $key is null/array, single value otherwise)
$options = \Vvveb\getSetting('my-plugin', null, []);
$value   = \Vvveb\getSetting('my-plugin', 'api_key');

// write one / many
\Vvveb\setSetting('my-plugin', 'api_key', 'abc123');
\Vvveb\setMultiSetting('my-plugin', ['header' => '…', 'footer' => '…']);
```

## Validation

Rule file `admin/validate/settings.php` returns a rules array keyed by field:

```php
return [
    'email' => [
        'email'    => ['message' => __('Invalid email')],
        'notEmpty' => ['message' => __('Email is required')],
    ],
];
```

Use in a controller (rule id = `plugins.<slug>.<file>`):

```php
$validator = new \Vvveb\Validator(['plugins.my-plugin.settings']);
$errors    = $validator->validate($this->request->post);

if ($errors !== true) {
    $this->view->errors = $errors;
}
```

## SQL models

Runtime queries go in `sql/{mysqli,pgsql,sqlite}/<table>.sql` using the SqlP preprocessor (`CREATE PROCEDURE` blocks, `@IF isset(:param) … THEN … END @IF` conditionals, `:param` bindings, `_` alias keeps rows in the main result array):

```sql
CREATE PROCEDURE get(OUT fetch_all)
BEGIN
    SELECT * FROM `{prefix}my_plugin_items` as _ WHERE 1 = 1
END
```

Access via `model('Plugins\MyPlugin\MyPluginItem')` → generated class with `get()/getAll()/add()/edit()/delete()` per procedure. Schema DDL for first activation goes in `install/sql/{engine}/` and runs on plugin setup.

## Lifecycle

Handled by `Vvveb\System\Extensions\Plugins` (`system/extensions/plugins.php`):

| Phase | Method | What happens |
|---|---|---|
| Activate | `Plugins::activate()` | fires `setup` event on first activation, sets status `active`, symlinks/copies `public/` → `public/plugins/<slug>/` |
| Load | `Plugins::loadPlugins()` | includes `plugin.php` of each active plugin on every request |
| Deactivate | `Plugins::deactivate()` | status → `inactive` |
| Uninstall | `Plugins::uninstall()` | removes config/files |

Because `plugin.php` is included on every request, keep the constructor cheap — register events, don't do work.

## Public assets

Reference them through the copied path (not the plugin dir):

```html
<link rel="stylesheet" href="/plugins/my-plugin/css/plugin.css">
<script src="/plugins/my-plugin/js/plugin.js"></script>
```

## Pitfalls

- Forgetting `return [$menu];` / `return [$routes];` in event listeners silently drops the modification.
- Slug/folder/class-prefix mismatch breaks loading and asset paths.
- Don't run queries in `plugin.php` top-level — use components/controllers/events.
- Admin templates need a matching `public/admin/*.html` source page (page-builder flow), and `.tpl` changes require recompiling (clear `storage/compiled-templates/`) while developing.
