Build payment gateways, shipping integrations, analytics, themes, and more. Drop a folder — the platform discovers and loads your plugin automatically.
Pitor uses auto-discovery — no registration step, no config change needed.
Create a folder inside plugins/ at the project root. Add a plugin.json manifest and your PHP entry class.
Send a ZIP file of your plugin folder to the platform admin. They extract it into plugins/ and click Install in the admin panel.
The platform admin enables the plugin globally. Tenants can then activate and configure it from their own store dashboard.
Every plugin is a self-contained folder.
Required structure
plugin.json
{
"slug": "my-payment-plugin",
"name": "My Payment Plugin",
"version": "1.0.0",
"description": "Accept payments via My Gateway.",
"type": "payment",
"author": "Your Name",
"author_url": "https://yoursite.com",
"entry_class": "MyPaymentPlugin\\MyPaymentPlugin",
"requires": "1.0.0",
"website": "https://yoursite.com/plugins/my-payment",
"autoload": {
"psr-4": {
"MyPaymentPlugin\\": "src/"
}
},
"config": {
"api_key": "",
"secret": ""
}
}
Available plugin types
payment
Integrate payment gateways (Stripe, bKash, etc.)
shipping
Add shipping providers (Pathao, Steadfast, etc.)
theme
Custom storefront themes
analytics
Track events & display reports
notification
SMS, push, email notification drivers
generic
Any other extension
From zero to a working plugin in 5 minutes.
mkdir plugins/my-plugin mkdir plugins/my-plugin/src
Then create plugins/my-plugin/plugin.json using the schema above.
<?php namespace MyPlugin; use App\Plugins\AbstractPlugin; class MyPlugin extends AbstractPlugin { /** Called when platform admin installs the plugin. */ public function onInstall(): void { // Run migrations, seed defaults, etc. } /** Called when a tenant activates this plugin in their store. */ public function onTenantEnable(): void { // Per-tenant setup. } /** Register hooks so your plugin can extend the storefront. */ public function boot(): void { hooks()->addAction('order.placed', function($order) { // Do something every time an order is placed. }); hooks()->addFilter('checkout.total', function($total) { return $total; // Modify and return. }); } }
For payment and shipping plugins, also implement the matching contract to unlock first-class platform integration.
use App\Contracts\PaymentGatewayDriver; // or TopupProviderDriver class MyPaymentPlugin extends AbstractPlugin implements PaymentGatewayDriver { public function charge(int $amountPaisa, array $meta): array { // Call your gateway API and return ['success'=>true, 'ref'=>'...'] } public function refund(string $ref, int $amountPaisa): bool { ... } public function getConfigFields(): array { ... } }
Drop your plugin folder into a local copy of the project to verify it is auto-discovered and loads without errors.
# Verify discovery php artisan tinker --execute="print_r(app(App\Plugins\PluginRegistry::class)->allManifests());" # Package into a zip (use the folder name as the zip name) cd plugins && zip -r my-payment-plugin.zip my-payment-plugin/
Plugins are deployed by the platform admin. Here's the full flow.
Developer (you)
What you do
Build your plugin
Follow the quickstart above. Test locally.
Create a ZIP
Zip your plugin folder: my-plugin.zip — the folder name must match the slug in plugin.json.
Send to platform admin
Email or share the ZIP with the platform admin along with your README or setup instructions.
Provide config docs
Document any API keys or config fields your plugin needs so the admin and tenants know what to fill in.
Platform Admin
What the admin does
Review the plugin
Inspect the ZIP for security. Never run untrusted code.
Extract to plugins/
Unzip into the plugins/ folder so the path is plugins/my-plugin/.
Open Admin → Plugins
The platform auto-discovers the new folder. Click Install next to the plugin.
Enable & configure
Toggle the plugin to Enabled. Fill in any platform-level config values.
Tenants activate it
Each tenant goes to their Dashboard → Plugins to activate and configure the plugin for their store.
Deployment Flow
Copy-paste complete plugin examples for every supported type.
A generic plugin is the simplest type — perfect for adding hooks, custom routes, or any behaviour that doesn't fit another type.
plugin.json
{
"slug": "discount-coupons",
"name": "Discount Coupons",
"version": "1.0.0",
"description": "Adds 10% discount to every order via hook.",
"type": "generic",
"author": "Acme Corp",
"author_url": "https://acmecorp.com",
"entry_class": "Plugins\\DiscountCoupons\\DiscountCouponsPlugin",
"autoload": "src",
"license": "MIT"
}
Config fields (optional)
public function getConfigFields(): array { return [ [ 'key' => 'discount_pct', 'label' => 'Discount %', 'type' => 'number', 'required' => true, 'default' => 10, 'help' => 'Percentage off every order total.', ], ]; }
src/DiscountCouponsPlugin.php
<?php namespace Plugins\DiscountCoupons; use App\Plugins\AbstractPlugin; class DiscountCouponsPlugin extends AbstractPlugin { public function getSlug(): string { return 'discount-coupons'; } public function getName(): string { return 'Discount Coupons'; } public function getDescription(): string { return 'Adds configurable discount to every order.'; } public function getAuthor(): string { return 'Acme Corp'; } public function getType(): string { return 'generic'; } public function getDefaultConfig(): array { return ['discount_pct' => 10]; } /** * boot() is called on every request while the plugin is active for the tenant. * Register your hooks and filters here. */ public function boot($app): void { // ── Action: log every placed order ────────────────────────────── hooks()->addAction('order.placed', function($order): void { \Log::info('[DiscountCoupons] New order #' . $order->id); }); // ── Filter: apply percentage discount to every checkout total ─── $pct = (float) $this->config('discount_pct', 10); hooks()->addFilter('cart.total', function(float $total) use ($pct): float { return $total * (1 - ($pct / 100)); }); } /** Called once when the platform admin installs the plugin. */ public function install(): void { // Optionally run migrations here. } /** Called when the plugin is removed from the platform. */ public function uninstall(): void { // Clean up DB tables or stored data. } }
Payment plugins implement PaymentPlugin + PaymentGatewayDriver so the platform can collect payments and issue refunds through your gateway.
plugin.json
{
"slug": "acme-pay",
"name": "Acme Pay Gateway",
"version": "1.0.0",
"description": "Accept payments via Acme Pay.",
"type": "payment",
"author": "Acme Corp",
"author_url": "https://acmecorp.com",
"entry_class": "Plugins\\AcmePay\\AcmePayPlugin",
"autoload": "src",
"license": "MIT"
}
PaymentGatewayDriver interface
interface PaymentGatewayDriver { public function getSlug(): string; public function getName(): string; public function isOnline(): bool; public function getConfigFields(): array; public function initiate($order, array $data): PaymentResult; public function callback($request): PaymentResult; public function verify(string $txnId): PaymentResult; public function refund($payment, float $amount, string $reason): PaymentResult; }
src/AcmePayPlugin.php — complete example
<?php namespace Plugins\AcmePay; use App\Contracts\PaymentGatewayDriver; use App\Contracts\Plugins\PaymentPlugin; use App\Plugins\AbstractPlugin; use App\Services\PaymentGateways\PaymentResult; use Illuminate\Support\Facades\Http; class AcmePayPlugin extends AbstractPlugin implements PaymentPlugin, PaymentGatewayDriver { // ── Identity ─────────────────────────────────────────────────────────── public function getSlug(): string { return 'acme-pay'; } public function getName(): string { return 'Acme Pay Gateway'; } public function getDescription(): string { return 'Accept payments via Acme Pay.'; } public function getAuthor(): string { return 'Acme Corp'; } public function getType(): string { return 'payment'; } public function isOnline(): bool { return true; } public function getCurrency(): ?string { return null; } // null = multi-currency // PaymentPlugin: return $this because the plugin IS the driver public function getDriver(): PaymentGatewayDriver { return $this; } // ── Config fields shown in Tenant → Plugins → Acme Pay ──────────────── public function getConfigFields(): array { return [ ['key' => 'api_key', 'label' => 'API Key', 'type' => 'password', 'required' => true], ['key' => 'api_secret', 'label' => 'API Secret', 'type' => 'password', 'required' => true], [ 'key' => 'mode', 'label' => 'Mode', 'type' => 'select', 'default' => 'sandbox', 'options' => [ ['value' => 'sandbox', 'label' => 'Sandbox (Test)'], ['value' => 'live', 'label' => 'Live'], ], ], ]; } public function getDefaultConfig(): array { return ['mode' => 'sandbox']; } // ── Payment flow ──────────────────────────────────────────────────────── /** Step 1 – redirect buyer to gateway or return pay-now data. */ public function initiate($order, array $data = []): PaymentResult { $base = $this->baseUrl(); $result = Http::withBasicAuth($this->config('api_key'), $this->config('api_secret')) ->post("$base/payments", [ 'amount' => $order->total, 'currency' => $order->currency ?? 'BDT', 'reference' => (string) $order->id, 'redirect_url' => route('tenant.checkout.callback'), ]); if ($result->successful()) { return PaymentResult::redirect($result->json('payment_url')); } return PaymentResult::fail($result->json('message', 'Gateway error')); } /** Step 2 – handle the gateway's callback / webhook. */ public function callback($request): PaymentResult { $txnId = $request->input('transaction_id'); $status = $request->input('status'); return $status === 'SUCCESS' ? PaymentResult::success($txnId) : PaymentResult::fail('Payment not completed'); } /** Verify a transaction ID with the gateway API. */ public function verify(string $txnId): PaymentResult { $base = $this->baseUrl(); $result = Http::withBasicAuth($this->config('api_key'), $this->config('api_secret')) ->get("$base/payments/$txnId"); return $result->json('status') === 'paid' ? PaymentResult::success($txnId) : PaymentResult::fail('Transaction not found or unpaid'); } /** Issue a refund. */ public function refund($payment, float $amount, string $reason = ''): PaymentResult { $base = $this->baseUrl(); $result = Http::withBasicAuth($this->config('api_key'), $this->config('api_secret')) ->post("$base/refunds", [ 'transaction_id' => $payment->transaction_id, 'amount' => $amount, 'reason' => $reason, ]); return $result->successful() ? PaymentResult::success($result->json('refund_id')) : PaymentResult::fail($result->json('message', 'Refund failed')); } // ── Private helpers ───────────────────────────────────────────────────── private function baseUrl(): string { return $this->config('mode') === 'live' ? 'https://api.acmepay.com/v1' : 'https://sandbox.acmepay.com/v1'; } }
Shipping plugins implement ShippingPlugin to provide live rates at checkout, create consignments on fulfillment, and return tracking info.
plugin.json
{
"slug": "acme-courier",
"name": "Acme Courier Shipping",
"version": "1.0.0",
"description": "Live rates and fulfilment via Acme Courier.",
"type": "shipping",
"author": "Acme Corp",
"author_url": "https://acmecorp.com",
"entry_class": "Plugins\\AcmeCourier\\AcmeCourierPlugin",
"autoload": "src",
"license": "MIT"
}
ShippingPlugin interface
interface ShippingPlugin extends PluginContract { /** Return an array of shipping rate options. */ public function getRates(array $items, array $address): array; /** Create a shipment; return tracking number. */ public function createShipment(int $orderId, array $address, array $parcels): string; /** Return tracking info for a consignment. */ public function trackShipment(string $trackingNumber): array; public function getCarrierName(): string; }
src/AcmeCourierPlugin.php — complete example
<?php namespace Plugins\AcmeCourier; use App\Contracts\Plugins\ShippingPlugin; use App\Plugins\AbstractPlugin; use Illuminate\Support\Facades\Http; class AcmeCourierPlugin extends AbstractPlugin implements ShippingPlugin { private string $baseUrl = 'https://api.acmecourier.com/v1'; // ── Identity ─────────────────────────────────────────────────────────── public function getSlug(): string { return 'acme-courier'; } public function getName(): string { return 'Acme Courier Shipping'; } public function getDescription(): string { return 'Live rates and fulfilment via Acme Courier.'; } public function getAuthor(): string { return 'Acme Corp'; } public function getType(): string { return 'shipping'; } public function getCarrierName(): string { return 'Acme Courier'; } // ── Config ───────────────────────────────────────────────────────────── public function getConfigFields(): array { return [ ['key' => 'api_key', 'label' => 'API Key', 'type' => 'password', 'required' => true], ['key' => 'store_id', 'label' => 'Store ID', 'type' => 'text', 'required' => true], [ 'key' => 'service', 'label' => 'Service', 'type' => 'select', 'default' => 'standard', 'options' => [ ['value' => 'standard', 'label' => 'Standard (3–5 days)'], ['value' => 'express', 'label' => 'Express (1 day)'], ], ], ]; } // ── ShippingPlugin ───────────────────────────────────────────────────── /** * Return live rates for the buyer's cart + address. * * @param array $items [['sku'=>..., 'quantity'=>..., 'weight_grams'=>...], ...] * @param array $address ['city'=>..., 'zone'=>..., 'area'=>...] * @return array [['id'=>..., 'label'=>..., 'price'=>...], ...] */ public function getRates(array $items, array $address): array { $weightKg = collect($items)->sum( fn($i) => (($i['weight_grams'] ?? 500) * $i['quantity']) / 1000 ); try { $resp = Http::withToken($this->config('api_key')) ->post("{$this->baseUrl}/rates", [ 'store_id' => $this->config('store_id'), 'weight_kg' => $weightKg, 'service' => $this->config('service', 'standard'), 'destination' => $address, ]); if ($resp->successful()) { return collect($resp->json('rates', []))->map(fn($r) => [ 'id' => 'acme-' . $r['service'], 'label' => $r['label'] . ' — ' . $r['price'] . ' BDT', 'price' => (float) $r['price'], ])->values()->all(); } } catch (\Throwable) {} return []; } /** * Create a consignment after the merchant fulfils the order. * Returns the tracking number. */ public function createShipment(int $orderId, array $address, array $parcels): string { $resp = Http::withToken($this->config('api_key')) ->post("{$this->baseUrl}/orders", [ 'store_id' => $this->config('store_id'), 'merchant_order_id' => (string) $orderId, 'recipient_name' => $address['name'] ?? '', 'recipient_phone' => $address['phone'] ?? '', 'recipient_address' => $address['address'] ?? '', 'recipient_city' => $address['city'] ?? '', 'parcel_weight' => $parcels[0]['weight_kg'] ?? 0.5, 'service' => $this->config('service', 'standard'), ]); if (! $resp->successful()) { throw new \RuntimeException('AcmeCourier: failed to create shipment — ' . $resp->body()); } return $resp->json('consignment_id'); } /** Fetch current status of a shipment. */ public function trackShipment(string $trackingNumber): array { $resp = Http::withToken($this->config('api_key')) ->get("{$this->baseUrl}/orders/{$trackingNumber}"); return [ 'status' => $resp->json('status'), 'location' => $resp->json('current_location'), 'updated_at' => $resp->json('updated_at'), 'events' => $resp->json('events', []), ]; } }
Notification plugins implement NotificationPlugin to add new channels (SMS, WhatsApp, push) that tenants can use for order/customer alerts.
plugin.json
{
"slug": "acme-sms",
"name": "Acme SMS Notifications",
"version": "1.0.0",
"description": "Send SMS alerts via Acme SMS API.",
"type": "notification",
"author": "Acme Corp",
"author_url": "https://acmecorp.com",
"entry_class": "Plugins\\AcmeSms\\AcmeSmsPlugin",
"autoload": "src",
"license": "MIT"
}
NotificationPlugin interface
interface NotificationPlugin extends PluginContract { /** Unique channel name, e.g. 'sms', 'whatsapp' */ public function getChannelName(): string; /** Send a message; return true on success. */ public function send( string $recipient, string $subject, string $body, array $data = [] ): bool; /** Variables that can appear in message templates */ public function getTemplateVariables(): array; }
src/AcmeSmsPlugin.php — complete example
<?php namespace Plugins\AcmeSms; use App\Contracts\Plugins\NotificationPlugin; use App\Plugins\AbstractPlugin; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; class AcmeSmsPlugin extends AbstractPlugin implements NotificationPlugin { // ── Identity ─────────────────────────────────────────────────────────── public function getSlug(): string { return 'acme-sms'; } public function getName(): string { return 'Acme SMS Notifications'; } public function getDescription(): string { return 'Send SMS alerts via Acme SMS API.'; } public function getAuthor(): string { return 'Acme Corp'; } public function getType(): string { return 'notification'; } public function getChannelName(): string { return 'sms'; } // ── Config ───────────────────────────────────────────────────────────── public function getConfigFields(): array { return [ ['key' => 'api_key', 'label' => 'API Key', 'type' => 'password', 'required' => true], ['key' => 'sender_id', 'label' => 'Sender ID', 'type' => 'text', 'required' => true, 'help' => 'The alphanumeric sender name shown on the SMS.'], ]; } // ── NotificationPlugin ───────────────────────────────────────────────── /** * Send an SMS to a recipient phone number. * * @param string $recipient Phone number, e.g. '+8801700000000' * @param string $subject Channel label (not used for SMS, but passed for compatibility) * @param string $body Message text (variable placeholders already resolved) * @param array $data Extra context (order ID, etc.) */ public function send(string $recipient, string $subject, string $body, array $data = []): bool { try { $resp = Http::withToken($this->config('api_key')) ->post('https://api.acmesms.com/v1/send', [ 'to' => $recipient, 'from' => $this->config('sender_id'), 'message' => $body, 'ref' => $data['order_id'] ?? null, ]); return $resp->successful(); } catch (\Throwable $e) { Log::error('[AcmeSMS] Send failed: ' . $e->getMessage()); return false; } } /** Variables the admin can use in notification templates. */ public function getTemplateVariables(): array { return [ ['key' => '{{order_number}}', 'label' => 'Order Number'], ['key' => '{{customer_name}}', 'label' => 'Customer Name'], ['key' => '{{order_total}}', 'label' => 'Order Total'], ['key' => '{{tracking_number}}', 'label' => 'Tracking Number'], ['key' => '{{store_name}}', 'label' => 'Store Name'], ]; } /** Hook into order events in boot() to auto-send SMS. */ public function boot($app): void { hooks()->addAction('order.placed', function($order): void { $this->send( $order->customer_phone, 'New Order', "Hi {$order->customer_name}, your order #{$order->id} has been received!", ['order_id' => $order->id] ); }); hooks()->addAction('order.paid', function($order): void { $this->send( $order->customer_phone, 'Payment Confirmed', "Payment confirmed for order #{$order->id}. We're preparing it now!", ['order_id' => $order->id] ); }); } }
Key interfaces, hooks, and helper functions available to your plugin.
Fired when a new order is created
Fired when payment is confirmed
Fired when order is marked shipped
Fired when a new tenant registers
Fired when tenant is activated
Fired on successful wallet top-up
Fired after a plugin is installed
Fired when a plugin is enabled
Modify order total before payment
Override product price
Modify available shipping options
Alter invoice data before rendering
Override per-tenant config values
// Usage in your plugin's boot() method: hooks()->addFilter('checkout.total', function(float $total): float { return $total * 0.9; // 10% discount } );
Return these from getConfigFields() to auto-render a settings form in the admin panel.
text
Single-line text input
password
Masked text (API keys, secrets)
select
Dropdown with predefined options
boolean
Toggle switch (true/false)
textarea
Multi-line text
number
Numeric input
Returns the HookDispatcher instance
Read a platform-level setting
Read a tenant-level setting (in tenant context)
Returns the active Tenant model
Returns the platform currency code
Returns the symbol for a currency code
Want the complete technical reference?
The full plugin API docs with advanced examples, lifecycle hooks, multi-tenant isolation, and more are available in the platform's admin panel.
Follow the quickstart above, package your ZIP, and reach out to get it deployed on the platform.