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.
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:
| Plugin | Demonstrates |
|---|---|
core/plugins/DomainReseller | Full-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/LocalPayGateway | Minimal hook-based payment gateway: registration filter, charge handler, HMAC-verified IPN, settings hooks. |
core/plugins/Course | Tenant feature plugin with tenant-admin CRUD, tenant-frontend pages, migrations + role/permission seeder. |
core/plugins/SeoManager | Sitemaps, robots, redirects, 404 capture, health audits, scheduled commands and positionable menus on both sides. |
core/plugins/ToltAffiliate | Landlord 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:
- The plugin is discovered via
plugin.json(module.jsonis accepted too —ModuleMetaDatafalls back fromplugin.jsontomodule.jsonfor menu data). Discovery coversplugins/*andplugins/Ecommerce/*. - The manifest declares a
provider.AppServiceProvider::registerHookedPluginProviders()registers that provider for every plugin. - The plugin is enabled in
core/modules_statuses.json(or via the Plugins admin UI): a manifest whoseidis nottruein that file is skipped. Disabled means never booted, so hooks never register — this is the kill-switch. - Your
ServiceProvider::boot()runs — register hooks, views, commands here. Guard withfunction_exists. - Run
composer dump-autoloadonce after creating the plugin (PSR-4 isPlugins\→plugins/).
"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);
}
}
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."
}
| Key | Meaning |
|---|---|
id | Unique plugin identifier (the modules_statuses key and menu identity). |
provider | FQCN of your service provider — booted when the plugin is enabled. |
scope | tenant (per-website feature), landlord (central), or both. Gateways register per-scope hooks regardless. |
hook_based | Convention flag marking a pure hook consumer (LocalPayGateway). Booting is controlled by provider + the modules_statuses enable flag. |
requires | Array of other plugin ids that must be enabled first. |
migration_scope | tenant or landlord — which database your migrations should run against. |
menu | Optional array of sidebar entries (see Menu registration). |
admin_settings | Optional map of settings-screen entries. |
icon | Optional 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');
});
| Rule | Why |
|---|---|
Name pattern <scope>.admin.<plugin>.* | Sidebar menus, settings modals and core redirects build URLs by convention. |
| Guard-agnostic controllers when serving both panels | One controller, ownership checks via auth()->id(), works under any guard (DomainReseller pattern). |
User-panel group uses the web guard | Website owners authenticate as web users, not admins. |
| State-changing endpoints are POST with CSRF | GET mutations broke once (DomainReseller activate). Keep POST plus a CSRF token in JS. |
| Landlord routes may need tenant scoping | SeoManager 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). |
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$fillableand$castsexplicitly; 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 throughplugin_landlord_migrations_path()/ yourmigration_scopeso they land in the right database. - Services live under
Http/Services/. For provider-abstracted logic (multiple vendors), use the provider pattern: aContracts/interface plus aBaseProviderand one class per vendor, selected from stored options — seeDomainReseller/Http/Services/Providers/. - Shared services over duplication. Expose cross-panel operations as shared services, not duplicated controller code. DomainReseller extracted
DomainActivationServiceso 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)
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 withfont-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-uistyle card, opened and closed by togglinghidden. Toggle withhidden, never Bootstrapd-none— Bootstrap CSS is not loaded in admin panels, sod-nonesilently does nothing. - If a UI pattern is copy-pasted a second time, promote it to
components/ui/and add a row inDESIGN_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
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.
Menu registration
Sidebar items (both panels)
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" }
]
| Field | Meaning |
|---|---|
route | Route name used in the landlord panel. |
tenantRoute | Route 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);
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
*_environmentoption means test/sandbox, any value means production. All DomainReseller providers follow this convention (GoDaddy OTE, Name.comapi.dev.name.com, ResellerClubtest.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. SeeDomainReseller/Resources/views/backend/settings-new.blade.phpandmodals/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-runfor 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_fieldcolumn — 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 alwaysTenancy::end()in afinallyblock — seeSeoManager/Console/Commands/GenerateSitemap.phpandAuditSeo.php.
Events, listeners and mail
- Define domain events under
Events/(useDispatchable,SerializesModels) and listeners underListeners/. - Register listeners in the core
EventServiceProvidermap using theListener::class.'@method'syntax, or fire-and-forget withevent(). - Mailables go under
Mail/with Blade templates inResources/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
SendActivationEmaillistener 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}.
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:
- Route group
user-home/yourpluginwith the user-dashboard middleware stack, nameslandlord.user.dashboard.yourplugin.*. - Controller with
auth()->id()ownership scoping on every query. - Views styled like
landlord/frontend/user/dashboard/*(Bootstrap-based frontend style — not the admin Ink system; this is a frontend tree). - Menu entry via the positionable filter.
- 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.
| Function | Signature | Notes |
|---|---|---|
hook() | hook(): HookContract | Raw 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): string | Like 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): mixed | Pipe a value through all callbacks. |
menu_insert_before | (array $items, string $beforeKey, string $newKey, array $newItem): array | Insert before a key; missing anchor prepends. |
menu_insert_after | (array $items, string $afterKey, string $newKey, array $newItem): array | Insert after a key; missing anchor appends. |
menu_insert_before_many | (array $items, string $beforeKey, array $newItems): array | Insert a block of keyed items before a key, preserving order. |
menu_insert | (array $items, string $newKey, array $newItem, array $position = []): array | Preferred position with fallback anchors (['after' => ..., 'before' => ...]). |
view_exists | (string $view): bool | True only if the view resolves — guards optional module/plugin namespaces. |
Actions vs filters
| Type | Function pair | What happens | Use for |
|---|---|---|---|
| Action | add_action / do_action / render_action | Callbacks 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. |
| Filter | add_filter / apply_filters | Each 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 priority20, 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_casewith 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
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.
Frontend head and footer
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_deductedis 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_actionin 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.jsonwith a uniqueidand the correctproviderFQCN (hook_based: truefor pure hook consumers). - Enabled in
core/modules_statuses.json;composer dump-autoloadrun. - Parallel route groups and names for every panel you serve;
tenantRouteset whereverrouteis. - POST + CSRF for state changes; ownership scoping on user-facing queries.
- Admin views follow Ink (
x-ui.*, Lucide,dark:pairs, Alpine,hiddennotd-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 afunction_existsguard; 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.

