mustafataj / tabby-php
PHP and Laravel SDK for Tabby Pay in 4 Custom API
Requires
- php: ^8.1
- guzzlehttp/guzzle: ^7.0
Requires (Dev)
- illuminate/support: ^10.0|^11.0|^12.0
- mockery/mockery: ^1.6
- orchestra/testbench: ^8.0|^9.0|^10.0
- phpstan/phpstan: ^1.10
- phpunit/phpunit: ^10.5|^11.0
Suggests
- illuminate/support: Required for Laravel integration (^10.0|^11.0|^12.0)
README
Production-ready PHP SDK for Tabby Pay in 4 Custom API. Works in plain PHP and Laravel 10+ out of the box.
Introduction
This package provides a framework-agnostic core with first-class Laravel integration for:
- Creating checkout sessions
- Retrieving and updating payments
- Capturing and refunding payments
- Listing payments
- Managing webhooks
The SDK uses Guzzle by default and supports injecting a custom HTTP client through HttpClientInterface.
API keys
Tabby uses two credentials:
| Key | Used for |
|---|---|
Public key (pk_test_... / pk_...) |
Checkout session creation |
Secret key (sk_test_... / sk_...) |
Payments and webhooks |
The SDK selects the correct key automatically per endpoint. Use sandbox keys while testing and live keys in production.
Installation
composer require mustafataj/tabby-php
Laravel Setup
The package auto-registers via Laravel package discovery:
- Service provider:
MustafaTaj\Tabby\Laravel\TabbyServiceProvider - Facade alias:
Tabby
No manual registration is required for Laravel 10, 11, or 12.
Publishing Config
php artisan vendor:publish --tag=tabby-config
This publishes config/tabby.php to your application.
Environment Variables
Add the following to your .env:
IS_TABBY_SANDBOX=true TABBY_LIVE_SECRET_KEY=sk_xxx TABBY_LIVE_PUBLIC_KEY=pk_xxx TABBY_SANDBOX_SECRET_KEY=sk_test_xxx TABBY_SANDBOX_PUBLIC_KEY=pk_test_xxx TABBY_MERCHANT_CODE=your_merchant_code TABBY_REGION=ksa TABBY_BASE_URL= TABBY_TIMEOUT=30 TABBY_CONNECT_TIMEOUT=10 TABBY_HTTP_DEBUG=false
When IS_TABBY_SANDBOX=true, the SDK uses TABBY_SANDBOX_* keys. When false, it uses TABBY_LIVE_* keys.
Optional legacy overrides (used only if the active environment keys are empty):
TABBY_SECRET_KEY= TABBY_PUBLIC_KEY=
Plain PHP Setup
<?php require __DIR__.'/vendor/autoload.php'; use MustafaTaj\Tabby\Config\Region; use MustafaTaj\Tabby\Tabby; $tabby = Tabby::make([ 'sandbox' => true, 'keys' => [ 'sandbox' => [ 'secret_key' => 'sk_test_xxx', 'public_key' => 'pk_test_xxx', ], 'live' => [ 'secret_key' => 'sk_xxx', 'public_key' => 'pk_xxx', ], ], 'merchant_code' => 'your_merchant_code', 'region' => Region::KSA, ]); $session = $tabby->checkout()->create([...]); // uses public key $payment = $tabby->payments()->retrieve('payment_id_here'); // uses secret key
Or pass keys directly without the nested structure:
$tabby = Tabby::make([ 'secret_key' => 'sk_test_xxx', 'public_key' => 'pk_test_xxx', 'merchant_code' => 'your_merchant_code', 'region' => Region::KSA, ]);
You can also load configuration from environment variables:
$tabby = Tabby::fromEnv();
Region and Base URL
| Region | Value | Base URL |
|---|---|---|
| KSA | ksa |
https://api.tabby.sa |
| UAE | uae |
https://api.tabby.ai |
| Kuwait | kuwait |
https://api.tabby.ai |
If base_url is explicitly configured, it overrides the region mapping.
Checkout Session Example
use MustafaTaj\Tabby\Facades\Tabby; $session = Tabby::checkout()->create([ 'payment' => [ 'amount' => '100.00', 'currency' => 'SAR', 'description' => 'Order #1001', 'buyer' => [ 'phone' => '500000001', 'email' => 'otp.success@tabby.ai', 'name' => 'Test Customer', ], 'order' => [ 'reference_id' => '1001', 'items' => [ [ 'title' => 'Product name', 'quantity' => 1, 'unit_price' => '100.00', 'reference_id' => 'SKU-001', ], ], ], ], 'lang' => 'en', 'merchant_urls' => [ 'success' => route('checkout.success'), 'cancel' => route('checkout.cancel'), 'failure' => route('checkout.failure'), ], ]);
If merchant_code is omitted from the payload, it is injected from config automatically.
Redirect to Hosted Payment Page
$webUrl = Tabby::checkout()->webUrl($session); if ($webUrl) { return redirect()->away($webUrl); } // Or use the helper directly: use MustafaTaj\Tabby\Support\CheckoutSession; $webUrl = CheckoutSession::webUrl($session); $paymentId = CheckoutSession::paymentId($session);
Retrieve Payment Example
use MustafaTaj\Tabby\Facades\Tabby; $payment = Tabby::payments()->retrieve($paymentId);
Dependency Injection
use MustafaTaj\Tabby\TabbyClient; class PaymentController { public function show(TabbyClient $tabby, string $paymentId) { return response()->json( $tabby->payments()->retrieve($paymentId) ); } }
Success Payment Callback Example
After the customer returns from Tabby's hosted payment page, verify the payment and capture it in one call:
use MustafaTaj\Tabby\Facades\Tabby; $result = Tabby::payments()->retrieveAndCapture( paymentId: $paymentId, referenceId: 'capture-order-1001', ); if ($result['successful']) { // Fulfill the order } // $result shape: // [ // 'payment' => [...], // latest payment object from Tabby // 'captured' => true, // true when a capture request was sent in this call // 'capture' => [...], // capture response, or null when not captured // 'status' => 'CLOSED', // 'successful' => true, // true for AUTHORIZED or CLOSED payments // ]
retrieveAndCapture() retrieves the payment first. If the status is AUTHORIZED, it captures the full payment amount (or a custom amount you pass). If the payment is already CLOSED, it returns the payment without sending another capture request.
Close Payment Example
Use this when an order is fully cancelled and should not be captured:
Tabby::payments()->close($paymentId);
Payment Status Helper
use MustafaTaj\Tabby\Enums\PaymentStatus; $status = PaymentStatus::tryFromMixed($payment['status']); if ($status?->isCapturable()) { Tabby::payments()->capture($paymentId, $payment['amount']); } if ($status?->isSuccessful()) { // Payment is authorized or closed }
Capture Payment Example
Tabby::payments()->capture( paymentId: $paymentId, amount: '100.00', referenceId: 'capture-order-1001' );
Refund Payment Example
Tabby::payments()->refund( paymentId: $paymentId, amount: '50.00', referenceId: 'refund-order-1001-1' );
List Payments Example
use MustafaTaj\Tabby\DTO\Payment\ListPaymentsQuery; $payments = Tabby::payments()->list(new ListPaymentsQuery( createdAtGte: '2024-01-01T00:00:00Z', createdAtLte: '2024-12-31T23:59:59Z', limit: 20, offset: 0, )); // Or with a raw array: $payments = Tabby::payments()->list([ 'created_at__gte' => '2024-01-01T00:00:00Z', 'limit' => 20, ]);
Update Payment Example
Tabby::payments()->update($paymentId, [ 'reference_id' => 'updated-order-reference', ]);
Webhook Registration Example
Tabby::webhooks()->register( url: 'https://example.com/webhooks/tabby', header: [ 'title' => 'X-Webhook-Secret', 'value' => 'my-secret', ] );
Webhook requests automatically include the X-Merchant-Code header from config.
Webhook CRUD Examples
// List all webhooks $webhooks = Tabby::webhooks()->all(); // Retrieve a webhook $webhook = Tabby::webhooks()->retrieve($webhookId); // Update a webhook $updated = Tabby::webhooks()->update($webhookId, [ 'url' => 'https://example.com/webhooks/tabby-v2', ]); // Delete a webhook Tabby::webhooks()->delete($webhookId);
Incoming Webhook Handler Example
Tabby sends payment updates as a POST request with a JSON body. The top-level id field is the payment ID (the same value as payment_id in redirect URLs).
Example payload:
{
"id": "string",
"created_at": "2021-09-14T13:08:54Z",
"expires_at": "2022-09-14T13:08:54Z",
"closed_at": "2021-09-14T13:09:45Z",
"status": "closed",
"is_test": false,
"is_expired": false,
"amount": "100",
"currency": "SAR",
"order": {
"reference_id": "string"
},
"captures": [],
"refunds": [],
"meta": {
"order_id": null,
"customer": null
},
"token": "string"
}
Laravel controller example — verify the webhook, then retrieve and capture using $request->input('id') as the payment ID:
use Illuminate\Http\Request; use Illuminate\Http\Response; use MustafaTaj\Tabby\Facades\Tabby; use MustafaTaj\Tabby\Webhooks\WebhookPayload; class TabbyWebhookController { public function __invoke(Request $request): Response { if (! WebhookPayload::verifyAuthHeader( headers: ['X-Webhook-Secret' => (string) $request->header('X-Webhook-Secret')], headerName: 'X-Webhook-Secret', expectedValue: config('services.tabby.webhook_secret'), )) { abort(401); } $payload = WebhookPayload::fromJson($request->getContent()); $paymentId = $request->input('id'); // same as $payload->paymentId() if ($payload->isAuthorizedEvent()) { $result = Tabby::payments()->retrieveAndCapture( paymentId: $paymentId, referenceId: $payload->orderReferenceId(), ); if ($result['successful']) { // Fulfill the order in your OMS } } if ($payload->isClosedEvent()) { // Payment completed — no capture action required } return response('OK', 200); } }
Plain PHP example:
use MustafaTaj\Tabby\Tabby; use MustafaTaj\Tabby\Webhooks\WebhookPayload; $payload = WebhookPayload::fromJson(file_get_contents('php://input')); $paymentId = $payload->paymentId(); // reads the "id" field from the webhook body if ($payload->isAuthorizedEvent()) { $tabby = Tabby::make($config); $result = $tabby->payments()->retrieveAndCapture( paymentId: $paymentId, referenceId: $payload->orderReferenceId(), ); }
Webhook payloads use lowercase statuses (authorized, closed). The parser normalizes them via PaymentStatus.
Respond with HTTP 200 immediately and process asynchronously when possible. Always verify the final payment state via the Tabby API — do not rely on the webhook body alone as your only source of truth.
Error Handling
use MustafaTaj\Tabby\Exceptions\ApiException; use MustafaTaj\Tabby\Exceptions\AuthenticationException; use MustafaTaj\Tabby\Exceptions\ConfigurationException; use MustafaTaj\Tabby\Exceptions\NetworkException; use MustafaTaj\Tabby\Exceptions\ValidationException; try { $payment = Tabby::payments()->retrieve($paymentId); } catch (AuthenticationException $e) { // HTTP 401 / 403 } catch (ValidationException $e) { // HTTP 400 / 422 } catch (ApiException $e) { // Other non-success API responses $status = $e->getStatusCode(); $body = $e->getResponseJson(); } catch (NetworkException $e) { // Connection errors and timeouts } catch (ConfigurationException $e) { // Missing or invalid SDK configuration }
Exception objects expose sanitized request context and never include raw secret keys.
Optional DTOs
All resource methods accept plain arrays. DTOs are optional helpers:
use MustafaTaj\Tabby\DTO\Payment\CapturePaymentData; Tabby::payments()->captureWithData( paymentId: $paymentId, data: new CapturePaymentData( amount: '100.00', referenceId: 'capture-1001', ), );
Custom HTTP Client
Implement MustafaTaj\Tabby\Contracts\HttpClientInterface and pass it to Tabby::make():
$tabby = Tabby::make($config, $customHttpClient);
Testing
composer validate composer dump-autoload vendor/bin/phpunit vendor/bin/phpstan analyse
The test suite uses mocked HTTP clients and does not make real Tabby API calls.
GitHub Actions runs the same checks on PHP 8.2 through 8.4 for every push and pull request to main. The package runtime still supports PHP 8.1+; Laravel dev dependencies require PHP 8.2+.
Security Notes
- Never commit real Tabby public or secret keys to source control.
- Use sandbox/test credentials during development (
IS_TABBY_SANDBOX=true). - Store secrets in
.envor a secure secret manager. - Do not expose secret keys to frontend clients. Public keys are intended for checkout session creation only.
- Validate incoming webhook requests in your application according to your security rules and any Tabby-provided headers or secrets.
Contributing
Contributions are welcome. Please open an issue or pull request on GitHub.
License
This package is open-sourced software licensed under the MIT license.