Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/own-pay/OwnPay-Documentation/llms.txt

Use this file to discover all available pages before exploring further.

Addons are the general-purpose extension type in OwnPay. Unlike gateways (which implement GatewayAdapterInterface) or themes (which override checkout templates), addons extend the platform through event hooks, custom HTTP routes, cron jobs, admin menu entries, and database-backed functionality. Every real-world extension that does not process payments directly — SMS notifications, Telegram bots, analytics dashboards, export utilities, subscription managers — is built as an addon.

How Addon Plugins Work

PluginLoader::loadActive() scans modules/addons/, validates each manifest.json, token-scans all PHP files, and calls the two-phase lifecycle:
  1. register(EventManager $events, Container $container) — called during boot. All hook and filter registrations must happen here. The EventManager wraps this call with events->pushOwner(slug), so every registration is attributed to your plugin slug and respects brand-context activation.
  2. boot(Container $container) — called after all plugins have registered. All DI container services are fully resolved at this point. Capture services you need for request-time use.
Routes declared in manifest.json are registered automatically by the core router. Cron jobs declared in manifest.json are scheduled by CronJobRunner under the key plugin:{slug}:{name}.

Directory Structure

modules/addons/my-addon/
├── manifest.json           # Required. Plugin metadata.
├── Plugin.php              # Required. Entrypoint implementing PluginInterface.
├── Service/
│   └── NotificationDispatcher.php
├── Cron/
│   └── SyncJob.php
├── migrations/
│   └── 001_create_notifications.sql
└── assets/
    └── admin.css

The manifest.json File

The manifest drives discovery, routing, cron scheduling, and admin menu registration. Here is a full example based on the reference example-kit addon:
{
    "name": "Example Kit",
    "slug": "example-kit",
    "version": "1.0.0",
    "description": "Reference addon exercising routes, cron, admin menu, and hooks.",
    "author": "OwnPay Core",
    "type": "addon",
    "entrypoint": "Plugin.php",
    "namespace": "OwnPay\\Modules\\Addons\\ExampleKit",
    "capabilities": ["addon", "hooks", "cron"],
    "requires": {
        "core": ">=0.1.0"
    },
    "routes": [
        ["GET",  "/plugins/example-kit/ping", "ping"],
        ["GET",  "/admin/example-kit",        "adminHome", "admin"]
    ],
    "cron": [
        {
            "name":     "heartbeat",
            "schedule": "every_5min",
            "class":    "OwnPay\\Modules\\Addons\\ExampleKit\\Cron\\HeartbeatJob"
        }
    ],
    "admin_menu": [
        { "label": "Example Kit", "url": "/admin/example-kit" }
    ]
}
FieldRequiredDescription
nameDisplay name in the admin panel.
slugUnique lowercase identifier matching directory name.
versionSemantic version (e.g., 1.0.0).
descriptionShort description.
authorAuthor name.
typeMust be "addon".
entrypointPHP filename for the entrypoint class.
namespacePSR-4 root namespace. All classes autoload from the plugin directory.
capabilitiesAt minimum ["addon"]. Add "hooks", "cron", "db_read", etc. as needed.
requires.coreSemver constraint on OwnPay core version.
routesRoute tuples: ["METHOD", "/path", "handlerMethod", "middlewareGroup"].
cronScheduled task declarations with name, schedule, and class.
admin_menuSidebar navigation entries with label and url.
hooksDeclarative list of hook names (informational; registration happens in register()).
migrationsPath to migrations directory. Defaults to "migrations".

The Entrypoint Class

The entrypoint must implement OwnPay\Plugin\PluginInterface, which requires six methods. This example is a complete email-logger addon — it intercepts the email.before_send hook and persists a record to a plugin-owned database table.
<?php
declare(strict_types=1);

namespace OwnPay\Plugins\EmailLogger;

use OwnPay\Container;
use OwnPay\Event\EventManager;
use OwnPay\Plugin\Capability;
use OwnPay\Plugin\PluginInterface;
use OwnPay\Http\Request;
use OwnPay\Http\Response;

final class EmailLogger implements PluginInterface
{
    private ?Container $container = null;

    public static function metadata(): array
    {
        return [
            'name'        => 'Email Logger',
            'slug'        => 'email-logger',
            'version'     => '1.0.0',
            'description' => 'Logs all outbound emails to a database table.',
            'author'      => 'Your Name',
            'type'        => 'addon',
        ];
    }

    public function capabilities(): array
    {
        return [Capability::ADDON, Capability::HOOKS, Capability::DB_WRITE];
    }

    public function register(EventManager $events, Container $container): void
    {
        // Register ALL hooks here — not in boot() or the constructor
        $events->addAction('communication.mail.send', [$this, 'logEmail'], priority: 10);

        // Inject a custom metric into the admin dashboard
        $events->addFilter('admin.dashboard.stats', function (array $stats): array {
            $stats['emails_logged_today'] = $this->countTodayLogs();
            return $stats;
        });
    }

    public function boot(Container $container): void
    {
        // All services are fully resolved at this point
        $this->container = $container;
    }

    public function deactivate(Container $container): void
    {
        // Flush caches. Do NOT drop tables here.
    }

    public function uninstall(Container $container): void
    {
        // Drop the plugin's table on uninstall
        \OwnPay\Database\Database::execute(
            'DROP TABLE IF EXISTS op_plugin_email_logger_logs'
        );
    }

    public function fields(): array
    {
        return [
            [
                'name'    => 'retention_days',
                'label'   => 'Log Retention (days)',
                'type'    => 'text',
                'default' => '30',
                'help'    => 'Logs older than this will be pruned by the cleanup cron.',
            ],
        ];
    }

    // ─── Hook handlers ────────────────────────────────────────────────────────

    public function logEmail(int $merchantId, array $messagePayload, array $deliveryResult): void
    {
        \OwnPay\Database\Database::execute(
            'INSERT INTO op_plugin_email_logger_logs
                (merchant_id, recipient, subject, status, created_at)
             VALUES
                (:mid, :to, :subject, :status, NOW())',
            [
                'mid'     => $merchantId,
                'to'      => htmlspecialchars($messagePayload['to'] ?? '', ENT_QUOTES, 'UTF-8'),
                'subject' => htmlspecialchars($messagePayload['subject'] ?? '', ENT_QUOTES, 'UTF-8'),
                'status'  => $deliveryResult['success'] ?? false ? 'sent' : 'failed',
            ]
        );
    }

    private function countTodayLogs(): int
    {
        $row = \OwnPay\Database\Database::fetchOne(
            'SELECT COUNT(*) AS total FROM op_plugin_email_logger_logs WHERE DATE(created_at) = CURDATE()'
        );
        return (int) ($row['total'] ?? 0);
    }

    // ─── Route handlers ───────────────────────────────────────────────────────

    public function adminHome(Request $req): Response
    {
        $logs = \OwnPay\Database\Database::fetchAll(
            'SELECT * FROM op_plugin_email_logger_logs ORDER BY created_at DESC LIMIT 50'
        );
        return Response::html('<pre>' . htmlspecialchars(json_encode($logs, JSON_PRETTY_PRINT), ENT_QUOTES, 'UTF-8') . '</pre>');
    }
}

Accessing Core Services

Plugins are not wired into the DI container automatically. Resolve services manually from the $container argument.
public function boot(Container $container): void
{
    $this->container = $container;
}

private function getSettings(): \OwnPay\Repository\SettingsRepository
{
    $repo = $this->container->get(\OwnPay\Repository\SettingsRepository::class);
    assert($repo instanceof \OwnPay\Repository\SettingsRepository);
    return $repo;
}

// Read plugin settings (stored under group "plugin.{slug}")
$value = $this->getSettings()->getGroup('plugin.email-logger')['retention_days'] ?? '30';

// Read brand-scoped settings
$brandValue = $this->getSettings()->forTenant($merchantId)->getGroup('plugin.email-logger')['setting_key'] ?? '';

Registering Admin Dashboard Pages

Declare the route and menu entry in manifest.json, then implement the handler method on your entrypoint class:
"routes": [
    ["GET", "/admin/email-logger",         "adminHome",    "admin"],
    ["GET", "/admin/email-logger/export",  "exportLogs",   "admin"]
],
"admin_menu": [
    { "label": "Email Logger", "url": "/admin/email-logger" }
]
public function adminHome(Request $req): Response
{
    // Render your admin page HTML
    return Response::html('...');
}

public function exportLogs(Request $req): Response
{
    // Stream a CSV download
    return Response::download('email-logs.csv', $csvContent, 'text/csv');
}
Always declare the "admin" middleware group as the fourth element in your route tuple for any authenticated admin route. Routes without this group are publicly accessible.

Registering API Endpoints

Public API endpoints follow the same pattern — omit the middleware group:
"routes": [
    ["POST", "/plugins/email-logger/webhook", "receiveWebhook"]
]
public function receiveWebhook(Request $req): Response
{
    $payload = $req->json(); // Decoded JSON body

    // Verify HMAC, process payload...

    return Response::json(['received' => true]);
}

Scheduled Jobs

Declare cron jobs in manifest.json and implement CronJobInterface:
"cron": [
    {
        "name":     "cleanup",
        "schedule": "daily",
        "class":    "OwnPay\\Plugins\\EmailLogger\\Cron\\CleanupJob"
    }
]
Supported schedules: every_minute, every_5min, every_15min, every_30min, hourly, every_6hours, daily, weekly.
<?php
declare(strict_types=1);

namespace OwnPay\Plugins\EmailLogger\Cron;

use OwnPay\Cron\CronJobInterface;

final class CleanupJob implements CronJobInterface
{
    public function run(): mixed
    {
        $deleted = \OwnPay\Database\Database::execute(
            'DELETE FROM op_plugin_email_logger_logs
              WHERE created_at < DATE_SUB(NOW(), INTERVAL 30 DAY)'
        );

        return ['deleted_rows' => $deleted];
    }
}
File location: modules/addons/email-logger/Cron/CleanupJob.php. The class autoloads from the PSR-4 namespace declared in manifest.json.

Database Migrations

-- migrations/001_create_email_logs.sql
CREATE TABLE IF NOT EXISTS op_plugin_email_logger_logs (
    id          BIGINT UNSIGNED  AUTO_INCREMENT PRIMARY KEY,
    merchant_id INT UNSIGNED     NOT NULL,
    recipient   VARCHAR(255)     NOT NULL,
    subject     VARCHAR(255)     NOT NULL,
    status      ENUM('sent','failed') NOT NULL DEFAULT 'sent',
    created_at  DATETIME(6)      NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
    INDEX idx_merchant_date (merchant_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- migrations/001_create_email_logs.down.sql
DROP TABLE IF EXISTS op_plugin_email_logger_logs;
All plugin tables must be prefixed with op_plugin_. Queries to any op_* table without this prefix are blocked by PluginSandbox::validateSql() and raise a RuntimeException.

PSR-4 Multi-File Classes

Declare a namespace in manifest.json to autoload additional classes:
OwnPay\Plugins\EmailLogger\Service\MailRenderer
  → modules/addons/email-logger/Service/MailRenderer.php

OwnPay\Plugins\EmailLogger\Cron\CleanupJob
  → modules/addons/email-logger/Cron/CleanupJob.php
The autoloader enforces directory containment — a crafted class name cannot escape the plugin directory.

Commonly Used Hooks for Addons

public function register(EventManager $events, Container $container): void
{
    // React to a completed payment
    $events->addAction('payment.transaction.completed', [$this, 'onPaymentCompleted'], priority: 10);

    // Waive the fee for large transactions
    $events->addFilter('payment.fee.calculate', function (string $fee, array $ctx): string {
        if (bccomp($ctx['amount'] ?? '0', '10000', 2) >= 0) {
            return '0.00';
        }
        return $fee;
    }, priority: 20);

    // Inject a dashboard metric
    $events->addFilter('admin.dashboard.stats', function (array $stats): array {
        $stats['my_addon_metric'] = 42;
        return $stats;
    });

    // Block a login from a specific IP
    $events->addFilter('auth.login.before', function (bool $allowed, string $email, string $ip): bool {
        if ($ip === '192.168.1.100') {
            return false;
        }
        return $allowed;
    });
}

Security Requirements

RuleRequirement
No eval()PluginLoader token-scans every PHP file. Any use of eval causes load failure.
No OS commandsexec(), shell_exec(), system(), passthru(), popen(), proc_open(), pcntl_exec(), dl() are blocked.
No direct op_* table accessOnly op_plugin_ prefixed tables are permitted.
Escape all outputHook callbacks that echo HTML must escape user-supplied values with htmlspecialchars($val, ENT_QUOTES, 'UTF-8').
Parameterised queriesNever interpolate variables directly into SQL strings. Use :param style placeholders.
Register hooks in register() onlyHooks registered in boot() or the constructor bypass brand-context activation checks.
Tenant isolationWhen reading or writing brand-scoped data, always pass merchant_id to forTenant($merchantId).

Installation Checklist

  • manifest.json complete; slug matches directory name.
  • namespace declared; all classes in correct PSR-4 paths.
  • Entrypoint implements PluginInterface with all six methods.
  • All hooks registered inside register(), not boot() or constructor.
  • uninstall() drops any op_plugin_* tables created by migrations.
  • Plugin tables named op_plugin_{slug}_*.
  • Admin routes use the "admin" middleware group.
  • Cron jobs implement CronJobInterface.
  • Hook output escapes all user-supplied values with htmlspecialchars().
  • No eval(), exec(), shell_exec(), system(), passthru().

Build docs developers (and LLMs) love