> ## Documentation Index
> Fetch the complete documentation index at: https://docs.palpluss.com/llms.txt
> Use this file to discover all available pages before exploring further.

# PHP SDK

> Use the PalPluss PHP SDK with Laravel, Symfony, and plain PHP applications.

# PHP SDK

```bash theme={null}
composer require palpluss/sdk
```

Requires PHP 8.1+ and the `ext-json` extension (standard in all modern PHP distributions).

***

## Client initialisation

```php theme={null}
use PalPluss\PalPluss;

$client = new PalPluss(
    apiKey: 'pk_live_...',          // or reads PALPLUSS_API_KEY from $_ENV
    timeout: 30.0,
    autoRetryOnRateLimit: true,
    maxRetries: 3,
);
```

***

## Framework examples

<Tabs>
  <Tab title="Laravel">
    **Register as a singleton in a service provider:**

    ```php theme={null}
    // app/Providers/PalPlussServiceProvider.php
    namespace App\Providers;

    use Illuminate\Support\ServiceProvider;
    use PalPluss\PalPluss;

    class PalPlussServiceProvider extends ServiceProvider
    {
        public function register(): void
        {
            $this->app->singleton(PalPluss::class, function () {
                return new PalPluss(
                    apiKey: config('services.palpluss.key'),
                );
            });
        }
    }
    ```

    Add the key in `config/services.php`:

    ```php theme={null}
    'palpluss' => [
        'key' => env('PALPLUSS_API_KEY'),
    ],
    ```

    **Controller**

    ```php theme={null}
    // app/Http/Controllers/PaymentController.php
    namespace App\Http\Controllers;

    use Illuminate\Http\Request;
    use Illuminate\Http\JsonResponse;
    use PalPluss\PalPluss;
    use PalPluss\Http\Errors\PalPlussApiError;

    class PaymentController extends Controller
    {
        public function __construct(private readonly PalPluss $palpluss) {}

        public function initiatePayment(Request $request): JsonResponse
        {
            $validated = $request->validate([
                'amount'    => 'required|numeric|min:1',
                'phone'     => 'required|string',
                'reference' => 'nullable|string',
            ]);

            try {
                $tx = $this->palpluss->stkPush(
                    amount: $validated['amount'],
                    phone:  $validated['phone'],
                    accountReference: $validated['reference'] ?? '',
                    callbackUrl: route('webhooks.palpluss'),
                    channelId: 'your-payment-channel-id'
                );

                return response()->json([
                    'transaction_id' => $tx['transactionId'],
                    'status'         => $tx['status'],
                ]);
            } catch (PalPlussApiError $e) {
                return response()->json([
                    'error' => $e->getMessage(),
                    'code'  => $e->errorCode,
                ], $e->httpStatus);
            }
        }
    }
    ```

    **Webhook controller**

    ```php theme={null}
    // app/Http/Controllers/WebhookController.php
    namespace App\Http\Controllers;

    use Illuminate\Http\Request;
    use Illuminate\Http\Response;
    use PalPluss\Webhooks;

    class WebhookController extends Controller
    {
        public function handlePalPluss(Request $request): Response
        {
            try {
                $payload = Webhooks::parsePayload($request->getContent());
            } catch (\InvalidArgumentException) {
                return response('Invalid payload', 400);
            }

            $eventType   = $payload['event_type'];
            $transaction = $payload['transaction'];

            match ($eventType) {
                'transaction.success' => $this->onSuccess($transaction),
                'transaction.failed'  => $this->onFailed($transaction),
                default               => null,
            };

            return response('', 200);
        }

        private function onSuccess(array $transaction): void
        {
            // fulfil order, dispatch a job, etc.
            logger()->info('Payment confirmed', [
                'id'      => $transaction['id'],
                'receipt' => $transaction['mpesa_receipt'],
            ]);
        }

        private function onFailed(array $transaction): void
        {
            logger()->warning('Payment failed', [
                'id'   => $transaction['id'],
                'desc' => $transaction['result_desc'],
            ]);
        }
    }
    ```

    **Routes**

    ```php theme={null}
    // routes/api.php
    use App\Http\Controllers\PaymentController;
    use App\Http\Controllers\WebhookController;

    Route::post('/pay', [PaymentController::class, 'initiatePayment']);
    Route::post('/webhooks/palpluss', [WebhookController::class, 'handlePalPluss'])
        ->name('webhooks.palpluss')
        ->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]);
    ```

    **Queue jobs for async processing**

    Dispatching a job from the webhook endpoint keeps response times fast and prevents timeout retries:

    ```php theme={null}
    // app/Jobs/ProcessPalPlussWebhook.php
    namespace App\Jobs;

    use Illuminate\Bus\Queueable;
    use Illuminate\Contracts\Queue\ShouldQueue;
    use Illuminate\Foundation\Bus\Dispatchable;

    class ProcessPalPlussWebhook implements ShouldQueue
    {
        use Dispatchable, Queueable;

        public function __construct(public readonly array $payload) {}

        public function handle(): void
        {
            $eventType   = $this->payload['event_type'];
            $transaction = $this->payload['transaction'];

            if ($eventType === 'transaction.success') {
                // fulfil order
            }
        }
    }
    ```

    ```php theme={null}
    // In WebhookController::handlePalPluss — dispatch instead of processing inline
    ProcessPalPlussWebhook::dispatch($payload);
    return response('', 200);
    ```
  </Tab>

  <Tab title="Symfony">
    **Register the client as a service:**

    ```yaml theme={null}
    # config/services.yaml
    services:
      PalPluss\PalPluss:
        arguments:
          $apiKey: '%env(PALPLUSS_API_KEY)%'
    ```

    **Controller**

    ```php theme={null}
    // src/Controller/PaymentController.php
    namespace App\Controller;

    use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
    use Symfony\Component\HttpFoundation\Request;
    use Symfony\Component\HttpFoundation\JsonResponse;
    use Symfony\Component\Routing\Attribute\Route;
    use PalPluss\PalPluss;
    use PalPluss\Http\Errors\PalPlussApiError;

    class PaymentController extends AbstractController
    {
        public function __construct(private readonly PalPluss $palpluss) {}

        #[Route('/api/pay', methods: ['POST'])]
        public function initiatePayment(Request $request): JsonResponse
        {
            $body = json_decode($request->getContent(), true);

            try {
                $tx = $this->palpluss->stkPush(
                    amount: $body['amount'],
                    phone:  $body['phone'],
                    accountReference: $body['reference'] ?? '',
                    callbackUrl: $this->generateUrl(
                        'webhook_palpluss',
                        referenceType: \Symfony\Component\Routing\Generator\UrlGeneratorInterface::ABSOLUTE_URL
                    ),
                    channelId: 'your-payment-channel-id'
                );

                return $this->json([
                    'transaction_id' => $tx['transactionId'],
                    'status'         => $tx['status'],
                ]);
            } catch (PalPlussApiError $e) {
                return $this->json(
                    ['error' => $e->getMessage(), 'code' => $e->errorCode],
                    $e->httpStatus,
                );
            }
        }
    }
    ```

    **Webhook controller**

    ```php theme={null}
    // src/Controller/WebhookController.php
    namespace App\Controller;

    use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
    use Symfony\Component\HttpFoundation\Request;
    use Symfony\Component\HttpFoundation\Response;
    use Symfony\Component\Routing\Attribute\Route;
    use PalPluss\Webhooks;

    class WebhookController extends AbstractController
    {
        #[Route('/webhooks/palpluss', name: 'webhook_palpluss', methods: ['POST'])]
        public function handlePalPluss(Request $request): Response
        {
            try {
                $payload = Webhooks::parsePayload($request->getContent());
            } catch (\InvalidArgumentException) {
                return new Response('Invalid payload', Response::HTTP_BAD_REQUEST);
            }

            $eventType   = $payload['event_type'];
            $transaction = $payload['transaction'];

            if ($eventType === 'transaction.success') {
                // fulfil order
            } elseif ($eventType === 'transaction.failed') {
                // notify customer
            }

            return new Response('', Response::HTTP_OK);
        }
    }
    ```
  </Tab>

  <Tab title="Plain PHP">
    ```php theme={null}
    <?php
    // index.php
    require_once __DIR__ . '/vendor/autoload.php';

    use PalPluss\PalPluss;
    use PalPluss\Webhooks;
    use PalPluss\Http\Errors\PalPlussApiError;
    use PalPluss\Http\Errors\RateLimitError;

    $palpluss = new PalPluss(apiKey: $_ENV['PALPLUSS_API_KEY'] ?? getenv('PALPLUSS_API_KEY'));

    $path   = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
    $method = $_SERVER['REQUEST_METHOD'];

    // POST /pay
    if ($method === 'POST' && $path === '/pay') {
        $body = json_decode(file_get_contents('php://input'), true);

        try {
            $tx = $palpluss->stkPush(
                amount: (float) $body['amount'],
                phone:  $body['phone'],
                accountReference: $body['reference'] ?? '',
                callbackUrl: 'https://yourapp.com/webhooks/palpluss',
                channelId: 'your-payment-channel-id'
            );

            header('Content-Type: application/json');
            echo json_encode(['transaction_id' => $tx['transactionId'], 'status' => $tx['status']]);
        } catch (RateLimitError $e) {
            http_response_code(429);
            header("Retry-After: {$e->retryAfter}");
            echo json_encode(['error' => 'Rate limited', 'retry_after' => $e->retryAfter]);
        } catch (PalPlussApiError $e) {
            http_response_code($e->httpStatus);
            echo json_encode(['error' => $e->getMessage(), 'code' => $e->errorCode]);
        }
        exit;
    }

    // POST /webhooks/palpluss
    if ($method === 'POST' && $path === '/webhooks/palpluss') {
        $raw = file_get_contents('php://input');

        try {
            $payload = Webhooks::parsePayload($raw);
        } catch (\InvalidArgumentException $e) {
            http_response_code(400);
            echo json_encode(['error' => 'Invalid payload']);
            exit;
        }

        http_response_code(200);
        echo json_encode(['received' => true]);

        // Process after sending response
        fastcgi_finish_request(); // only on PHP-FPM

        $eventType   = $payload['event_type'];
        $transaction = $payload['transaction'];

        if ($eventType === 'transaction.success') {
            // fulfil order
        }
        exit;
    }

    http_response_code(404);
    echo json_encode(['error' => 'Not found']);
    ```
  </Tab>
</Tabs>

***

## STK Push

```php theme={null}
$tx = $client->stkPush(
    amount: 500,
    phone: '254712345678',
    accountReference: 'ORDER-001',
    transactionDesc: 'Payment for order #001',
    callbackUrl: 'https://yourapp.com/webhooks/palpluss',
    channelId: 'your-payment-channel-id'
);

echo $tx['transactionId'];  // save for polling / reconciliation
echo $tx['status'];         // "PENDING"
```

***

## B2C Payout

Always supply an `idempotencyKey` so you can safely retry without double-paying:

```php theme={null}
$payout = $client->b2cPayout(
    amount: 1000,
    phone: '254712345678',
    reference: 'SALARY-JAN-2025-EMP001',
    description: 'January salary',
    idempotencyKey: 'salary-jan-2025-emp001',
);

echo $payout['transactionId'];
echo $payout['status'];  // "PENDING"
```

***

## Service wallet

```php theme={null}
// Check balance
$balance = $client->getServiceBalance();
echo $balance['availableBalance'];  // e.g. 45000

// Top up via STK Push
$topup = $client->serviceTopup(
    amount: 10000,
    phone: '254712345678',
    idempotencyKey: 'topup-jan-2025-001',
);
```

***

## Transactions

```php theme={null}
// Fetch one
$tx = $client->getTransaction('tx_01j8abc123');
echo $tx['status'];  // "SUCCESS"

// List with pagination
$cursor = null;

do {
    $page = $client->listTransactions(
        limit: 100,
        status: 'SUCCESS',
        type: 'STK',
        cursor: $cursor,
    );

    foreach ($page['items'] as $item) {
        echo $item['transaction_id'] . ' ' . $item['status'] . "\n";
    }

    $cursor = $page['next_cursor'] ?? null;
} while ($cursor !== null);
```

***

## Payment wallet channels

```php theme={null}
// Create
$channel = $client->createChannel(
    type: 'PAYBILL',
    shortcode: '400200',
    name: 'Main Paybill',
    isDefault: true,
);

// Update
$client->updateChannel(
    channelId: $channel['id'],
    name: 'Primary Paybill',
);

// Delete
$client->deleteChannel($channel['id']);
```

***

## Error handling

```php theme={null}
use PalPluss\Http\Errors\PalPlussApiError;
use PalPluss\Http\Errors\RateLimitError;

try {
    $result = $client->stkPush(amount: 500, phone: '254712345678');
} catch (RateLimitError $e) {
    // $e->retryAfter — seconds to wait before retrying
    echo "Rate limited — retry after {$e->retryAfter}s\n";
} catch (PalPlussApiError $e) {
    // $e->errorCode  — machine-readable code e.g. "INVALID_PHONE"
    // $e->httpStatus — HTTP status code       e.g. 400
    // $e->requestId  — include in support requests
    echo "[{$e->errorCode}] HTTP {$e->httpStatus}: {$e->getMessage()}\n";
    echo "Request ID: {$e->requestId}\n";
}
```

### Common error codes

| Code                           | HTTP | Meaning                                    |
| ------------------------------ | ---- | ------------------------------------------ |
| `INVALID_PHONE`                | 400  | Phone number format not recognised         |
| `INSUFFICIENT_SERVICE_BALANCE` | 402  | Not enough service tokens to cover the fee |
| `INSUFFICIENT_FUNDS`           | 409  | B2C wallet balance below requested amount  |
| `KYC_NOT_VERIFIED`             | 403  | KYC must be approved before sending B2C    |
| `INVALID_API_KEY`              | 401  | API key missing, invalid, or revoked       |
| `RATE_LIMIT_EXCEEDED`          | 429  | 60 requests per minute limit exceeded      |
