iabduul7 / laravel-moyasar
A modern Laravel package for Moyasar payment gateway integration. Built for Saudi Arabian e-commerce.
Requires
- php: ^8.3
- illuminate/contracts: ^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: ^2.0|^3.0
- laravel/pint: ^1.0
- orchestra/testbench: ^9.0|^10.0|^11.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
- phpstan/phpstan: ^1.0|^2.0
README
A modern, opinionated Laravel package for the Moyasar payment gateway. Built for Saudi Arabian e-commerce: mada, Visa, Mastercard, Apple Pay, Google Pay, Samsung Pay, and STC Pay.
Status: v0.2.0-dev — Adds Tokens, Payouts, the
Moneyvalue object, a specialised exception hierarchy, the Sadad source type, andMoyasarTestCards. v0.1.0 covered Payments, Invoices, Webhooks, Events, and an Eloquent trait.
Why this package?
Other Moyasar Laravel packages exist. This one is the only one that:
- Handles webhooks end-to-end. Constant-time
secret_tokenverification, cache-backed replay protection, and a typed Laravel event per Moyasar event type. The other packages leave you to parse the JSON yourself. - Ships eight typed events (
PaymentPaid,PaymentFailed,PaymentAuthorized,PaymentCaptured,PaymentRefunded,PaymentVoided,PaymentAbandoned,PaymentVerified) — not one generic "transaction created" event. - Has a real Eloquent layer.
HasMoyasarPaymentstrait + polymorphicmoyasar_paymentstable + automatic webhook→DB sync. Attach payments toOrder,Subscription, anything. - Is built for modern Laravel. PHP 8.2+, Laravel 11/12/13, strict types everywhere, readonly DTOs, backed enums. Tested under PHPStan level 8.
- Uses Laravel's own HTTP client.
Http::fake()works out of the box for downstream tests. No Guzzle wiring, no Mockery. - Is idempotency-aware via Moyasar's
given_idand retries 5xx automatically (never 4xx). - Is MIT-licensed. Safe for commercial use, no GPL contamination.
| This package | roduankd/laravel-moyasar |
ahmedebead/moyasar-laravel |
|
|---|---|---|---|
| Webhooks + signature verification | ✓ | — | — |
| Typed event per Moyasar event type | 8 events | 1 generic | — |
| Eloquent trait + auto-sync | ✓ | partial | — |
| Readonly DTOs + enums | ✓ | — | — |
| Idempotency support | ✓ | — | — |
| Retry on 5xx (not 4xx) | ✓ | — | — |
| Card number / CVC log masking | ✓ | — | — |
Pest 4 + Http::fake() test suite |
109 tests | Pest 1 | PHPUnit + Mockery |
| Laravel 11/12/13 support | ✓ | Laravel 8 | Laravel 9 |
| Active maintenance | ✓ | abandoned Oct 2023 | last push Sept 2024 |
| License | MIT | MIT | GPL-3.0 |
Requirements
- PHP 8.2+ (the test suite itself requires 8.3+ due to Pest 4 — runtime works fine on 8.2)
- Laravel 11.x, 12.x, or 13.x
Installation
composer require iabduul7/laravel-moyasar
Publish the config and migration:
php artisan vendor:publish --tag=moyasar-config php artisan vendor:publish --tag=moyasar-migrations php artisan migrate
Configuration
Add your keys to .env:
MOYASAR_SECRET_KEY=sk_test_... MOYASAR_PUBLISHABLE_KEY=pk_test_... MOYASAR_WEBHOOK_SECRET=whsec_...
Get test keys from your Moyasar Dashboard → Settings → API keys.
All values, including the webhook route, retry behaviour, logging, and replay-cache TTL, live in config/moyasar.php.
Quick start
use Iabduul7\Moyasar\Facades\Moyasar; $payment = Moyasar::payments()->create([ 'amount' => 10000, // halalas = 100.00 SAR 'currency' => 'SAR', 'description' => 'Order #123', 'callback_url' => route('checkout.callback'), 'source' => [ 'type' => 'creditcard', 'name' => 'John Doe', 'number' => '4111111111111111', 'cvc' => '123', 'month' => 12, 'year' => 2030, ], ]); // $payment is an Iabduul7\Moyasar\DataObjects\PaymentData (readonly DTO) $payment->id; $payment->status; // Iabduul7\Moyasar\Enums\PaymentStatus $payment->source->company; // 'visa'
Payments
use Iabduul7\Moyasar\Enums\PaymentStatus; use Iabduul7\Moyasar\Facades\Moyasar; Moyasar::payments()->find('pay_123'); Moyasar::payments()->list( page: 1, perPage: 50, filters: ['status' => PaymentStatus::Paid, 'metadata[order_id]' => '123'], ); Moyasar::payments()->refund('pay_123'); // full refund Moyasar::payments()->refund('pay_123', amount: 5000); // 50.00 SAR partial Moyasar::payments()->capture('pay_123', amount: 10000); // capture an authorized payment Moyasar::payments()->void('pay_123'); // void an authorized payment
Idempotency
Moyasar supports an idempotency key called given_id on payment creation. Pass a UUID v4 to safely retry the same request:
use Illuminate\Support\Str; Moyasar::payments()->create([ 'given_id' => (string) Str::uuid(), 'amount' => 10000, 'currency' => 'SAR', // ... ]);
Errors
All non-2xx responses raise an Iabduul7\Moyasar\Exceptions\ApiException that carries statusCode, type, errors, and the full decoded payload:
use Iabduul7\Moyasar\Exceptions\ApiException; try { Moyasar::payments()->create([...]); } catch (ApiException $e) { $e->statusCode; // 400 $e->type; // 'invalid_request_error' $e->errors; // ['amount' => ['must be at least 1']] }
5xx responses and connection errors are automatically retried (count + delay are configurable). 4xx responses are never retried.
Typed exception hierarchy
ApiException::fromResponse() returns the most specific subclass available so callers can catch by intent:
use Iabduul7\Moyasar\Exceptions\{ AuthenticationException, NotFoundException, RateLimitException, ServerErrorException, ValidationException, CardDeclinedException, InsufficientFundsException, ExpiredCardException, InvalidCardException, }; try { Moyasar::payments()->create([...]); } catch (InsufficientFundsException $e) { // ISO 8583 response code 51 — prompt the customer to try a different card } catch (CardDeclinedException $e) { // Any other declined card (response codes 04, 05, 41, 43, 62, 65) } catch (AuthenticationException $e) { // 401 — API key invalid or revoked } catch (RateLimitException $e) { // 429 — back off and retry } catch (ApiException $e) { // Catch-all }
Card-level exceptions win over generic status mapping. Each carries sourceMessage and sourceResponseCode as readonly properties for forensic logging.
Money
Use the Money value object to convert between major-unit decimals and Moyasar's smallest-unit integers — the #1 source of integration bugs:
use Iabduul7\Moyasar\Money; Money::sar(100.00)->amount; // 10000 (halalas) Money::halalas(10000)->format(); // "100.00 SAR" Money::fromMajor(10.500, 'KWD'); // 10500 minor (3-decimal currency) $total = Money::sar(99.99)->plus(Money::sar(0.01)); // 100.00 SAR $total->amount; // 10000
Invoices
use Iabduul7\Moyasar\Facades\Moyasar; $invoice = Moyasar::invoices()->create([ 'amount' => 10000, 'currency' => 'SAR', 'description' => 'Invoice INV-001', 'callback_url' => route('checkout.callback'), ]); $invoice->url; // hosted checkout page Moyasar::invoices()->find($invoice->id); Moyasar::invoices()->list(page: 1, perPage: 50); Moyasar::invoices()->update($invoice->id, ['description' => 'Updated']); Moyasar::invoices()->cancel($invoice->id);
Tokens
Server-side tokenisation for trusted-environment flows (admin tools, card-on-file migrations). For browser tokenisation, use Moyasar's JS SDK with your publishable key — the PAN never touches your server that way.
$token = Moyasar::tokens()->create([ 'name' => 'John Doe', 'number' => '4111111111111111', 'cvc' => '123', 'month' => '12', 'year' => '30', 'callback_url' => route('checkout.token-callback'), ]); $token->id; // 'token_...' $token->status; // TokenStatus enum $token->lastFour; // '1111' $token->verificationUrl; // 3DS URL when status === Initiated // Use the token in a subsequent payment: Moyasar::payments()->create([ 'amount' => 10000, 'currency' => 'SAR', 'source' => ['type' => 'token', 'token' => $token->id], ]); Moyasar::tokens()->find('token_...');
Payouts
use Iabduul7\Moyasar\Enums\PayoutStatus; $payout = Moyasar::payouts()->create([ 'amount' => 100000, // 1000.00 SAR 'currency' => 'SAR', 'description' => 'Vendor settlement', 'beneficiary' => [ 'name' => 'Vendor Co.', 'iban' => 'SA0380000000608010167519', 'type' => 'iban', ], ]); Moyasar::payouts()->find($payout->id); Moyasar::payouts()->list(page: 1, perPage: 50, filters: ['status' => PayoutStatus::Paid]);
Bulk payouts and payout-account endpoints aren't wrapped yet — open an issue if you need them.
Testing helpers
use Iabduul7\Moyasar\Testing\MoyasarTestCards; // Named constants instead of magic 16-digit literals: MoyasarTestCards::VISA_SUCCESS; MoyasarTestCards::VISA_DECLINE; MoyasarTestCards::MADA_SUCCESS; MoyasarTestCards::MASTERCARD_SUCCESS; MoyasarTestCards::AMEX_SUCCESS; // Ready-to-spread source arrays: $source = MoyasarTestCards::source( number: MoyasarTestCards::MADA_SUCCESS, threeDs: true, manual: true, ); $stcPay = MoyasarTestCards::stcPaySource('0512345678');
Webhooks
The package registers a POST /moyasar/webhook route automatically (configurable). Add the URL to your Moyasar dashboard with the same secret you set in MOYASAR_WEBHOOK_SECRET. Moyasar embeds that secret as secret_token inside the webhook body — the package verifies it in constant time and rejects any mismatch with a 401.
Replay protection caches each event id for MOYASAR_WEBHOOK_REPLAY_TTL seconds (default 24h). A redelivered event returns 200 but is not dispatched a second time.
Events
Listen for the events you care about:
use Iabduul7\Moyasar\Events\PaymentPaid; use Illuminate\Support\Facades\Event; Event::listen(function (PaymentPaid $event) { $event->payment; // PaymentData DTO $event->rawPayload; // raw webhook body, array });
All eight Moyasar event types are mapped:
| Moyasar event | Laravel event class |
|---|---|
payment_paid |
Iabduul7\Moyasar\Events\PaymentPaid |
payment_failed |
Iabduul7\Moyasar\Events\PaymentFailed |
payment_authorized |
Iabduul7\Moyasar\Events\PaymentAuthorized |
payment_captured |
Iabduul7\Moyasar\Events\PaymentCaptured |
payment_refunded |
Iabduul7\Moyasar\Events\PaymentRefunded |
payment_voided |
Iabduul7\Moyasar\Events\PaymentVoided |
payment_abandoned |
Iabduul7\Moyasar\Events\PaymentAbandoned |
payment_verified |
Iabduul7\Moyasar\Events\PaymentVerified |
Eloquent integration
Add the HasMoyasarPayments trait to any model that needs to track payments:
use Iabduul7\Moyasar\Concerns\HasMoyasarPayments; class Order extends Model { use HasMoyasarPayments; }
$order = Order::create([...]); $record = $order->createMoyasarPayment([ 'amount' => 10000, 'currency' => 'SAR', 'description' => "Order #{$order->id}", 'source' => [...], ]); $record->moyasar_id; $record->status; // PaymentStatus enum $order->moyasarPayments(); // MorphMany<MoyasarPayment> $order->latestMoyasarPayment(); $order->hasSuccessfulPayment(); // true if any payment is paid, captured, or authorized
When webhooks arrive, the package automatically updates any existing moyasar_payments row keyed by moyasar_id — your local copy stays in sync without any wiring. Disable via MOYASAR_WEBHOOK_SYNC_ELOQUENT=false.
MoyasarPaymentCast
Cast a payment-ID column to a live PaymentData DTO (lazy fetch + per-instance cache):
use Iabduul7\Moyasar\Casts\MoyasarPaymentCast; protected function casts(): array { return ['moyasar_payment_id' => MoyasarPaymentCast::class]; }
Reading the attribute calls the Moyasar API. Use sparingly to avoid N+1.
Testing your integration
Downstream apps should fake the Moyasar HTTP API the same way the package's own suite does:
use Iabduul7\Moyasar\Facades\Moyasar; use Illuminate\Support\Facades\Http; Http::fake([ 'api.moyasar.com/v1/payments' => Http::response([ 'id' => 'pay_test', 'status' => 'paid', 'amount' => 10000, 'currency' => 'SAR', // ... ], 201), ]); $payment = Moyasar::payments()->create([...]); expect($payment->isPaid())->toBeTrue();
For webhook tests, post a JSON body that includes a matching secret_token and assert against the dispatched event with Event::fake([PaymentPaid::class, ...]).
Saudi-specific notes
- Amounts are in halalas. 100 halalas = 1.00 SAR. The DTOs expose both
amount(int, halalas) andamount_format(string, e.g.100.00 SAR). - mada is the Saudi domestic card scheme. It is selected automatically by Moyasar based on the BIN — clients just send the card number.
- STC Pay uses the
stcpaysource with a Saudi mobile number (05xxxxxxxx,+9665xxxxxxxx, or009665xxxxxxxx). - Apple Pay / Google Pay / Samsung Pay must be tokenised on the client. Pass the encrypted token under
source.token(Apple/Samsung Pay) or as a generictoken-type source (Google Pay).
Configuration reference
return [ 'secret_key' => env('MOYASAR_SECRET_KEY'), 'publishable_key' => env('MOYASAR_PUBLISHABLE_KEY'), 'webhook_secret' => env('MOYASAR_WEBHOOK_SECRET'), 'base_url' => env('MOYASAR_BASE_URL', 'https://api.moyasar.com/v1'), 'http' => [ 'timeout' => env('MOYASAR_HTTP_TIMEOUT', 30), 'retry_times' => env('MOYASAR_HTTP_RETRY_TIMES', 3), 'retry_delay' => env('MOYASAR_HTTP_RETRY_DELAY', 200), ], 'webhooks' => [ 'enabled' => env('MOYASAR_WEBHOOKS_ENABLED', true), 'route' => env('MOYASAR_WEBHOOK_ROUTE', 'moyasar/webhook'), 'middleware' => ['api'], 'replay_ttl' => env('MOYASAR_WEBHOOK_REPLAY_TTL', 86400), 'sync_eloquent' => env('MOYASAR_WEBHOOK_SYNC_ELOQUENT', true), ], 'default_currency' => env('MOYASAR_DEFAULT_CURRENCY', 'SAR'), 'logging' => [ 'enabled' => env('MOYASAR_LOGGING_ENABLED', false), 'channel' => env('MOYASAR_LOG_CHANNEL', 'stack'), ], ];
Contributing
Pull requests welcome. Before submitting:
composer test # Pest 4 suite composer phpstan # Larastan level 8 composer pint # Laravel preset
Run the full check locally:
composer test && composer phpstan && composer pint-test
Security
Found a security issue? Email abdullahshahneel@outlook.com rather than opening a public issue.
License
MIT — see LICENSE.