mlquarizm / payment-gateway
Laravel Payment Gateway Package for Tabby and Tamara
Requires
- php: ^8.2
- illuminate/database: ^10.0|^11.0
- illuminate/http: ^10.0|^11.0
- illuminate/support: ^10.0|^11.0
- laravel/framework: ^10.0|^11.0
Requires (Dev)
- phpunit/phpunit: ^9.5|^10.0
README
Laravel package for integrating Tabby and Tamara payment gateways.
📋 For detailed architecture and development plan, see PLAN.md
Installation
Via Composer (if published to Packagist)
composer require mlquarizm/payment-gateway
Via Git Repository (Private Package)
Add to your composer.json:
{
"repositories": [
{
"type": "vcs",
"url": "https://github.com/your-org/ml-payment-gateway.git"
}
],
"require": {
"mlquarizm/payment-gateway": "dev-main"
}
}
Then run:
composer require mlquarizm/payment-gateway:dev-main
Via Local Path (Development)
Add to your composer.json:
{
"repositories": [
{
"type": "path",
"url": "./packages/ML/PaymentGateway"
}
],
"require": {
"mlquarizm/payment-gateway": "*"
}
}
Then run:
composer require mlquarizm/payment-gateway
Configuration
Publish the configuration files:
php artisan vendor:publish --tag=payment-gateway-config php artisan vendor:publish --tag=payment-gateway-migrations
Run migrations:
php artisan migrate
CSRF Configuration
Since payment callbacks and webhooks come from external sources, you need to exclude them from CSRF verification.
Add the following routes to your app/Http/Middleware/VerifyCsrfToken.php:
<?php namespace App\Http\Middleware; use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as Middleware; class VerifyCsrfToken extends Middleware { /** * The URIs that should be excluded from CSRF verification. * * @var array<int, string> */ protected $except = [ 'payment/callback/*', // Payment callbacks 'webhooks/payment/*', // Payment webhooks ]; }
Environment Variables
Add to your .env file:
# Tabby TABBY_SANDBOX_MODE=true TABBY_SECRET_KEY=your_secret_key TABBY_PUBLIC_KEY=your_public_key TABBY_MERCHANT_CODE=your_merchant_code TABBY_SUCCESS_URL=https://yourdomain.com/payment/callback/tabby?status=success TABBY_FAILURE_URL=https://yourdomain.com/payment/callback/tabby?status=failure TABBY_CANCEL_URL=https://yourdomain.com/payment/callback/tabby?status=cancel # Tamara TAMARA_SANDBOX_MODE=true TAMARA_API_TOKEN=your_api_token TAMARA_NOTIFICATION_TOKEN=your_notification_token TAMARA_SUCCESS_URL=https://yourdomain.com/payment/callback/tamara?status=success TAMARA_FAILURE_URL=https://yourdomain.com/payment/callback/tamara?status=failure TAMARA_CANCEL_URL=https://yourdomain.com/payment/callback/tamara?status=cancel # Redirect URLs (where the user lands after we process the callback; e.g. your app's status page) TABBY_REDIRECT_SUCCESS_URL=https://yourdomain.com/payment-status/success/ar TABBY_REDIRECT_FAILURE_URL=https://yourdomain.com/payment-status/error/ar TABBY_REDIRECT_CANCEL_URL=https://yourdomain.com/payment-status/cancel/ar TAMARA_REDIRECT_SUCCESS_URL=https://yourdomain.com/payment-status/success/ar TAMARA_REDIRECT_FAILURE_URL=https://yourdomain.com/payment-status/error/ar TAMARA_REDIRECT_CANCEL_URL=https://yourdomain.com/payment-status/cancel/ar
Callback vs redirect URLs
- TABBY_SUCCESS_URL / TAMARA_SUCCESS_URL (and failure/cancel): Must point to this project’s callback route so the package can process the payment. Use the package route, e.g.
https://yourdomain.com/payment/callback/tabby(same idea for Tamara). The gateway redirects the user here after payment; the package then processes the result and redirects the user again. - TABBY_REDIRECT_ / TAMARA_REDIRECT_*:* Where to send the user after the package has processed the callback (e.g. your
payment-status/{status}/{language}Blade page). The callback always redirects (never returns JSON); if these are not set, it usesPAYMENT_REDIRECT_FALLBACK_URLor the app root.
Configuration Files
The package includes three configuration files that you can customize after publishing:
1. config/payment-gateway.php (Main Configuration)
This is the main configuration file for the package:
return [ // Default payment gateway to use when not specified 'default_gateway' => env('PAYMENT_DEFAULT_GATEWAY', 'tabby'), // Fallback URL when gateway-specific redirect_*_url are not set (callback appends ?status=...&gateway=...) 'redirect_fallback_url' => env('PAYMENT_REDIRECT_FALLBACK_URL', ''), // URL the user is sent to after the package status Blade (success/error/cancel). Use {order_id} placeholder; replaced by payable_id (e.g. order id). 'redirect_after_status_url' => env('PAYMENT_REDIRECT_AFTER_STATUS_URL', ''), // When redirect_after_status_url is empty, Blade redirects here after 5s. Use a public URL (e.g. dashboard); avoid auth/login. 'redirect_after_status_fallback_url' => env('PAYMENT_REDIRECT_AFTER_STATUS_FALLBACK_URL', ''), // Callbacks configuration for payment events 'callbacks' => [ 'tabby' => [ 'on_success' => null, // Callable: fn($transaction) => void 'on_failure' => null, // Callable: fn($transaction, $reason) => void ], 'tamara' => [ 'on_success' => null, // Callable: fn($transaction) => void 'on_failure' => null, // Callable: fn($transaction, $reason) => void ], ], // Payment transaction configuration 'transaction' => [ 'table' => 'payment_transactions', // Database table name 'polymorphic' => true, // Enable polymorphic relationships ], ];
Configuration Options:
-
default_gateway: The default payment gateway to use when not explicitly specified. Options:'tabby'or'tamara'. -
redirect_fallback_url: When a gateway-specificredirect_success_url/redirect_error_url/redirect_cancel_urlis not set, the callback redirects here with?status=...&gateway=.... Leave empty to fall back to the app root. -
redirect_after_status_url: URL the user is sent to after the package status Blade (5s redirect). Use placeholder{order_id}; replaced by payable id (e.g. order id). Example:https://yourdomain.com/orders/{order_id}. Set in.envasPAYMENT_REDIRECT_AFTER_STATUS_URL. -
redirect_after_status_fallback_url: Whenredirect_after_status_urlis empty (or when order id is missing), the Blade redirects here. Use a public URL (e.g. dashboard or home). Avoid auth/login routes. SetPAYMENT_REDIRECT_AFTER_STATUS_FALLBACK_URLin.env. -
transaction: Configuration for payment transactions:table: Database table name for storing payment transactions.polymorphic: Enable polymorphic relationships to link transactions to different models (Order, Invoice, etc.).
2. config/tabby.php (Tabby Gateway Configuration)
Configuration specific to Tabby payment gateway:
return [ // Enable/disable sandbox mode (true for testing, false for production) 'sandbox_mode' => env('TABBY_SANDBOX_MODE', true), // Tabby API credentials 'secret_key' => env('TABBY_SECRET_KEY', ''), 'public_key' => env('TABBY_PUBLIC_KEY', ''), 'merchant_code' => env('TABBY_MERCHANT_CODE', ''), // Callback URLs (where Tabby redirects after payment) 'success_url' => env('TABBY_SUCCESS_URL', ''), 'failure_url' => env('TABBY_FAILURE_URL', ''), 'cancel_url' => env('TABBY_CANCEL_URL', ''), // Redirect URLs (where to redirect user after processing callback) 'redirect_success_url' => env('TABBY_REDIRECT_SUCCESS_URL', ''), 'redirect_error_url' => env('TABBY_REDIRECT_FAILURE_URL', ''), 'redirect_cancel_url' => env('TABBY_REDIRECT_CANCEL_URL', ''), // Currency code (default: SAR) 'currency' => env('TABBY_CURRENCY', 'SAR'), ];
Configuration Options:
-
sandbox_mode: Set totruefor testing,falsefor production. When enabled, uses Tabby's sandbox environment. -
API Credentials:
secret_key: Your Tabby secret key (obtained from Tabby dashboard).public_key: Your Tabby public key.merchant_code: Your merchant code.
-
Callback URLs: URLs where Tabby sends payment status updates:
success_url: Called when payment succeeds.failure_url: Called when payment fails.cancel_url: Called when user cancels payment.
-
Redirect URLs: URLs where users are redirected after processing the callback:
redirect_success_url: User redirect after successful payment.redirect_error_url: User redirect after failed payment.redirect_cancel_url: User redirect after cancelled payment.
-
currency: Currency code (ISO 4217). Default:'SAR'(Saudi Riyal).
3. config/tamara.php (Tamara Gateway Configuration)
Configuration specific to Tamara payment gateway:
return [ // Enable/disable sandbox mode (true for testing, false for production) 'sandbox_mode' => env('TAMARA_SANDBOX_MODE', true), // Tamara API credentials 'api_token' => env('TAMARA_API_TOKEN', ''), 'notification_token' => env('TAMARA_NOTIFICATION_TOKEN', ''), 'webhook_token' => env('TAMARA_WEBHOOK_TOKEN', ''), 'public_key' => env('TAMARA_PUBLIC_KEY', ''), // Callback URLs (where Tamara redirects after payment) 'success_url' => env('TAMARA_SUCCESS_URL', ''), 'failure_url' => env('TAMARA_FAILURE_URL', ''), 'cancel_url' => env('TAMARA_CANCEL_URL', ''), // Redirect URLs (where to redirect user after processing callback) 'redirect_success_url' => env('TAMARA_REDIRECT_SUCCESS_URL', ''), 'redirect_error_url' => env('TAMARA_REDIRECT_FAILURE_URL', ''), 'redirect_cancel_url' => env('TAMARA_REDIRECT_CANCEL_URL', ''), // Payment options 'default_payment_type' => env('TAMARA_DEFAULT_PAYMENT_TYPE', 'PAY_BY_INSTALMENTS'), 'default_instalments' => env('TAMARA_DEFAULT_INSTALMENTS', 3), // Localization 'currency' => env('TAMARA_CURRENCY', 'SAR'), 'country_code' => env('TAMARA_COUNTRY_CODE', 'SA'), 'locale' => env('TAMARA_LOCALE', 'ar_SA'), ];
Configuration Options:
-
sandbox_mode: Set totruefor testing,falsefor production. When enabled, uses Tamara's sandbox environment. -
API Credentials:
api_token: Your Tamara API token (obtained from Tamara dashboard).notification_token: Token for verifying notifications.webhook_token: Token for verifying webhook requests.public_key: Your Tamara public key.
-
Callback URLs: URLs where Tamara sends payment status updates:
success_url: Called when payment succeeds.failure_url: Called when payment fails.cancel_url: Called when user cancels payment.
-
Redirect URLs: URLs where users are redirected after processing the callback:
redirect_success_url: User redirect after successful payment.redirect_error_url: User redirect after failed payment.redirect_cancel_url: User redirect after cancelled payment.
-
Payment Options:
default_payment_type: Default payment type. Options:'PAY_BY_INSTALMENTS','PAY_LATER', etc.default_instalments: Default number of instalments (typically 3, 6, or 12).
-
Localization:
currency: Currency code (ISO 4217). Default:'SAR'.country_code: Country code (ISO 3166-1 alpha-2). Default:'SA'(Saudi Arabia).locale: Locale code. Default:'ar_SA'(Arabic - Saudi Arabia).
Usage
Basic Usage with Tabby
use MLQuarizm\PaymentGateway\Factory\PaymentGatewayFactory; use MLQuarizm\PaymentGateway\DTOs\TabbyPaymentDTO; use MLQuarizm\PaymentGateway\DTOs\PaymentOrderDTO; use MLQuarizm\PaymentGateway\DTOs\BuyerDTO; use MLQuarizm\PaymentGateway\DTOs\BuyerHistoryDTO; use MLQuarizm\PaymentGateway\DTOs\AddressDTO; use MLQuarizm\PaymentGateway\DTOs\OrderItemDTO; // Build DTOs $orderDTO = new PaymentOrderDTO( id: $order->id, referenceId: (string) $order->id, amount: 500.00, currency: 'SAR', description: "Order #{$order->id}" ); $buyerDTO = new BuyerDTO( name: $client->name, email: $client->email, // optional (nullable) phone: $client->full_phone // optional (nullable) ); $addressDTO = new AddressDTO( city: $order->city->name, address: $order->address, zip: $order->postal_code, countryCode: 'SA' ); $itemDTO = new OrderItemDTO( referenceId: "service-{$order->service->id}", title: $order->service->name, description: $order->service->description, quantity: 1, unitPrice: 500.00 ); $buyerHistoryDTO = new BuyerHistoryDTO( registeredSince: $client->created_at->toISOString(), loyaltyLevel: 12, isPhoneVerified: true, isEmailVerified: (bool) $client->email_verified_at ); $tabbyDTO = new TabbyPaymentDTO( order: $orderDTO, buyer: $buyerDTO, shippingAddress: $addressDTO, // optional (nullable) items: [$itemDTO], buyerHistory: $buyerHistoryDTO, // required orderHistory: [] // required (empty array if no previous orders) ); // Initiate payment $factory = new PaymentGatewayFactory(); $gateway = $factory->make('tabby'); $paymentInfo = $gateway->initiatePayment($tabbyDTO); // IMPORTANT: Check if payment was rejected before redirecting (Tabby Pre-scoring Reject) if (!$paymentInfo['success']) { // Tabby rejected the session (e.g. buyer not eligible, amount too high/low) // $paymentInfo['message'] → localized rejection message (AR/EN) // $paymentInfo['rejection_reason'] → e.g. 'not_available', 'order_amount_too_high', 'order_amount_too_low' // $paymentInfo['rejection_reason_code'] → Tabby internal code (nullable) // $paymentInfo['status'] → e.g. 'rejected' return back()->withErrors(['payment' => $paymentInfo['message']]); } // Record the transaction so callback/webhook can find and update it (required) use MLQuarizm\PaymentGateway\Facades\PaymentGateway; PaymentGateway::recordTransaction( $order, (string) $order->id, $paymentInfo['payment_id'] ?? null, 'tabby', 500.00, $paymentInfo ); // Redirect to payment URL return redirect($paymentInfo['url']);
Important: You must call PaymentGateway::recordTransaction(...) (or create a row with the package’s PaymentTransaction model) after initiatePayment and before redirecting the user to the gateway. Otherwise the callback/webhook will not find a transaction to update.
Handling Tabby Pre-scoring Rejection
Tabby performs a Background Pre-scoring check when you call initiatePayment(). If the buyer is not eligible (e.g. credit risk, amount limits), Tabby rejects the session immediately — before the user is ever redirected to Tabby’s checkout page.
You must check $paymentInfo[‘success’] before redirecting. If you skip this check and blindly call redirect($paymentInfo[‘url’]), the URL will be null and the user will see a broken page instead of a helpful rejection message.
$paymentInfo = $gateway->initiatePayment($tabbyDTO); if (!$paymentInfo[‘success’]) { // Session was rejected by Tabby’s pre-scoring return back()->withErrors([‘payment’ => $paymentInfo[‘message’]]); } // Only record transaction and redirect if session was created successfully PaymentGateway::recordTransaction(...); return redirect($paymentInfo[‘url’]);
Response on rejection:
[
‘url’ => null,
‘session_id’ => null,
‘payment_id’ => null,
‘success’ => false,
‘message’ => ‘Sorry, Tabby is unable to approve this purchase. Please use an alternative payment method for your order.’,
‘rejection_reason’ => ‘not_available’, // or ‘order_amount_too_high’, ‘order_amount_too_low’
‘rejection_reason_code’ => ‘...’, // Tabby internal code (nullable)
‘status’ => ‘rejected’,
]
Rejection reasons:
| Reason | English Message | Arabic Message |
|---|---|---|
not_available |
Sorry, Tabby is unable to approve this purchase. Please use an alternative payment method for your order. | ... تابي غير قادرة على الموافقة |
order_amount_too_high |
This purchase is above your current spending limit with Tabby, try a smaller cart or use another payment method. | ... قيمة الطلب تفوق الحد الأقصى |
order_amount_too_low |
The purchase amount is below the minimum amount required to use Tabby, try adding more items or use another payment method. | ... قيمة الطلب أقل من الحد الأدنى |
The message language is determined automatically based on app()->getLocale().
Testing pre-scoring rejection: See Tabby Testing Guidelines - Background Pre-scoring Reject for test credentials that trigger rejection.
Callback flow: default is package Blade (never JSON)
The package callback (GET or POST to payment/callback/{gateway}) uses the same “pay or not” logic as the webhook: it finds the transaction by track_id/payment_id, updates status, and fires events. The only difference is the response:
- Default: Callback redirects to the package status Blade at
payment-gateway/status/{status}(public, no auth). After 5s the Blade redirects to the URL from env; setPAYMENT_REDIRECT_AFTER_STATUS_FALLBACK_URLto a public URL (e.g. dashboard) so users are not sent to login. If gateway redirect URLs are set and you need custom behaviour, the callback can use those instead (with?status=...&gateway=...). This matches the usual “return from gateway → process → show success/error/cancel page” flow. - The callback always redirects (never returns JSON). If gateway-specific redirect URLs are not set, it uses
payment-gateway.redirect_fallback_url, or the app root with?status=...&gateway=....
The webhook endpoint is unchanged (returns 200, no redirect).
Using Builder Pattern
Important:
buyerHistory()andorderHistory()are required for Tabby. Tabby uses this data for pre-scoring and risk assessment. If you don't provide them, the builder will throw anInvalidArgumentException. Pass an empty array[]toorderHistory()if the buyer has no previous orders.
Note:
shippingAddress()is optional.buyer()requires onlyname—phoneare optional (passnullor empty string to omit them from the Tabby request).
Full Example
use MLQuarizm\PaymentGateway\Builders\TabbyPaymentDTOBuilder; use MLQuarizm\PaymentGateway\Factory\PaymentGatewayFactory; $tabbyDTO = TabbyPaymentDTOBuilder::new() ->order( id: $order->id, referenceId: (string) $order->id, amount: 500.00, currency: 'SAR', description: "Order #{$order->id}" ) ->buyer( name: $client->name, email: $client->email, phone: $client->full_phone ) ->shippingAddress( city: $order->city->name, address: $order->address, zip: $order->postal_code, countryCode: 'SA' ) ->orderHistory([ [ 'purchased_at' => $previousOrder->created_at->toISOString(), 'amount' => '350.00', 'status' => 'new', 'buyer' => [ 'name' => $client->name, 'email' => $client->email, 'phone' => $client->full_phone, ], 'shipping_address' => [ 'city' => $previousOrder->city->name, 'address' => $previousOrder->address, 'zip' => $previousOrder->postal_code, ], 'payment_method' => $previousOrder->payment_method, 'items' => [ [ 'reference_id' => "service-{$previousOrder->service->id}", 'title' => $previousOrder->service->name, 'description' => $previousOrder->service->description ?? '', 'quantity' => 1, 'unit_price' => '350.00', 'discount_amount' => '0.00', 'category' => 'Building inspection', ], ], ], ]) ->buyerHistory( registeredSince: $client->created_at->toISOString(), loyaltyLevel: 12, isPhoneVerified: true, isEmailVerified: (bool) $client->email_verified_at, ) ->item( referenceId: "service-{$order->service->id}", title: $order->service->name, description: $order->service->description, quantity: 1, unitPrice: 500.00 ) ->build(); $factory = new PaymentGatewayFactory(); $gateway = $factory->make('tabby'); $paymentInfo = $gateway->initiatePayment($tabbyDTO); // Always check for rejection before redirecting (see "Handling Tabby Pre-scoring Rejection" below) if (!$paymentInfo['success']) { return back()->withErrors(['payment' => $paymentInfo['message']]); }
Using OrderHistoryDTO
Instead of raw arrays, you can use OrderHistoryDTO instances for type safety:
use MLQuarizm\PaymentGateway\DTOs\OrderHistoryDTO; use MLQuarizm\PaymentGateway\DTOs\BuyerDTO; use MLQuarizm\PaymentGateway\DTOs\AddressDTO; use MLQuarizm\PaymentGateway\DTOs\OrderItemDTO; $orderHistoryItems = [ new OrderHistoryDTO( purchasedAt: $previousOrder->created_at->toISOString(), amount: '350.00', status: 'new', buyer: new BuyerDTO( name: $client->name, email: $client->email, phone: $client->full_phone, ), shippingAddress: new AddressDTO( city: $previousOrder->city->name, address: $previousOrder->address, zip: $previousOrder->postal_code, ), paymentMethod: 'credit_card', items: [ new OrderItemDTO( referenceId: "service-{$previousOrder->service->id}", title: $previousOrder->service->name, description: $previousOrder->service->description ?? '', quantity: 1, unitPrice: 350.00, ), ], ), ]; $tabbyDTO = TabbyPaymentDTOBuilder::new() ->order(...) ->buyer(...) ->orderHistory($orderHistoryItems) ->buyerHistory(...) ->item(...) ->build();
Note:
orderHistory()accepts both raw arrays andOrderHistoryDTO[]. Raw arrays are sent to Tabby as-is, whileOrderHistoryDTOinstances are serialized by the package.
Minimal Example (no shipping address, no email/phone, new buyer with no order history)
$tabbyDTO = TabbyPaymentDTOBuilder::new() ->order( id: $order->id, referenceId: (string) $order->id, amount: 500.00, ) ->buyer(name: $client->name) ->orderHistory([]) // required — pass empty array if no previous orders ->buyerHistory( registeredSince: $client->created_at->toISOString(), loyaltyLevel: 0, ) ->item( referenceId: "service-{$order->service->id}", title: $order->service->name, unitPrice: 500.00 ) ->build();
Multiple Items
You can add multiple items in two ways:
Option 1: Using item() method multiple times
$tabbyDTO = TabbyPaymentDTOBuilder::new() ->order(...) ->buyer(...) ->shippingAddress(...) ->item( referenceId: "service-1", title: "Service 1", description: "Description 1", quantity: 1, unitPrice: 200.00 ) ->item( referenceId: "service-2", title: "Service 2", description: "Description 2", quantity: 2, unitPrice: 150.00 ) ->build();
Option 2: Using items() method with array
use MLQuarizm\PaymentGateway\DTOs\OrderItemDTO; $items = [ new OrderItemDTO( referenceId: "service-1", title: "Service 1", description: "Description 1", quantity: 1, unitPrice: 200.00 ), new OrderItemDTO( referenceId: "service-2", title: "Service 2", description: "Description 2", quantity: 2, unitPrice: 150.00 ), ]; $tabbyDTO = TabbyPaymentDTOBuilder::new() ->order(...) ->buyer(...) ->shippingAddress(...) ->items($items) ->build();
Option 3: Using items() with array of arrays
$items = [ [ 'referenceId' => "service-1", 'title' => "Service 1", 'description' => "Description 1", 'quantity' => 1, 'unitPrice' => 200.00 ], [ 'referenceId' => "service-2", 'title' => "Service 2", 'description' => "Description 2", 'quantity' => 2, 'unitPrice' => 150.00 ], ]; $tabbyDTO = TabbyPaymentDTOBuilder::new() ->order(...) ->buyer(...) ->shippingAddress(...) ->items($items) ->build();
Using Tamara
use MLQuarizm\PaymentGateway\Builders\TamaraPaymentDTOBuilder; use MLQuarizm\PaymentGateway\Factory\PaymentGatewayFactory; $tamaraDTO = TamaraPaymentDTOBuilder::new() ->order( id: $order->id, referenceId: (string) $order->id, amount: 500.00, currency: 'SAR', description: "Order #{$order->id}" ) ->consumer( firstName: $client->name, lastName: '', phoneNumber: $client->full_phone, email: $client->email, dateOfBirth: '1990-01-01' ) ->billingAddress( city: $order->city->name, line1: $order->address, zip: $order->postal_code, countryCode: 'SA' ) ->shippingAddress( city: $order->city->name, line1: $order->address, zip: $order->postal_code, countryCode: 'SA' ) ->item( referenceId: "service-{$order->service->id}", type: 'Physical', name: $order->service->name, sku: "SERVICE-{$order->service->id}", unitPrice: 500.00, totalAmount: 500.00 ) ->build(); $factory = new PaymentGatewayFactory(); $gateway = $factory->make('tamara'); $paymentInfo = $gateway->initiatePayment($tamaraDTO); // Check for rejection before redirecting if (!$paymentInfo['success']) { return back()->withErrors(['payment' => $paymentInfo['message'] ?? 'Payment rejected']); } // Record the transaction and redirect (same as Tabby) PaymentGateway::recordTransaction($order, (string) $order->id, $paymentInfo['payment_id'] ?? null, 'tamara', 500.00, $paymentInfo); return redirect($paymentInfo['url']);
Multiple Items
You can add multiple items using the same methods as Tabby:
Using item() method multiple times:
$tamaraDTO = TamaraPaymentDTOBuilder::new() ->order(...) ->consumer(...) ->billingAddress(...) ->shippingAddress(...) ->item( referenceId: "service-1", type: 'Physical', name: "Service 1", sku: "SERVICE-1", unitPrice: 200.00, totalAmount: 200.00 ) ->item( referenceId: "service-2", type: 'Physical', name: "Service 2", sku: "SERVICE-2", unitPrice: 150.00, totalAmount: 300.00, quantity: 2 ) ->build();
Using items() method with array:
use MLQuarizm\PaymentGateway\DTOs\TamaraOrderItemDTO; $items = [ new TamaraOrderItemDTO( referenceId: "service-1", type: 'Physical', name: "Service 1", sku: "SERVICE-1", unitPrice: 200.00, totalAmount: 200.00 ), new TamaraOrderItemDTO( referenceId: "service-2", type: 'Physical', name: "Service 2", sku: "SERVICE-2", unitPrice: 150.00, totalAmount: 300.00, quantity: 2 ), ]; $tamaraDTO = TamaraPaymentDTOBuilder::new() ->order(...) ->consumer(...) ->billingAddress(...) ->shippingAddress(...) ->items($items) ->build();
Handling Payment Events
The package uses Laravel Events to handle payment events. This allows you to listen to payment events in your application without modifying the package code.
Available Events
The package dispatches the following events:
MLQuarizm\PaymentGateway\Events\PaymentSuccess- Dispatched when a payment is successfulMLQuarizm\PaymentGateway\Events\PaymentFailed- Dispatched when a payment failsMLQuarizm\PaymentGateway\Events\PaymentCancelled- Dispatched when a payment is cancelledMLQuarizm\PaymentGateway\Events\PaymentPending- Dispatched when a payment is pending
Listening to Events
Register event listeners in your app/Providers/EventServiceProvider.php:
use MLQuarizm\PaymentGateway\Events\PaymentSuccess; use MLQuarizm\PaymentGateway\Events\PaymentFailed; use MLQuarizm\PaymentGateway\Events\PaymentCancelled; use App\Listeners\HandlePaymentSuccess; use App\Listeners\HandlePaymentFailure; use App\Listeners\HandlePaymentCancellation; protected $listen = [ PaymentSuccess::class => [ HandlePaymentSuccess::class, ], PaymentFailed::class => [ HandlePaymentFailure::class, ], PaymentCancelled::class => [ HandlePaymentCancellation::class, ], ];
Creating Event Listeners
Create listeners using Artisan:
php artisan make:listener HandlePaymentSuccess php artisan make:listener HandlePaymentFailure php artisan make:listener HandlePaymentCancellation
Example listener:
<?php namespace App\Listeners; use MLQuarizm\PaymentGateway\Events\PaymentSuccess; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Queue\InteractsWithQueue; class HandlePaymentSuccess implements ShouldQueue { use InteractsWithQueue; /** * Handle the event. */ public function handle(PaymentSuccess $event): void { $transaction = $event->transaction; $order = $transaction->payable; // Your Order model or any payable model // Update order status $order->update(['payment_status' => 'paid']); // Send notifications $order->client->notify(new PaymentSuccessfulNotification($order)); // Dispatch jobs, etc. dispatch(new ProcessSuccessfulPaymentJob($order)); } }
Using Closures (Alternative)
You can also use closures in EventServiceProvider:
use Illuminate\Support\Facades\Event; use MLQuarizm\PaymentGateway\Events\PaymentSuccess; use MLQuarizm\PaymentGateway\Events\PaymentFailed; public function boot(): void { Event::listen(PaymentSuccess::class, function (PaymentSuccess $event) { $transaction = $event->transaction; $order = $transaction->payable; $order->update(['payment_status' => 'paid']); // Handle success... }); Event::listen(PaymentFailed::class, function (PaymentFailed $event) { $transaction = $event->transaction; $reason = $event->reason; // Log failure, notify admin, etc. Log::error('Payment failed', [ 'transaction_id' => $transaction->id, 'reason' => $reason ]); }); }
How to Use Callback and Webhook
The package handles payment results in two ways: callback (when the user is sent back to your site after paying) and webhook (when the gateway sends a server-to-server request). Both use the same logic to update the transaction and fire events; only the entry point and response differ.
Callback (user redirect)
What it is: After the user completes or cancels payment on Tabby/Tamara, the gateway redirects the user to a URL you provide. That URL must be your app so the package can process the result and then send the user to your success/error/cancel page.
How to use it:
-
Register the callback URL with the gateway
In Tabby/Tamara merchant settings (and in your.env), set:- Success URL:
https://yourdomain.com/payment/callback/tabby(or.../payment/callback/tamara) - Failure URL: same path
- Cancel URL: same path
The package uses one route and decides success/failure/cancel from the request data.
- Success URL:
-
Exclude the callback from CSRF
Inapp/Http/Middleware/VerifyCsrfToken.phpadd:protected $except = [ 'payment/callback/*', 'webhooks/payment/*', ];
-
Set redirect URLs in config
So the user is sent to your status page after processing, set in.env(or config):TABBY_REDIRECT_SUCCESS_URL,TABBY_REDIRECT_FAILURE_URL,TABBY_REDIRECT_CANCEL_URLTAMARA_REDIRECT_SUCCESS_URL, etc.
Example:https://yourdomain.com/payment-status/success/ar.
The callback always redirects (never returns JSON). If these are not set, it usesPAYMENT_REDIRECT_FALLBACK_URLor the app root with?status=...&gateway=....
-
Flow
User finishes on gateway → gateway redirects toGET /payment/callback/{gateway}(with query params) → package runsHandlePaymentAction, updatesPaymentTransaction, fires events → package redirects to your redirect_success_url / redirect_error_url / redirect_cancel_url.
Route: GET|POST /payment/callback/{gateway} (e.g. payment/callback/tabby, payment/callback/tamara).
Webhook (server-to-server)
What it is: Tabby/Tamara send an HTTP POST to your server to notify payment status. No user is in the browser; the gateway calls your URL directly.
How to use it:
-
Register the webhook URL with the gateway
In Tabby/Tamara merchant/dashboard settings, set the webhook URL to:https://yourdomain.com/webhooks/payment/tabbyhttps://yourdomain.com/webhooks/payment/tamara
-
Exclude the webhook from CSRF
Same as above: addwebhooks/payment/*toVerifyCsrfToken::$except. -
Configure verification (recommended)
- Tamara: set
TAMARA_NOTIFICATION_TOKENin.env; the package verifies the JWT. - Tabby: set
TABBY_SECRET_KEYand optionallyTABBY_WEBHOOK_VERIFY_SIGNATURE=truefor HMAC verification.
- Tamara: set
-
Flow
Gateway sendsPOST /webhooks/payment/{gateway}→ package verifies signature/token → runs sameHandlePaymentAction, updates transaction, fires same events → returns 200 so the gateway does not retry.
Route: POST /webhooks/payment/{gateway}.
Summary: callback vs webhook
| Callback | Webhook | |
|---|---|---|
| Who calls | User’s browser (redirect from gateway) | Gateway’s server |
| Method | GET (or POST) | POST |
| Route | /payment/callback/{gateway} |
/webhooks/payment/{gateway} |
| Response | Redirect to your redirect_*_url or JSON | Always 200 (body not used by gateway) |
| Logic | Same: find transaction, update status, fire events | Same |
Reacting to payment result (both callback and webhook)
Listen to package events; they are fired for both callback and webhook:
use Illuminate\Support\Facades\Event; use MLQuarizm\PaymentGateway\Events\PaymentSuccess; use MLQuarizm\PaymentGateway\Events\PaymentFailed; use MLQuarizm\PaymentGateway\Events\PaymentCancelled; use MLQuarizm\PaymentGateway\Events\PaymentPending; // In a service provider or dedicated listener class Event::listen(PaymentSuccess::class, function (PaymentSuccess $event) { $transaction = $event->transaction; // Update order, send notification, etc. }); Event::listen(PaymentFailed::class, function (PaymentFailed $event) { $transaction = $event->transaction; $reason = $event->reason; }); Event::listen(PaymentCancelled::class, function (PaymentCancelled $event) { $transaction = $event->transaction; });
Routes
The package automatically registers:
GET|POST /payment/callback/{gateway}– Callback (public): redirects by default to package status Blade.GET /payment-gateway/status/{status}– Package status page (public Blade: success / error / cancel); redirect URL from env, with order id when set.POST /webhooks/payment/{gateway}– Webhook: gateway server-to-server notification.
Webhook Signature Verification
The package includes built-in webhook signature verification for security. All webhooks are automatically verified before processing.
Tamara Webhook Verification
Tamara uses JWT tokens for webhook verification. The token can be provided in:
- Query parameter:
?tamaraToken=... - Authorization header:
Bearer <token>
The package verifies:
- JWT token format (header.payload.signature)
- Signature using HMAC-SHA256 with
notification_token - Token expiration (
expclaim)
Configuration:
TAMARA_NOTIFICATION_TOKEN=your_notification_token
Tabby Webhook Verification
Tabby uses HMAC-SHA256 signature verification. The signature is expected in:
X-Tabby-Signatureheader (preferred)X-Signatureheader (fallback)Signatureheader (fallback)
Configuration:
TABBY_SECRET_KEY=your_secret_key TABBY_WEBHOOK_VERIFY_SIGNATURE=true # Enable strict signature verification
Note: By default, Tabby webhook signature verification is disabled (TABBY_WEBHOOK_VERIFY_SIGNATURE=false) to maintain backward compatibility. Enable it when you're ready to enforce signature verification.
How It Works
- Webhook request arrives at
POST /webhooks/payment/{gateway} WebhookVerificationServiceverifies the signature/token- If verification fails, the request is rejected (but returns 200 to prevent gateway retries)
- If verification succeeds, the request is processed normally
Custom Verification
You can extend WebhookVerificationService to add custom verification logic for other gateways or modify existing verification methods.
Payment Transaction Model
The PaymentTransaction model uses polymorphic relationships, so it can be associated with any model:
PaymentTransaction::create([ 'payable_type' => Order::class, // or any model 'payable_id' => $order->id, 'track_id' => (string) $order->id, 'payment_id' => $paymentInfo['payment_id'], 'payment_gateway' => 'tabby', 'amount' => 500.00, 'status' => 'pending', ]);
License
MIT