Xgenious/ docs

Plugin Development

MultiSaas has a WordPress-style plugin framework. A plugin is a folder with a manifest (plugin.json) and a service provider. It extends both panels of the platform — the landlord (central) panel and the tenant (per-website) panel — without editing core files.

This guide is for PHP developers. It covers the boot contract, routing, admin UI rules, menus, settings, background jobs, the hook API, the payment-gateway API, and the full list of hooks you can consume. The exhaustive hook catalogue is on a separate page: Hooks Reference.

Note

Everything here wires through the hook system in core/app/Helpers/hook-helper.php and Laravel service providers. A plugin can add admin pages, sidebar menus, settings screens, user-dashboard pages, frontend features and payment gateways — with zero core edits.

What a plugin can do

  • Add admin pages, sidebar menus and settings screens in either or both panels.
  • Add user-dashboard pages for website owners (the DomainReseller "My Domains" pattern).
  • Add tenant-website frontend features and fallback-template integrations (the Course pattern).
  • Provide payment gateways with zero core edits (the LocalPayGateway pattern).
  • Run background Artisan commands on the platform scheduler.
  • Consume and fire hooks so other plugins and themes can extend it.

Reference implementations shipped in-tree:

PluginDemonstrates
core/plugins/DomainResellerFull-featured: both admin panels, provider abstraction, settings UI with modals, DNS/SSL/WHOIS services, lifecycle events + listener + mail set, console commands with dry-run, user-dashboard pages, positionable menu hook usage.
core/plugins/LocalPayGatewayMinimal hook-based payment gateway: registration filter, charge handler, HMAC-verified IPN, settings hooks.
core/plugins/CourseTenant feature plugin with tenant-admin CRUD, tenant-frontend pages, migrations + role/permission seeder.
core/plugins/SeoManagerSitemaps, robots, redirects, 404 capture, health audits, scheduled commands and positionable menus on both sides.
core/plugins/ToltAffiliateLandlord affiliate integration: webhook, sync service, admin CRUD, user-dashboard widget.

Plugin skeleton and boot contract

plugins/<YourPlugin>/                 // PSR-4 root: Plugins\  →  plugins/
├── plugin.json                       // manifest — module.json also accepted
├── Providers/<Name>ServiceProvider.php   // boots everything: hooks, commands, views
├── Providers/RouteServiceProvider.php     // loads your Routes/ files
├── Routes/
│   └── web.php                        // landlord + tenant route groups
├── Http/
│   ├── Controllers/
│   │   ├── Landlord/Admin/…            // central admin panel controllers
│   │   ├── Tenant/Admin/…              // per-website admin controllers
│   │   └── User/…                      // optional: user-dashboard controllers
│   ├── Services/…                      // business logic (provider pattern, etc.)
│   └── Enums/…
├── Entities/…                          // Eloquent models for your tables
├── Database/Migrations/…               // timestamped migrations
├── Console/Commands/…                  // optional artisan commands
├── Mail/…  + Resources/views/emails/
├── Resources/views/                    // view namespace: <slug>::
├── assets/…                            // plugin assets (logos etc.)
└── Config/config.php                   // optional

The boot contract, in order:

  1. The plugin is discovered via plugin.json (module.json is accepted too — ModuleMetaData falls back from plugin.json to module.json for menu data). Discovery covers plugins/* and plugins/Ecommerce/*.
  2. The manifest declares a provider. AppServiceProvider::registerHookedPluginProviders() registers that provider for every plugin.
  3. The plugin is enabled in core/modules_statuses.json (or via the Plugins admin UI): a manifest whose id is not true in that file is skipped. Disabled means never booted, so hooks never register — this is the kill-switch.
  4. Your ServiceProvider::boot() runs — register hooks, views, commands here. Guard with function_exists.
  5. Run composer dump-autoload once after creating the plugin (PSR-4 is Plugins\ → plugins/).
Note

"hook_based": true is the convention that marks a plugin as pure hook consumer (see LocalPayGateway/plugin.json). It does not itself gate booting — the two things that control boot are the declared provider and the modules_statuses.json enable flag. A plugin without a provider entry is ignored.

namespace Plugins\YourPlugin\Providers;

use Illuminate\Support\ServiceProvider;

class YourPluginServiceProvider extends ServiceProvider
{
    protected $moduleName = 'YourPlugin';
    protected $moduleNameLower = 'yourplugin';

    public function boot(): void
    {
        $this->registerViews();

        if ($this->app->runningInConsole()) {
            $this->commands([\Plugins\YourPlugin\Console\Commands\DoThing::class]);
        }

        if (! function_exists('add_action')) {
            return; // helpers not loaded yet
        }

        // Inject into the tenant admin footer
        add_action('tenant_admin_footer', function () {
            echo '<script src="'.asset('plugin-assets/yourplugin.js').'"></script>';
        }, 10);

        // Add a positionable user-dashboard menu item
        add_filter('landlord_user_dashboard_menu_items', function (array $items) {
            return menu_insert_after($items, 'support_tickets', 'your_item', [
                'label'  => __('Your Feature'),
                'url'    => route('landlord.user.dashboard.yourplugin.index'),
                'icon'   => 'la-star',
                'active' => request()->routeIs('landlord.user.dashboard.yourplugin.*'),
            ]);
        }, 10);
    }

    public function register(): void
    {
        $this->app->register(RouteServiceProvider::class);
    }

    protected function registerViews(): void
    {
        // view('yourplugin::backend.index')
        $this->loadViewsFrom(plugin_module_path($this->moduleName, 'Resources/views'), $this->moduleNameLower);
    }
}
Warning

Register hooks from boot(), never from a controller or view. The hook registry is built per request, so any registration that happens after the layout starts rendering is too late.

The plugin.json contract

{
    "id": "YourPlugin",
    "name": "Your Plugin",
    "version": "1.0.0",
    "author": "You",
    "provider": "Plugins\\YourPlugin\\Providers\\YourPluginServiceProvider",
    "requires": [],
    "scope": "tenant",
    "hook_based": true,
    "description": "What the plugin does."
}
KeyMeaning
idUnique plugin identifier (the modules_statuses key and menu identity).
providerFQCN of your service provider — booted when the plugin is enabled.
scopetenant (per-website feature), landlord (central), or both. Gateways register per-scope hooks regardless.
hook_basedConvention flag marking a pure hook consumer (LocalPayGateway). Booting is controlled by provider + the modules_statuses enable flag.
requiresArray of other plugin ids that must be enabled first.
migration_scopetenant or landlord — which database your migrations should run against.
menuOptional array of sidebar entries (see Menu registration).
admin_settingsOptional map of settings-screen entries.
iconOptional menu icon name.

Routing in both panels

Plugin routes live in Routes/web.php, loaded by your plugin's RouteServiceProvider. Use mirrored groups with matching middleware stacks and parallel route names:

// Routes/web.php — tenant admin group
Route::middleware(['web', 'auth:admin', 'tenant_admin_glvar', 'package_expire',
                   'tenantAdminPanelMailVerify', 'setlang'])
    ->prefix('admin-home/yourplugin')
    ->name('tenant.admin.yourplugin.')
    ->group(function () {
        Route::get('/',             [ItemController::class, 'index'])->name('index');
        Route::post('/store',       [ItemController::class, 'store'])->name('store');
        Route::post('/items/update',[ItemController::class, 'update'])->name('item.update');
    });

// landlord admin group (same shape, landlord middleware)
Route::middleware(['web', 'auth:admin', 'adminglobalVariable', 'setlang'])
    ->prefix('admin-home/landlord/yourplugin')
    ->name('landlord.admin.yourplugin.')
    ->group(function () {
        Route::get('/', [ItemController::class, 'index'])->name('index');
    });

// optional: user-dashboard group (web guard!)
Route::middleware(['landlord_glvar', 'maintenance_mode', 'userMailVerify', 'setlang'])
    ->prefix('user-home/yourplugin')
    ->name('landlord.user.dashboard.yourplugin.')
    ->group(function () {
        Route::get('/', [UserItemController::class, 'index'])->name('index');
    });
RuleWhy
Name pattern <scope>.admin.<plugin>.*Sidebar menus, settings modals and core redirects build URLs by convention.
Guard-agnostic controllers when serving both panelsOne controller, ownership checks via auth()->id(), works under any guard (DomainReseller pattern).
User-panel group uses the web guardWebsite owners authenticate as web users, not admins.
State-changing endpoints are POST with CSRFGET mutations broke once (DomainReseller activate). Keep POST plus a CSRF token in JS.
Landlord routes may need tenant scopingSeoManager scopes its landlord routes by central_domains and prefixes landlord. — an unscoped landlord group sharing admin-home/* is shadowed by the tenant group (last-registered wins).
Note

Views extend the master of their panel: route_prefix().'admin.admin-master' resolves to landlord.admin.admin-master or tenant.admin.admin-master at runtime. Only hard-code a master when a page truly is single-panel (for example a tenant-only checkout → tenant.admin.admin-master).

Controllers, entities and services

  • Entities are plain Eloquent models under Entities/. Declare $fillable and $casts explicitly; hide tokens and secrets via $hidden.
  • Migrations are timestamped files under Database/Migrations/. Avoid hard-coding a connection name — connection-less migrations run against whichever DB is default when installed. For tenant-scoped plugins, load them through plugin_landlord_migrations_path() / your migration_scope so they land in the right database.
  • Services live under Http/Services/. For provider-abstracted logic (multiple vendors), use the provider pattern: a Contracts/ interface plus a BaseProvider and one class per vendor, selected from stored options — see DomainReseller/Http/Services/Providers/.
  • Shared services over duplication. Expose cross-panel operations as shared services, not duplicated controller code. DomainReseller extracted DomainActivationService so the tenant controller and the user-panel controller call one activation flow.
  • Ownership checks on user-facing endpoints: ->where('user_id', auth()->id())->firstOrFail() — returns 404 and never leaks other users' rows.

Admin views (Ink design system)

Danger

The Ink design system is mandatory for anything rendered in an admin panel (landlord/admin, tenant/admin). Theme frontend views are the exception — they keep their own Bootstrap-based design.

  • Reach for the shared component library before raw Tailwind (core/resources/views/components/ui/): <x-ui.card>, <x-ui.panel>, <x-ui.stat-card>, <x-ui.icon-tile>, <x-ui.badge>, <x-ui.catalog-card>, <x-ui.page-header>, <x-ui.empty-state>, <x-ui.dropdown>, <x-ui.avatar>. Field components: <x-fields.input>, <x-fields.select>, <x-fields.checkbox>, <x-fields.radio>, <x-fields.switcher>, <x-fields.media-upload>, <x-fields.textarea>.
  • Every color utility ships with its dark: pair. Numeric, currency and ID values render with font-mono.
  • Icons are Lucide via <i data-lucide="name"> — never inline SVG paths. Client interactivity is Alpine.js.
  • No Bootstrap classes and no jQuery for new UI (jQuery is reserved for DataTables/Select2/legacy ajax).
  • Modal pattern: fixed overlay plus an x-ui style card, opened and closed by toggling hidden. Toggle with hidden, never Bootstrap d-none — Bootstrap CSS is not loaded in admin panels, so d-none silently does nothing.
  • If a UI pattern is copy-pasted a second time, promote it to components/ui/ and add a row in DESIGN_PRINCIPLES.md.

A minimal Ink admin page:

@extends(route_prefix().'admin.admin-master')
@section('title') {{ __('Your Plugin') }} @endsection

@section('content')
    <x-ui.card>
        <x-flash-msg/>
        <x-error-msg/>

        <x-ui.panel :title="__('General Settings')">
            <form action="{{ route(route_prefix().'admin.yourplugin.settings.update') }}" method="POST" class="p-5">
                @csrf
                <x-fields.input name="api_key" label="{{ __('API Key') }}"
                    :value="get_static_option_central('yourplugin_api_key')" />
                <x-fields.switcher name="yourplugin_enabled" label="{{ __('Enabled') }}"
                    :value="get_static_option_central('yourplugin_enabled')" />
                <div class="mt-2">
                    <x-button type="submit" class="primary">
                        <i data-lucide="save" class="w-4 h-4"></i> {{ __('Save Changes') }}
                    </x-button>
                </div>
            </form>
        </x-ui.panel>
    </x-ui.card>
@endsection
Tip

Build step. Admin CSS is compiled ahead-of-time to assets/landlord/admin/css/modern.css. After editing any Blade view or core/resources/css/app.css, run cd core && npm run tailwind. A new utility class that is not in the compiled file renders unstyled.

Declare simple menu entries in plugin.json (or module.json) — ModuleMetaData reads them for the sidebars via MenuWithPermission:

"menu": [
    { "id": "yourplugin-root", "type": "plugin-menu", "label": "Your Plugin",
      "icon": "la-star",
      "route": "landlord.admin.yourplugin.index",
      "tenantRoute": "tenant.admin.yourplugin.index" }
]
FieldMeaning
routeRoute name used in the landlord panel.
tenantRouteRoute name used in the tenant panel. Always provide both when the plugin serves both panels — a missing name 404s the sidebar.

Positionable sidebar via hooks (preferred)

For anything dynamic — feature gates, conditional visibility, parent + children — register through the *_admin_menu_items filters. SeoManager inserts a parent and a second top-level entry in one call:

add_filter('landlord_admin_menu_items', function (array $items): array {
    return menu_insert_before_many($items, 'general-settings-menu-items', [
        'seo-manager-menu' => [
            'route'       => 'landlord.admin.seo-manager.settings',
            'label'       => __('SEO Manager'),
            'parent'      => null,
            'permissions' => ['seo-manager'],
            'icon'        => 'mdi mdi-search-web',
            'class'       => 'plugin-menu',
        ],
        'seo-redirects-menu' => [
            'route'       => 'landlord.admin.seo-manager.redirects',
            'label'       => __('SEO Redirects'),
            'parent'      => null,
            'permissions' => ['seo-manager'],
            'icon'        => 'mdi mdi-arrow-right-bold-circle',
            'class'       => 'plugin-menu',
        ],
    ]);
}, 10);

The tenant equivalent is tenant_admin_menu_items. To build a parent with children, set parent on the child items to the parent's key (the ToltAffiliate pattern):

add_filter('landlord_admin_menu_items', function (array $items) {
    $items['tolt-affiliate-menu-items'] = [
        'route' => '#', 'label' => __('Affiliate (Tolt)'), 'parent' => null,
        'permissions' => ['plugin-manage'], 'icon' => 'mdi mdi-account-multiple',
        'class' => 'plugin-menu',
    ];
    $items['tolt-affiliate-settings'] = [
        'route' => 'landlord.admin.tolt.settings', 'label' => __('Settings'),
        'parent' => 'tolt-affiliate-menu-items', 'permissions' => ['plugin-manage'],
    ];
    $items['tolt-affiliate-partners'] = [
        'route' => 'landlord.admin.tolt.partners.index', 'label' => __('Partners'),
        'parent' => 'tolt-affiliate-menu-items', 'permissions' => ['plugin-manage'],
    ];
    return $items;
}, 10);
Warning

Items without a permissions entry may not render. Match the permission slug granted by your plugin's role seeder (or an existing core permission such as plugin-manage).

Settings-menu entry

"admin_settings": {
    "yourplugin-settings": {
        "label": "Your Plugin", "route": "landlord.admin.yourplugin.settings.v2"
    }
}

User-dashboard menu (positionable)

Use the landlord_user_dashboard_menu_items filter with the menu helpers. The anchor keys and item shape are in Hooks Reference. The legacy raw-HTML action landlord_user_dashboard_menu still works but appends only.

Settings and options

Store configuration in the options table via the static-option helpers — never env(), which is not runtime-writable:

// read (tenant options inside a tenant request; central options on the landlord)
$key    = get_static_option_central('yourplugin_api_key');
$envSet = get_static_option_central('yourplugin_environment');   // '' => test mode

// write
update_static_option_central('yourplugin_api_key', $value);
delete_static_option_central('yourplugin_api_key');

// tenant-scoped (inside a tenant request)
update_static_option('yourplugin_setting', $value);
$value = get_static_option('yourplugin_setting');

Choosing the right store (the SeoManager pattern):

$isTenant = function_exists('tenant') && tenant();

$sitemapAuto = $isTenant
    ? get_static_option('seo_sitemap_auto')
    : get_static_option_central('seo_sitemap_auto');

update_static_option($isTenant ? 'seo_sitemap_auto' : '', $value); // tenant
// central:
update_static_option_central('seo_sitemap_auto', $value);
  • Pattern for test/production toggles: an empty *_environment option means test/sandbox, any value means production. All DomainReseller providers follow this convention (GoDaddy OTE, Name.com api.dev.name.com, ResellerClub test.httpapi.com, OpenSRS Horizon).
  • Settings UI: a settings page route (for example landlord.admin.yourplugin.settings.v2) with a per-provider config modal that POSTs to your own update route, plus the plugin-identity controls. See DomainReseller/Resources/views/backend/settings-new.blade.php and modals/ for the full pattern.
  • Secrets belong in options/DB, never in code or committed files. Mask password fields with type="password".
  • Validate on save and surface a friendly message: ToltAffiliate rejects a percentage above 100 and reports a warning when the API key fails validation, without losing the save.

Console commands and schedules

// Console/Commands/DoThing.php
protected $signature = 'yourplugin:do-thing
                        {--dry-run : Preview without making changes}
                        {--batch=100}';
protected $description = 'Process pending yourplugin records.';

public function handle()
{
    set_time_limit(0);
    $dryRun = (bool) $this->option('dry-run');
    // ...
    return self::SUCCESS;
}

Register commands in the provider when runningInConsole(), and schedule them from the provider so the plugin stays self-contained:

// SeoManagerServiceProvider — nightly sitemap regeneration
$this->app->booted(function () {
    $this->app->make(\Illuminate\Console\Scheduling\Schedule::class)
        ->command('seo:sitemap --all-tenants')
        ->dailyAt('04:00')
        ->withoutOverlapping()
        ->runInBackground();
});
  • Always support --dry-run for destructive or billing commands; make notification sends no-ops in dry-run.
  • Rate-limit customer emails from cron commands (for example one notice per 24h recorded in a JSON custom_field column — the DomainReseller pattern) so daily runs do not spam.
  • Guard long-running work with a per-item try/catch so one bad row does not kill the batch; keep console output structured (── item ── blocks) for log grepping.
  • Run the tenant loop with Tenancy::initialize($tenant) and always Tenancy::end() in a finally block — see SeoManager/Console/Commands/GenerateSitemap.php and AuditSeo.php.

Events, listeners and mail

  • Define domain events under Events/ (use Dispatchable, SerializesModels) and listeners under Listeners/.
  • Register listeners in the core EventServiceProvider map using the Listener::class.'@method' syntax, or fire-and-forget with event().
  • Mailables go under Mail/ with Blade templates in Resources/views/emails/ (plain-CSS HTML emails).
  • Dispatch events for lifecycle moments instead of mailing inline — the DomainReseller pattern: purchase, activation, renewal and expiry all dispatch events; one SendActivationEmail listener class sends the corresponding mail per event. Never dispatch an event with no listener (an orphaned event is a silent no-op — wire it or delete it).
  • Fire your own public hooks too, so others can extend your plugin (see Firing your own hooks).

Payment gateway plugins

Payment gateways are pure hook-based — zero core edits. Reference: core/plugins/LocalPayGateway/. The full contract is in Hooks Reference.

Register a gateway from your provider's boot():

add_filter('tenant_payment_gateways', function (array $gateways) {
    $gateways['yourpay'] = [
        'scope'   => 'tenant',
        'handler' => fn (array $params) =>
            (new \Plugins\YourGateway\Http\Controllers\YourGatewayController())->charge($params),
        'meta'    => [
            'name'        => 'YourPay',
            'description' => 'Local provider',
            'logo'        => asset('plugins/YourGateway/assets/logo.png'),
            'credentials_schema' => [
                ['key' => 'api_key',    'label' => 'API Key',    'type' => 'text'],
                ['key' => 'secret_key', 'label' => 'Secret Key', 'type' => 'text'],
            ],
            'test_mode_support' => true,
        ],
    ];
    return $gateways;
}, 10);

The charge handler:

public function charge(array $params)
{
    $ctx     = gateway_charge_context($params);
    $orderId = $ctx['payment_details']['id'] ?? $ctx['payment_details']['order_id'] ?? 0;
    session()->put('payment_for', $ctx['payment_for']);   // the IPN dispatcher reads this

    $token = hash_hmac('sha256', (string) $orderId, (string) get_static_option('yourpay_secret_key'));

    return redirect()->to(route('yourpay.checkout', [
        'order_id' => $orderId, 'amount' => $ctx['total'], 'token' => $token,
    ]));
}

The IPN closure (point the provider at gateway_ipn_url('yourpay') → POST /gateway-ipn/yourpay, which is CSRF-exempt):

add_action("tenant_gateway_ipn_{$slug}", function ($request) use ($slug) {
    $orderId = (int) $request->input('order_id');
    $token   = (string) $request->input('token');
    $txn     = (string) $request->input('transaction_id', '');

    $expected = hash_hmac('sha256', (string) $orderId, (string) get_static_option("{$slug}_secret_key"));
    if (! hash_equals($expected, $token)) {
        return redirect()->to(gateway_cancel_url('tenant'));   // MUST return
    }

    $ok = gateway_mark_payment_complete('tenant', $orderId, $txn);

    return redirect()->to($ok ? gateway_success_url('tenant', $orderId) : gateway_cancel_url('tenant'));
}, 10);

Rules:

  • The charge handler receives gateway_charge_context() data (payment_for, total, payment_details, request, type) and must return a redirect/Response. Scope is enforced by the registry before your code runs.
  • IPN needs no core edits. Set session('payment_for') in your charge redirect so the dispatcher resolves the scope, and register {scope}_gateway_ipn_{slug} returning a response. No callback means 404.
  • Public helpers — gateway_registry(), gateway_currency(), gateway_success_url($scope,$id), gateway_cancel_url($scope), gateway_mark_payment_complete($scope,$id,$txn), gateway_charge_context($params) — never import core internals directly.
  • Built-in gateways always win slug conflicts; pick a unique slug (slugs are lowercased).
  • Settings persist via {scope}_gateway_settings_save_{slug}; credentials are supplied via {scope}_gateway_credentials_{slug}.
Danger

The IPN endpoint is unauthenticated. Verify every callback (signature/HMAC, amount, order existence) inside your closure before calling gateway_mark_payment_complete(). Never trust amounts sent by the provider without re-checking against the order.

Extending the user dashboard

Website owners (the web guard) have their own dashboard at /user-home. The full pattern is implemented by DomainReseller and ToltAffiliate:

  1. Route group user-home/yourplugin with the user-dashboard middleware stack, names landlord.user.dashboard.yourplugin.*.
  2. Controller with auth()->id() ownership scoping on every query.
  3. Views styled like landlord/frontend/user/dashboard/* (Bootstrap-based frontend style — not the admin Ink system; this is a frontend tree).
  4. Menu entry via the positionable filter.
  5. Reuse shared services from your admin controllers instead of duplicating flows.

The hook API

All extensibility is on the WordPress-like hook bus, loaded globally — no imports needed. Guard plugin boot code with function_exists so a plugin never fatals when loaded before the helpers.

FunctionSignatureNotes
hook()hook(): HookContractRaw singleton access (addAction/doAction/addFilter/applyFilters/hasAction/clear).
add_action(string $tag, callable $cb, int $priority = 10)Register a callback. Same tag + priority keeps registration order.
do_action(string $tag, mixed ...$args)Fire all callbacks; return values ignored.
render_action(string $tag, mixed ...$args): stringLike do_action but captures echoed output and returned strings. In Blade: {!! render_action('tag') !!}.
add_filter(string $tag, callable $cb, int $priority = 10)Callback receives a value and returns the modified value.
apply_filters(string $tag, mixed $value, mixed ...$args): mixedPipe a value through all callbacks.
menu_insert_before(array $items, string $beforeKey, string $newKey, array $newItem): arrayInsert before a key; missing anchor prepends.
menu_insert_after(array $items, string $afterKey, string $newKey, array $newItem): arrayInsert after a key; missing anchor appends.
menu_insert_before_many(array $items, string $beforeKey, array $newItems): arrayInsert a block of keyed items before a key, preserving order.
menu_insert(array $items, string $newKey, array $newItem, array $position = []): arrayPreferred position with fallback anchors (['after' => ..., 'before' => ...]).
view_exists(string $view): boolTrue only if the view resolves — guards optional module/plugin namespaces.

Actions vs filters

TypeFunction pairWhat happensUse for
Actionadd_action / do_action / render_actionCallbacks run in priority order. render_action() additionally captures echoed output and returned strings into HTML.Injecting markup (headers, footers, menus, extra table rows), side effects.
Filteradd_filter / apply_filtersEach callback receives a value, may modify it, and must return it.Altering arrays/HTML/data: gateway registries, menu item arrays, sidebar cells.

Special runner: dispatchActionResponse

Actions normally return nothing. One dispatcher is different: hook()->dispatchActionResponse($tag, ...$args) runs callbacks until one returns an HTTP response (Response, JsonResponse, RedirectResponse, or an object with toResponse()) and returns it. This is how gateway IPN closures behave like controllers:

add_action('tenant_gateway_ipn_mypay', function (\Illuminate\Http\Request $request) {
    // verify signature, update order...
    return redirect()->to(gateway_success_url('tenant', $order->id));
});

// core dispatcher returns it to the browser:
$response = hook()->dispatchActionResponse('tenant_gateway_ipn_mypay', $request);

Priorities and ordering

  • Callbacks are stored as tag ⇒ priority ⇒ [callbacks]; the map is re-sorted on every registration, so execution is strictly ascending by priority, then registration order within a priority.
  • Default priority is 10. Use lower numbers to run earlier, higher for later. Document your chosen priority when position matters (ToltAffiliate reports completed orders at priority 20, after core's own mail/subscription side effects).
  • The registry is per-request. Nothing is cached between requests; all registrations must happen during provider boot.
  • hook()->clear() empties the registry — exists for tests.

Naming and scoping

  • Tags are snake_case with a leading scope: tenant_frontend_header, landlord_admin_head.
  • Generic tags (no scope prefix) fire in both contexts: frontend_head, frontend_footer, admin_head, admin_footer.
  • Scoped tags fire only in that context — landlord (central) vs tenant (per-tenant website).
  • Gateway hooks interpolate the slug: {scope}_gateway_ipn_{slug} → tenant_gateway_ipn_localpay.
  • When a callback must run in one context but the tag is generic, guard it: if (! empty(tenant())) { ... }. Scoped tags already guarantee the context.

Hooking into the UI

Note

This section shows the most-used hooks with real code. The complete tag-by-tag catalogue — every action, filter, payload and firing location — is on the Hooks Reference page.

Inject assets into every tenant page or the landlord frontend:

// tenant website <head>
add_action('tenant_frontend_head', function () {
    echo '<link rel="stylesheet" href="'.asset('plugin-assets/yourplugin.css').'">';
}, 10);

// tenant website before </body> — the Blog/Service/Product pattern
add_action('tenant_frontend_footer', function () {
    if (! tenant()) return;                       // tenant-only guard
    if (view_exists('yourplugin::frontend.js')) {
        echo view('yourplugin::frontend.js')->render();
    }
}, 10);

// landlord frontend <head>
add_action('landlord_frontend_head', function () {
    echo '<meta name="yourplugin" content="1">';
}, 10);

Admin content and menus

// every landlord admin page, above the content
add_action('landlord_admin_before_content', function () {
    echo '<div class="alert">Maintenance window tonight.</div>';
}, 10);

// raw HTML appended to the rendered sidebar (legacy — prefer *_menu_items)
add_action('tenant_admin_menu', function () {
    echo '<li class="nav-item"><a href="'.route('tenant.admin.yourplugin.index').'">Your Plugin</a></li>';
}, 10);

Fallback template filters

Fallback frontend templates (used when the active theme does not ship a view) expose filters so plugins can replace or decorate whole sections without touching core views. When the filter returns empty, the fallback renders its own default.

// replace the product grid on the shop fallback page
add_filter('shop_grid_products', function ($html) {
    return view('yourplugin::frontend.shop.grid', ['products' => $products ?? []])->render();
}, 10);

Available fallback filters: shop_grid_products, shop_sidebar_filters, shop_before_details (action), blog_sidebar, event_grid.

Pricing, plan and order pages

// add a badge to every plan card on /pricing
add_filter('landlord_pricing_plan_buttons', function ($html, $plan) {
    return $plan->price == 0 ? $html.'<span class="badge">Free forever</span>' : $html;
}, 10, 2);

// replace the plan-details feature list
add_filter('landlord_plan_features', function ($html, $orderDetails) {
    return view('yourplugin::plan-features', ['plan' => $orderDetails])->render();
}, 10, 2);

Landlord tenant list

landlord/admin/tenant/index.blade.php is hookable end-to-end: landlord_admin_tenant_list_before, _toolbar, _th, _cell (filter), landlord_admin_tenant_row_actions_after, _rows_after, _after_table, _after, landlord_admin_tenant_pagination (filter) and landlord_admin_tenant_list_scripts.

add_filter('landlord_admin_tenant_list_cell', function (string $html, $user) {
    return $html.'<td>'.($user->plan ?? '—').'</td>';
}, 10, 2);

Firing your own hooks

Themes and plugins can expose their own extension points. Follow the same conventions so third parties can hook you:

  • Prefix with your slug: domain_wallet_deducted is fired by DomainReseller with ($userId, $amount, $logId).
  • Fire filters wherever a value could plausibly be overridden: $title = apply_filters('yourplugin_widget_title', $title, $widget);.
  • For template sections, use render_action in your Blade and document the tag in your readme — same capture semantics as core.
  • Document every hook you add (tag, type, payload, firing location).
// Your plugin fires:
do_action('yourplugin_after_enrollment', $enrollment, $user);

// Third-party plugin/theme hooks in:
add_action('yourplugin_after_enrollment', function ($enrollment, $user) {
    // e.g. issue a certificate
}, 10);

Developer checklist

  • plugin.json with a unique id and the correct provider FQCN (hook_based: true for pure hook consumers).
  • Enabled in core/modules_statuses.json; composer dump-autoload run.
  • Parallel route groups and names for every panel you serve; tenantRoute set wherever route is.
  • POST + CSRF for state changes; ownership scoping on user-facing queries.
  • Admin views follow Ink (x-ui.*, Lucide, dark: pairs, Alpine, hidden not d-none); Tailwind rebuilt (cd core && npm run tailwind).
  • Options stored via update_static_option / update_static_option_central; secrets never committed.
  • Hooks registered in boot() with a function_exists guard; new hooks documented.
  • Migrations respect the tenant/landlord scope; commands support --dry-run; emails rate-limited.
  • Verified: enable → both panels render; disable → nothing renders and nothing fatals (kill-switch test); minimal-install test with other plugins off.
Still stuck?
Our support team is ready to help you get set up.
Get support