Pitor Store Dev
Plugin Developer Documentation

Extend Pitor Store
with your own plugins

Build payment gateways, shipping integrations, analytics, themes, and more. Drop a folder — the platform discovers and loads your plugin automatically.

5+
Plugin types
Zero config
Auto-discovery
WordPress-style
Hooks & Filters
Multi-tenant
Plugin isolation

How the Plugin System Works

Pitor uses auto-discovery — no registration step, no config change needed.

STEP 01

Create

Create a folder inside plugins/ at the project root. Add a plugin.json manifest and your PHP entry class.

STEP 02

Upload

Send a ZIP file of your plugin folder to the platform admin. They extract it into plugins/ and click Install in the admin panel.

STEP 03

Enable

The platform admin enables the plugin globally. Tenants can then activate and configure it from their own store dashboard.

Plugin File Structure

Every plugin is a self-contained folder.

Required structure

plugins/
└── my-payment-plugin/
├── plugin.json ← manifest (required)
├── src/
└── MyPaymentPlugin.php ← entry class
├── views/ ← optional blade views
├── assets/ ← optional CSS/JS
└── README.md ← optional

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

Quickstart — Build Your First Plugin

From zero to a working plugin in 5 minutes.

1

Create the folder and manifest

mkdir plugins/my-plugin
mkdir plugins/my-plugin/src

Then create plugins/my-plugin/plugin.json using the schema above.

2

Write the entry class

<?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.
        });
    }
}
3

Implement a typed contract (for payment / shipping plugins)

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  { ... }
}
4

Test locally, then package for upload

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/

Upload & Deploy Your Plugin

Plugins are deployed by the platform admin. Here's the full flow.

Developer (you)

What you do

  1. 1

    Build your plugin

    Follow the quickstart above. Test locally.

  2. 2

    Create a ZIP

    Zip your plugin folder: my-plugin.zip — the folder name must match the slug in plugin.json.

  3. 3

    Send to platform admin

    Email or share the ZIP with the platform admin along with your README or setup instructions.

  4. 4

    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

  1. 1

    Review the plugin

    Inspect the ZIP for security. Never run untrusted code.

  2. 2

    Extract to plugins/

    Unzip into the plugins/ folder so the path is plugins/my-plugin/.

  3. 3

    Open Admin → Plugins

    The platform auto-discovers the new folder. Click Install next to the plugin.

  4. 4

    Enable & configure

    Toggle the plugin to Enabled. Fill in any platform-level config values.

  5. 5

    Tenants activate it

    Each tenant goes to their Dashboard → Plugins to activate and configure the plugin for their store.

Deployment Flow

Developer builds ZIP Admin receives ZIP Extracted to plugins/ Admin installs & enables Tenants activate in dashboard

Plugin Code Examples

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]
            );
        });
    }
}

API Reference

Key interfaces, hooks, and helper functions available to your plugin.

🪝 Action Hooks

order.placed ($order)

Fired when a new order is created

order.paid ($order)

Fired when payment is confirmed

order.shipped ($order)

Fired when order is marked shipped

tenant.created ($tenant)

Fired when a new tenant registers

tenant.activated ($tenant)

Fired when tenant is activated

wallet.topup ($transaction)

Fired on successful wallet top-up

plugin.installed ($slug)

Fired after a plugin is installed

plugin.enabled ($slug)

Fired when a plugin is enabled

🔀 Filters

checkout.total ($total)

Modify order total before payment

product.price ($price, $product)

Override product price

shipping.rates ($rates, $order)

Modify available shipping options

invoice.data ($data, $order)

Alter invoice data before rendering

tenant.config ($config, $tenant)

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
    }
);

⚙️ Config Field Types

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

🛠️ Global Helpers

hooks()

Returns the HookDispatcher instance

platform_setting($key)

Read a platform-level setting

tenant_setting($key)

Read a tenant-level setting (in tenant context)

current_tenant()

Returns the active Tenant model

platformCurrency()

Returns the platform currency code

currencySymbol($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.

Contact Us

Ready to build your first plugin?

Follow the quickstart above, package your ZIP, and reach out to get it deployed on the platform.