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.
PluginLoader::loadActive() scans modules/addons/, validates each manifest.json, token-scans all PHP files, and calls the two-phase lifecycle:
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.
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}.
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.
<?phpdeclare(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>'); }}
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.
-- migrations/001_create_email_logs.sqlCREATE 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.sqlDROP 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.