froshly / parakit
The payment kit for Kurdistan and Iraq — Laravel-native.
Requires
- php: ^8.2
- firebase/php-jwt: ^7.0
- illuminate/contracts: ^11.0 || ^12.0 || ^13.0
- illuminate/database: ^11.0 || ^12.0 || ^13.0
- illuminate/http: ^11.0 || ^12.0 || ^13.0
- illuminate/support: ^11.0 || ^12.0 || ^13.0
Requires (Dev)
- larastan/larastan: ^3.0
- orchestra/testbench: ^9.0 || ^10.0 || ^11.0
- pestphp/pest: ^3.0 || ^4.0
- pestphp/pest-plugin-laravel: ^3.0 || ^4.0
- phpstan/phpstan: ^2.1
This package is auto-updated.
Last update: 2026-05-18 21:13:46 UTC
README
پارەکیت — The payment kit for Kurdistan and Iraq, Laravel-native.
Take payments from Iraqi and Kurdish customers in a Laravel app — FIB, ZainCash — with idempotent webhooks, retry & circuit-breaker, redacted logging, a sweeper for lost webhooks, and three localised UIs (en/ar/ckb) out of the box.
composer require → first sandbox charge in under 15 minutes. That's the goal.
Contents
- Install
- Charge a customer
- Receive a webhook
- What you get for free
- Operations
- Multi-tenant / multiple merchants
- Laravel Octane
- Security
- Versioning
- License
Install
composer require froshly/parakit php artisan parakit:install
Add credentials to your .env:
PARAKIT_DEFAULT=fib FIB_BASE_URL=https://fib.stage.fib.iq FIB_CLIENT_ID=your-fib-client-id FIB_CLIENT_SECRET=your-fib-client-secret FIB_CALLBACK_URL=https://yourapp.com/payments/webhooks/fib ZAINCASH_BASE_URL=https://test.zaincash.iq ZAINCASH_MERCHANT_ID=your-merchant-id ZAINCASH_MSISDN=07xxxxxxxxx ZAINCASH_SECRET=your-32-byte-or-longer-secret ZAINCASH_REDIRECT_URL=https://yourapp.com/payments/zaincash/return
Verify everything is wired:
php artisan parakit:doctor --gateway=fib php artisan parakit:test-charge fib --amount=1000
Charge a customer
Fluent builder (recommended)
use Froshly\Parakit\Facades\Payment; use Froshly\Parakit\Enums\Currency; $response = Payment::for($order) ->driver('fib') ->amount(5000, Currency::IQD) ->description("Order #{$order->id}") ->idempotencyKey($order->id) ->charge(); if ($response->failed()) { return back()->with('error', $response->error->message(app()->getLocale())); } // FIB returns a QR + readable code + deep link. return view('checkout.fib', [ 'qrCode' => $response->qrCode, 'readableCode' => $response->readableCode, 'deepLink' => $response->deepLink, ]);
Explicit DTO
use Froshly\Parakit\DTOs\PaymentRequest; $response = Payment::driver('zaincash')->charge(new PaymentRequest( reference: $order->id, amount: 5000, currency: Currency::IQD, description: 'Order #' . $order->id, )); return redirect()->away($response->redirectUrl);
Receive a webhook
Parakit registers POST /payments/webhooks/{gateway} automatically. Listen for lifecycle events anywhere in your app:
use Froshly\Parakit\Events\PaymentSucceeded; Event::listen(PaymentSucceeded::class, function ($event) { Order::find($event->transaction->reference)->markPaid(); });
Other events: PaymentInitiated, PaymentFailed, PaymentCancelled, PaymentRefunded, WebhookReceived, WebhookVerificationFailed, GatewayTimeout, CircuitOpened.
What you get for free
| Concern | Mechanism |
|---|---|
| Idempotency | charge() with the same idempotencyKey returns a cached response for 24h. DB unique index on (gateway, event_id) makes webhook delivery race-safe. |
| Retries | Exponential backoff + jitter on transient gateway failures. Never on 4xx, never without an idempotency key. |
| Circuit breaker | Per-gateway. Fails fast after N failures, auto-recovers after cooldown. |
| State machine | Illegal status transitions (e.g. Paid → Pending) throw and are logged — no double-fulfillment. |
| Webhook replay protection | Rejects webhooks older than parakit.webhooks.tolerance_seconds. |
| Lost-webhook recovery | parakit:sweep-pending polls status for stale pending transactions every 5 minutes. |
| Redacted logging | Request/response written to payment_logs with secrets redacted by configured key names + Luhn-gated PAN regex. |
| Correlation IDs | One ULID traces a payment across charge → webhook → status → refund. |
| Translations | en / ar / ckb shipped; publish with php artisan vendor:publish --tag=parakit-lang. |
Operations
| Command | Purpose |
|---|---|
parakit:install |
Publishes config + migrations, runs migrate. |
parakit:doctor [--gateway=fib] |
Verifies config + connectivity. Non-zero exit on failure. |
parakit:test-charge fib --amount=1000 |
Sandbox roundtrip. Proves end-to-end works. |
parakit:sweep-pending |
Polls status for pending transactions to recover lost webhooks. Auto-scheduled every 5 min. |
parakit:webhook:simulate fib --transaction-id=pid_1 --status=paid |
POSTs a correctly-formed test webhook to your local app. |
parakit:logs:prune --days=90 |
Trims payment_logs per retention policy. Auto-scheduled daily. |
Multi-tenant / multiple merchants
Need FIB credentials for merchant A and a different set for merchant B? Add as many named gateway configs as you need — same driver, different keys:
// config/parakit.php 'gateways' => [ 'fib_main' => [ 'driver' => 'fib', 'base_url' => env('FIB_MAIN_BASE_URL'), 'client_id' => env('FIB_MAIN_CLIENT_ID'), 'client_secret' => env('FIB_MAIN_CLIENT_SECRET'), 'callback_url' => env('FIB_MAIN_CALLBACK_URL'), ], 'fib_branch' => [ 'driver' => 'fib', 'base_url' => env('FIB_BRANCH_BASE_URL'), 'client_id' => env('FIB_BRANCH_CLIENT_ID'), 'client_secret' => env('FIB_BRANCH_CLIENT_SECRET'), 'callback_url' => env('FIB_BRANCH_CALLBACK_URL'), ], 'zaincash_merchant_b' => [ 'driver' => 'zaincash', 'base_url' => env('ZC_B_BASE_URL'), 'merchant_id' => env('ZC_B_MERCHANT_ID'), 'msisdn' => env('ZC_B_MSISDN'), 'secret' => env('ZC_B_SECRET'), 'redirect_url'=> env('ZC_B_REDIRECT_URL'), ], ],
Then select the right one per request:
// Resolve at runtime — driven by your tenant lookup, user attribute, etc. $gateway = $merchant->gateway_key; // e.g. "fib_branch" $response = Payment::for($order) ->driver($gateway) ->amount(5000, Currency::IQD) ->description("Order #{$order->id}") ->idempotencyKey($order->id) ->charge();
Each named config is fully isolated: its own circuit-breaker state, idempotency cache namespace, webhook route (POST /payments/webhooks/fib_branch), and gateway column in payment_transactions. Two configs sharing the same driver never bleed into each other.
Credentials in a database — resolveMerchantUsing()
When merchants self-onboard and their credentials live in your database — not in config/parakit.php — register a resolver once in a service provider. Parakit calls it with the gateway name and expects the same config array shape as a static entry:
// app/Providers/AppServiceProvider.php — boot() use Froshly\Parakit\Facades\Payment; Payment::resolveMerchantUsing(function (string $gateway): array { $merchant = app(TenantManager::class)->current(); return $merchant->gatewayConfig($gateway); // ['driver' => 'fib', 'base_url' => ..., ...] });
After that, nothing else changes — Payment::for($order)->driver('fib')->charge() routes through your resolver. The resolver receives the gateway name, so one tenant can have any number of gateways (fib, zaincash, fib_vip, …), each resolved independently.
Store credentials encrypted (or as secret-manager references) and decrypt inside the resolver — don't keep raw secrets in plain DB columns.
Laravel Octane
Parakit is Octane-safe out of the box — no extra setup, just run octane:start:
- Correlation IDs —
AssignCorrelationIdrunsCorrelationId::reset()in itsterminate()hook, so the per-request ULID is scrubbed from the container before the next request lands on the same worker. - Resolved gateways —
PaymentManagermemoises instantiated drivers for the life of a request. Parakit flushes that cache automatically after every request (onRequestHandled, plus Octane'sRequestTerminated), so a resolver returning per-tenant credentials never leaks one tenant's gateway into the next request on a reused worker.
If you reconfigure gateways manually mid-request, you can still call app('parakit.manager')->flushResolved() yourself.
Security
See SECURITY.md. TL;DR: webhook signatures are mandatory, comparisons are constant-time, TLS verification is always on, secrets are redacted before they touch the DB.
Report vulnerabilities privately via GitHub Security Advisories on this repository (Security tab → Report a vulnerability).
Versioning
Semantic versioning. v0.1 is alpha — public API may still shift before v1.0. Subsequent releases will lock the API and follow ^11.0 || ^12.0 for Laravel.
See CHANGELOG.md for the full history.
License
MIT. See LICENSE.