zakhir-laravel maintained by amolood
Table of Contents
- Requirements
- Installation
- Configuration
- Usage
- Webhook Handling
- Database
- Events Reference
- Exception Handling
- Architecture Overview
- Testing
- Changelog
- Credits
- License
Requirements
| Dependency | Version |
|---|---|
| PHP | ^8.2 |
| Laravel | ^10.0 | ^11.0 | ^12.0 |
Installation
Install via Composer:
composer require amolood/zakhir-laravel
Laravel's auto-discovery will register the service provider and Zakhir facade automatically. No manual registration needed.
Publish the configuration file:
php artisan vendor:publish --tag=zakhir-config
Run the migrations:
php artisan migrate
If you prefer to publish migrations instead of letting the package load them automatically:
php artisan vendor:publish --tag=zakhir-migrations
Configuration
All configuration lives in config/zakhir.php. After publishing, open that file and fill in your values directly — no .env entries are required.
php artisan vendor:publish --tag=zakhir-config
// config/zakhir.php
return [
// "production" or "staging"
'environment' => 'production',
// Production credentials — from your Zakhir merchant dashboard
'base_url' => 'https://zakhir.net/api/',
'tenant' => 'your_tenant_id',
'profile' => 'your_profile_id',
'api_key' => 'your_api_key',
// Staging credentials — used when environment is "staging"
'staging_base_url' => '',
'staging_tenant' => '',
'staging_profile' => '',
'staging_api_key' => '',
// Where Zakhir POSTs payment status notifications
'webhook_url' => 'https://yourdomain.com/api/zakhir/webhook',
// Where customers are redirected after checkout
'return_url' => 'https://yourdomain.com/orders/return',
// HTTP timeout in seconds
'timeout' => 15,
// Log every API request and response to the zakhir_logs table
'logging' => true,
'routes' => [
'enabled' => true,
'prefix' => 'api/zakhir', // webhook available at POST /api/zakhir/webhook
'middleware' => ['api'],
],
];
Options Reference
| Key | Type | Description |
|---|---|---|
environment |
string |
"production" or "staging" |
base_url |
string |
Production API base URL |
tenant |
string |
Merchant tenant ID |
profile |
string |
Merchant profile ID |
api_key |
string |
API key for request authentication |
staging_base_url |
string |
Staging API base URL |
staging_tenant |
string |
Staging tenant ID |
staging_profile |
string |
Staging profile ID |
staging_api_key |
string |
Staging API key |
webhook_url |
string |
Public URL where Zakhir sends status callbacks |
return_url |
string |
URL customers land on after checkout |
timeout |
int |
HTTP request timeout in seconds |
logging |
bool |
Write every API call to zakhir_logs table |
routes.enabled |
bool |
Auto-register the built-in webhook route |
routes.prefix |
string |
URL prefix for the webhook route |
routes.middleware |
array |
Middleware applied to the webhook route |
Staging Environment
Set environment to "staging" and fill in the staging credentials block. The package selects the correct set of credentials automatically — no other changes needed.
'environment' => 'staging',
'staging_base_url' => 'https://staging.zakhir.net/api/',
'staging_tenant' => 'staging_tenant_id',
'staging_profile' => 'staging_profile_id',
'staging_api_key' => 'staging_api_key',
Usage
Create a Payment
use Zakhir\LaravelZakhir\ZakhirPaymentService;
use Zakhir\LaravelZakhir\Data\PaymentResponse;
$zakhir = app(ZakhirPaymentService::class);
$response = $zakhir->createPayment(
amount: 250.00, // in SDG (or your configured currency)
currency: 'SDG',
note: 'Order #1024',
returnUrl: 'https://yourdomain.com/orders/1024', // optional, falls back to config
notifyUrl: 'https://yourdomain.com/api/zakhir/webhook', // optional, falls back to config
referenceId: null, // optional — a UUID is auto-generated if omitted
);
// Redirect the customer to the Zakhir checkout page
return redirect($response->checkoutUrl);
The returned PaymentResponse object exposes:
| Property | Type | Description |
|---|---|---|
$id |
string |
Zakhir's internal payment ID |
$referenceId |
string |
The UUID sent in the request (store this to poll/cancel later) |
$status |
string |
PENDING, COMPLETED, etc. |
$checkoutUrl |
string|null |
Hosted checkout page URL — redirect your customer here |
$mobileAppUrl |
string|null |
Deep link for mobile Zakhir app |
$paymentToken |
string|null |
Short-lived payment token |
$paymentTokenExpiresAt |
string|null |
ISO 8601 expiry timestamp |
$raw |
array |
Full raw API response |
$response->isPending(); // true
$response->isCompleted(); // false
Poll Payment Status
Use this to check the current state of a payment without waiting for a webhook:
$status = $zakhir->getPaymentStatus($referenceId);
if ($status->isCompleted()) {
// Mark your order as paid
}
if ($status->isRejected()) {
// Notify the customer
}
PaymentStatusResponse properties:
| Property | Type | Description |
|---|---|---|
$referenceId |
string |
Your original referenceId |
$status |
string |
PENDING / COMPLETED / REJECTED |
$id |
string |
Zakhir's payment ID |
$raw |
array |
Full raw API response |
$status->isPending(); // bool
$status->isCompleted(); // bool
$status->isRejected(); // bool
Cancel a Payment
Cancel a PENDING payment that has no transaction attached yet:
$result = $zakhir->cancelPayment($referenceId);
Returns the raw response array from the Zakhir API.
Using the Facade
All methods are also available via the Zakhir facade:
use Zakhir\LaravelZakhir\Facades\Zakhir;
$response = Zakhir::createPayment(250.00, 'SDG', 'Order #1024');
$status = Zakhir::getPaymentStatus($referenceId);
Zakhir::cancelPayment($referenceId);
Dependency Injection
Inject ZakhirPaymentService directly into your controllers or services:
use Zakhir\LaravelZakhir\ZakhirPaymentService;
class CheckoutController extends Controller
{
public function __construct(
private readonly ZakhirPaymentService $zakhir,
) {}
public function pay(Order $order)
{
$response = $this->zakhir->createPayment(
amount: $order->total,
currency: 'SDG',
note: "Order #{$order->id}",
);
// Store the referenceId so you can look it up later
$order->update(['zakhir_reference_id' => $response->referenceId]);
return redirect($response->checkoutUrl);
}
}
Webhook Handling
The package registers a webhook endpoint automatically at:
POST /api/zakhir/webhook
The route prefix is configurable via ZAKHIR_ROUTE_PREFIX. To disable the built-in route entirely and register your own, set:
ZAKHIR_ROUTES_ENABLED=false
Then point to your own controller that resolves ZakhirWebhookController or implements the same logic.
Registering a Payment
Before Zakhir's webhook can update a payment, you must create a ZakhirPayment record after calling createPayment. This record is the package's local representation of the payment:
use Zakhir\LaravelZakhir\Models\ZakhirPayment;
$response = Zakhir::createPayment(250.00, 'SDG', "Order #{$order->id}");
ZakhirPayment::create([
'transaction_id' => 'zakhir-pending-' . $response->referenceId,
'reference_id' => $response->referenceId,
'gateway_reference' => $response->id,
'payable_id' => $order->id,
'payable_type' => Order::class,
'amount' => 25000, // store in piasters (SDG × 100)
'currency' => 'SDG',
'status' => 'PENDING',
]);
When Zakhir sends a COMPLETED webhook, the controller updates the record atomically (row-level lock, idempotency guard) and dispatches ZakhirPaymentCompleted.
Listening to Events
Register listeners in your EventServiceProvider (or using #[AsEventListener]):
use Zakhir\LaravelZakhir\Events\ZakhirPaymentCompleted;
use Zakhir\LaravelZakhir\Events\ZakhirPaymentFailed;
use Zakhir\LaravelZakhir\Events\ZakhirWebhookReceived;
// AppServiceProvider or EventServiceProvider
Event::listen(ZakhirPaymentCompleted::class, function ($event) {
$payment = $event->payment; // ZakhirPayment model (already COMPLETED)
$order = Order::find($payment->payable_id);
$order->markAsPaid();
$order->customer->notify(new OrderConfirmed($order));
});
Event::listen(ZakhirPaymentFailed::class, function ($event) {
// $event->payload → WebhookPayload DTO
// $event->localPayableId → your model's ID
// $event->localPayableType → your model's class
});
Event::listen(ZakhirWebhookReceived::class, function ($event) {
// Fired for every webhook regardless of status — useful for raw auditing
// $event->payload → WebhookPayload DTO
});
Or use a dedicated listener class:
class HandleZakhirPayment
{
public function handle(ZakhirPaymentCompleted $event): void
{
$payment = $event->payment;
// ...
}
}
Database
Migrations
Two tables are created:
| Table | Purpose |
|---|---|
zakhir_payments |
One row per payment attempt; tracks status, amount, and raw payload |
zakhir_logs |
Append-only audit log of every outgoing API request/response |
Migrations are loaded automatically. To publish them instead:
php artisan vendor:publish --tag=zakhir-migrations
ZakhirPayment Model
Zakhir\LaravelZakhir\Models\ZakhirPayment
| Column | Type | Description |
|---|---|---|
id |
bigint |
Auto-increment primary key |
transaction_id |
string |
Unique internal ID — format zakhir-{seed} |
gateway_reference |
string|null |
Zakhir's own payment ID |
reference_id |
string |
UUID sent as referenceId in the API request |
payable_id |
int |
ID of the related local model |
payable_type |
string |
Class of the related local model |
amount |
bigint |
Amount in smallest unit (piasters for SDG) |
currency |
string(3) |
ISO currency code, e.g. SDG |
status |
string |
PENDING / COMPLETED / FAILED |
raw_payload |
json|null |
Full webhook or API response payload |
paid_at |
timestamp|null |
When the payment completed |
Polymorphic relation — attach payments to any Eloquent model:
// On your Invoice / Order model
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Zakhir\LaravelZakhir\Models\ZakhirPayment;
public function zakhirPayments(): MorphMany
{
return $this->morphMany(ZakhirPayment::class, 'payable');
}
$completedPayments = $order->zakhirPayments()->where('status', 'COMPLETED')->get();
ZakhirLog Model
Zakhir\LaravelZakhir\Models\ZakhirLog
Every outgoing API call is recorded automatically when ZAKHIR_LOGGING=true:
| Column | Type | Description |
|---|---|---|
id |
bigint |
Auto-increment primary key |
direction |
string |
outgoing or incoming |
method |
string |
HTTP verb |
url |
string |
Full endpoint URL |
ip |
string|null |
Client IP (incoming only) |
status_code |
smallint |
HTTP status code |
request_body |
json|null |
Request payload |
response_body |
json|null |
Response payload |
duration_ms |
int |
Round-trip time in milliseconds |
created_at |
timestamp |
Log timestamp |
Logging failures are silently swallowed — a broken log table will never block a payment.
Events Reference
| Event | When | Payload |
|---|---|---|
ZakhirWebhookReceived |
Every incoming webhook | WebhookPayload $payload |
ZakhirPaymentCompleted |
Webhook status=COMPLETED, after DB update |
ZakhirPayment $payment |
ZakhirPaymentFailed |
Webhook status=REJECTED, after DB update |
WebhookPayload $payload, int $localPayableId, string $localPayableType |
WebhookPayload DTO
$payload->id; // string — Zakhir's payment ID
$payload->referenceId; // string — your original referenceId
$payload->status; // PaymentStatus enum
$payload->raw; // array — full raw webhook body
PaymentStatus Enum
use Zakhir\LaravelZakhir\Enums\PaymentStatus;
PaymentStatus::Pending; // 'PENDING'
PaymentStatus::Completed; // 'COMPLETED'
PaymentStatus::Rejected; // 'REJECTED'
PaymentStatus::Unknown; // 'UNKNOWN'
$status->isTerminal(); // true for Completed and Rejected
Exception Handling
All package exceptions extend ZakhirException (which extends RuntimeException):
| Exception | Thrown when |
|---|---|
ZakhirException |
Base class — gateway disabled, missing config, invalid response |
ZakhirApiException |
Zakhir API returns a non-2xx HTTP response |
ZakhirWebhookException |
Webhook payload is missing a required field or fails signature check |
use Zakhir\LaravelZakhir\Exceptions\ZakhirException;
use Zakhir\LaravelZakhir\Exceptions\ZakhirApiException;
try {
$response = Zakhir::createPayment(250.00, 'SDG', 'Order #1024');
} catch (ZakhirApiException $e) {
// HTTP-level error from the Zakhir API
logger()->error('Zakhir API error', [
'status' => $e->statusCode,
'body' => $e->responseBody,
'message' => $e->getMessage(),
]);
} catch (ZakhirException $e) {
// Configuration issue or invalid response
logger()->error('Zakhir error: ' . $e->getMessage());
}
ZakhirApiException exposes two read-only properties:
$e->statusCode; // int — HTTP status code (401, 422, 500, …)
$e->responseBody; // array — decoded JSON response body
Architecture Overview
src/
├── ZakhirServiceProvider.php Auto-discovery, DI bindings, routes, migrations
├── ZakhirPaymentService.php Public API — createPayment / getPaymentStatus / cancelPayment
│
├── Contracts/
│ └── ZakhirClientInterface.php Interface for the HTTP client (swap or mock in tests)
│
├── Http/
│ ├── ZakhirConfig.php Reads config; handles prod/staging switching
│ ├── ZakhirClient.php HTTP layer — all Zakhir API calls + logging
│ ├── Controllers/
│ │ └── ZakhirWebhookController.php Processes COMPLETED / REJECTED webhooks
│ └── Middleware/
│ └── VerifyZakhirWebhookSignature.php Passthrough middleware (no signature required)
│
├── Data/ Typed DTOs — no raw arrays leaking across boundaries
│ ├── CreatePaymentData.php
│ ├── PaymentResponse.php
│ ├── PaymentStatusResponse.php
│ └── WebhookPayload.php
│
├── Enums/
│ └── PaymentStatus.php PENDING / COMPLETED / REJECTED / UNKNOWN
│
├── Events/
│ ├── ZakhirWebhookReceived.php
│ ├── ZakhirPaymentCompleted.php
│ └── ZakhirPaymentFailed.php
│
├── Exceptions/
│ ├── ZakhirException.php
│ ├── ZakhirApiException.php
│ └── ZakhirWebhookException.php
│
├── Facades/
│ └── Zakhir.php
│
├── Models/
│ ├── ZakhirPayment.php Polymorphic payment record
│ └── ZakhirLog.php Append-only API audit log
│
└── Support/
└── ZakhirLogger.php Writes to zakhir_logs; silently skips on DB failure
Key design decisions:
- Idempotent webhooks — every status update runs inside a
DB::transaction()withlockForUpdate(), so replayed or concurrent webhooks are safe. - Polymorphic
ZakhirPayment— attach payments to any Eloquent model (Order, Invoice, Subscription…) without modifying the package. - Events over tight coupling — the package fires events; your application decides what to do.
- Logging never crashes —
ZakhirLoggercatches all exceptions internally so a brokenzakhir_logstable can never block a live payment. - Interface-bound client —
ZakhirClientInterfacelets you swap or mock the HTTP client cleanly in tests.
Testing
The package ships with a full PHPUnit suite using Orchestra Testbench.
composer install
./vendor/bin/phpunit
In your own application, use Laravel's Http::fake() to mock Zakhir API calls without hitting the real gateway:
use Illuminate\Support\Facades\Http;
use Zakhir\LaravelZakhir\Facades\Zakhir;
Http::fake([
'*/payments' => Http::response([
'id' => 'zakhir-id-001',
'referenceId' => 'test-uuid',
'status' => 'PENDING',
'checkoutPage' => [
'url' => 'https://zakhir.net/pay/test',
],
], 200),
]);
$response = Zakhir::createPayment(100.00, 'SDG', 'Test payment');
$this->assertEquals('PENDING', $response->status);
$this->assertNotEmpty($response->checkoutUrl);
To test webhook handling, use ZakhirPaymentCompleted with Event::fake():
use Illuminate\Support\Facades\Event;
use Zakhir\LaravelZakhir\Events\ZakhirPaymentCompleted;
use Zakhir\LaravelZakhir\Models\ZakhirPayment;
Event::fake();
ZakhirPayment::create([
'transaction_id' => 'zakhir-pending-ref-001',
'reference_id' => 'ref-001',
'payable_id' => 1,
'payable_type' => Order::class,
'amount' => 10000,
'currency' => 'SDG',
'status' => 'PENDING',
]);
$this->postJson('/api/zakhir/webhook', [
'id' => 'gw-id-001',
'referenceId' => 'ref-001',
'status' => 'COMPLETED',
])->assertOk();
Event::assertDispatched(ZakhirPaymentCompleted::class);
$this->assertDatabaseHas('zakhir_payments', [
'reference_id' => 'ref-001',
'status' => 'COMPLETED',
]);
Changelog
See CHANGELOG.md for a full history of releases and changes.
Credits
| Package Author | Abdalrahman Molood |
| Company | Digitalize Lab |
| Payment Gateway | Zakhir |
Contributions, issues, and pull requests are welcome.
License
This package is open-source software licensed under the MIT License.