Skip to content
Paybob

Development

Addon development

Build an addon with its own pages, settings, permissions, events and database tables.

An addon adds features to Paybob: a settings page, emails to customers, a sync with your accounting system, a new section in the panel. Addons are Laravel service providers that Paybob loads only while they're enabled.

The bundled SMTP - Pro addon (addons/smtp-pro/) is a complete, real example: per-Brand settings, a Filament settings page, listeners for core events and its own emails.

Package structure

Text
addons/sms-notifications/
├── config.php                              ← required: the manifest
├── src/
│   ├── SmsNotificationsServiceProvider.php ← required: the entry point
│   ├── Listeners/
│   └── Filament/
├── routes/web.php                          ← optional
├── database/migrations/                    ← optional
├── resources/views/                        ← optional
└── README.md                               ← recommended

The folder name is the addon ID: lowercase letters and numbers with single hyphens, such as sms-notifications. It never changes, and it's never written inside a file.

The manifest: config.php

config.php returns an array describing the addon, and nothing else:

PHP
<?php

return [
    'name' => 'SMS Notifications',
    'description' => 'Texts customers when their payment completes.',
    'version' => '1.0.0',
    'author' => [
        'name' => 'Example Ltd',
        'url' => 'https://example.com',
    ],
    'requires' => [
        'appversion' => '>=3.0.1',
        'php' => '>=8.3',
    ],
    'scope' => 'brand',
    'navigation' => [
        'enabled' => true,
        'placement' => 'system-settings',
        'label' => 'SMS Notifications',
        'icon' => 'heroicon-o-chat-bubble-left',
        'sort' => 100,
    ],
    'permissions' => [
        ['key' => 'view', 'label' => 'View SMS Notifications settings'],
        ['key' => 'manage', 'label' => 'Manage SMS Notifications settings'],
    ],
    'capabilities' => ['events', 'http'],
];
KeyRequiredMeaning
name, descriptionYesShown in the addon list and on its settings card.
versionYesA semantic version, such as 1.2.0.
author.nameYesShown in the addon list. author.url is optional and must be http or https.
requires.appversion, requires.phpYesThe Paybob and PHP versions the addon works with. Checked on upload, install and every enable.
scopeNoglobal (default): one switch and one set of settings for every Brand. brand: each Brand enables and configures it separately.
navigationNoWhere the addon appears in the panel. See Pages and navigation.
dependenciesNoOther addons it needs, such as ['another-addon' => '>=1.2.0']. They must be installed and enabled.
permissionsNoPermissions admins can give to roles. See Permissions.
capabilitiesNoWhat the addon does, such as events, http, mail, database. Informational only.

Version constraints

>=1.0.0, <=, >, <, ==, !=, or ^1.0 (at least 1.0.0, below 2.0.0). A bare 1.0.0 means >=1.0.0.

The service provider

src/{StudlyAddonId}ServiceProvider.php, in the namespace Addons\{StudlyAddonId}, extending Paybob's AddonServiceProvider:

PHP
<?php

namespace Addons\SmsNotifications;

use Addons\SmsNotifications\Listeners\TextCustomer;
use App\Events\TransactionStatusUpdated;
use App\Support\Addons\AddonServiceProvider;

class SmsNotificationsServiceProvider extends AddonServiceProvider
{
    public function addonId(): string
    {
        return 'sms-notifications'; // must match the folder name
    }

    public function register(): void
    {
        $this->listen(TransactionStatusUpdated::class, TextCustomer::class);
    }

    public function boot(): void
    {
        $this->loadViewsFrom(dirname(__DIR__).'/resources/views', 'sms-notifications');
    }

    /** @return list<class-string> */
    public function filamentPages(): array
    {
        return [Filament\SmsNotificationsSettings::class];
    }
}

Classes under src/ load automatically: src/Listeners/TextCustomer.php is Addons\SmsNotifications\Listeners\TextCustomer. There's no composer dump-autoload step.

register() and boot() only run while the addon is enabled. Disable it and its listeners, routes, hooks and pages stop on the next request.

Lifecycle

Override any of these on your provider:

MethodCalled whenUse for
install()Files are installed for the first time.One-time setup that isn't a migration.
upgrade($from, $to)A newer version replaces the files.Moving data between versions.
enable()An admin enables the addon (once per Brand for a per-Brand addon).Seeding default settings.
disable()An admin disables it, and before removal.Your own clean-up. Never delete settings or data here.
uninstall()Just before the files are removed.Best-effort logging. A failure doesn't stop removal.

Listening to events

Paybob fires events when things happen: a payment completes, a refund is made, an invoice is emailed. Listen with $this->listen() in register():

PHP
$this->listen(TransactionStatusUpdated::class, TextCustomer::class); // a class or a closure
PHP
namespace Addons\SmsNotifications\Listeners;

use App\Events\TransactionStatusUpdated;

class TextCustomer
{
    public function handle(TransactionStatusUpdated $event): void
    {
        if ($event->newStatus !== 'completed' || blank($event->customerPhone)) {
            return;
        }

        $brandName = $event->merchant()->name();
        // Send "Thanks for your payment of {$event->amount} {$event->currency}" …
    }
}

$this->listen() only calls your listener when the addon is enabled in the event's Brand, and runs it in that Brand's context, so settings and addon_settings() read that Brand. A per-Brand addon must use it. The events and their data are listed on Events.

If your listener throws, Paybob logs the error and carries on: a broken addon never breaks the payment or admin action that fired the event.

Hooks

Hooks are lighter than events: actions run code at a named point, and filters let addons change a value.

PHP
use App\Support\Addons\Facades\Hooks;

// An action
Hooks::addAction('sms-notifications.sent', function (string $to): void {
    // …
}, addonId: $this->addonId());

hooks()->doAction('sms-notifications.sent', $to);

// A filter
Hooks::addFilter('sms-notifications.message', fn (string $text) => $text.' Thank you!', addonId: $this->addonId());

$text = hooks()->applyFilters('sms-notifications.message', $text);
  • Always pass addonId:, so your hooks are removed when the addon is disabled.
  • Order with priority: using HookManager::PRIORITY_EARLY (10), PRIORITY_NORMAL (100, the default) or PRIORITY_LATE (1000).
  • A failing callback is logged and skipped; a failing filter leaves the value unchanged.
  • Prefix hook names with your addon ID. Use hooks to let other addons extend yours; Paybob's own extension points are its events.

Settings

PHP
addon_settings('sms-notifications')->get('sender_name', 'Paybob');
addon_settings('sms-notifications')->set('sender_name', 'Example Store');
addon_settings('sms-notifications')->setEncrypted('api_key', $secret); // for secrets
addon_settings('sms-notifications')->all();

For a per-Brand addon these read and write the current Brand's settings. To work with another Brand, pass it: addon_settings('sms-notifications', $brand). Outside any Brand, for example in a console command, get() returns your default and set() refuses, so a value is never saved to the wrong Brand.

Check whether the addon runs here with addon_enabled('sms-notifications'), which answers for the current Brand when the addon is per-Brand.

Pages and navigation

Set navigation.placement in config.php:

PlacementAppears
system-settingsAs a card in System Settings, and as Settings in the addon list. The usual choice.
mainIn the panel's main sidebar.
addonUnder another addon. Add 'parent' => 'other-addon-id'.

Return your Filament page classes from filamentPages(). A settings page extends Paybob's SystemSettingsPage, and must check that the addon is enabled and that the user has your permission:

PHP
namespace Addons\SmsNotifications\Filament;

use App\Filament\Pages\SystemSettings\SystemSettingsPage;
use App\Support\Addons\AddonPermissionRegistrar;

class SmsNotificationsSettings extends SystemSettingsPage
{
    public static function canAccess(): bool
    {
        return parent::canAccess()
            && addon_enabled('sms-notifications')
            && app(AddonPermissionRegistrar::class)->userCan('sms-notifications', 'view');
    }

    public function save(): void
    {
        abort_unless(app(AddonPermissionRegistrar::class)->userCan('sms-notifications', 'manage'), 403);
        // save …
    }
}

Your page's route exists even while the addon is disabled, so this check is what keeps it closed.

Permissions

Each permission in config.php becomes a checkbox on the Addons tab of Role Management. Use the short form ['view', 'manage'] or give each one a label as above. Check them with app(AddonPermissionRegistrar::class)->userCan($addonId, $key). Admins always pass. A permission added in an update isn't given to any role automatically.

Routes, views and migrations

Routes. Register them in boot(), prefixed with your addon ID:

PHP
use Illuminate\Support\Facades\Route;

Route::middleware('web')->group(function (): void {
    Route::get('/addons/sms-notifications/status', StatusController::class)
        ->name('addon.sms-notifications.status');
});

Views. $this->loadViewsFrom(..., 'sms-notifications'), then view('sms-notifications::status').

Migrations. Put standard Laravel migrations in database/migrations/. They run automatically after install and after each upgrade. They're never rolled back automatically: disabling or removing an addon never deletes its tables.

Code that runs without a Brand

A console command or scheduled task has no current Brand. For a per-Brand addon, loop over the Brands yourself, skip those where the addon is off, and run your code inside each Brand:

PHP
use App\Models\Brand;
use App\Support\Brand\BrandContext;

foreach (Brand::all() as $brand) {
    if (! addon_enabled('sms-notifications', $brand)) {
        continue;
    }

    app(BrandContext::class)->run($brand, function () {
        // addon_settings() and Settings read this Brand here
    });
}

To configure something per Brand at runtime, such as a mail transport, listen to BrandContextChanged with a plain Event::listen(), which also runs where your addon is off, so you can restore the default. SMTP - Pro does this.

Packaging and installing

  1. ZIP the folder so the ZIP contains one top-level folder named after the addon ID.
  2. Leave out vendor/, node_modules/ and .git/.
  3. Raise version for every release.

Upload with System Settings → Addons → Upload Addon. Paybob checks the ZIP before installing anything: its structure, your provider class (loaded in a separate process, so a broken file is refused rather than crashing the panel), versions and dependencies. New addons are installed disabled. Uploading a higher version upgrades the addon after a backup; the same or a lower version is refused.

Limits: up to 5,000 files and 500 MB unpacked. Files can only be written inside the addon's own folder.

Checklist

  • The folder name is the addon ID, and addonId() returns it.
  • The provider is src/{StudlyAddonId}ServiceProvider.php, in Addons\{StudlyAddonId}.
  • config.php has every required key, with requirements you've actually tested.
  • Listeners use $this->listen(), especially in a per-Brand addon.
  • Settings pages check addon_enabled() and your permissions, and saving checks manage on the server.
  • Secrets use setEncrypted().
  • Disabling never deletes data.
  • Tested: upload, enable, disable, upgrade, remove.