digitaltunnel / moyasar
Moyasar payment gateway SDK for Laravel β credit card, Apple Pay, Samsung Pay, STC Pay, tokenization, webhooks.
Requires
- php: ^8.2
- ext-json: *
- 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/routing: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- orchestra/testbench: ^9.0|^10.0
- pestphp/pest: ^3.0
- pestphp/pest-plugin-laravel: ^3.0
- pestphp/pest-plugin-type-coverage: ^3.0
Suggests
- ext-pcntl: Recommended for running the queue worker that processes webhooks when moyasar.webhook.queue is enabled.
README
A typed, testable, batteries-included SDK for the Moyasar payment gateway with first-class Laravel integration. Credit cards, Apple Pay, Samsung Pay, STC Pay, tokenization, invoices, and webhooks β all covered out of the box.
use DigitalTunnel\Moyasar\Facades\Moyasar; $payment = Moyasar::payments()->create( CreatePaymentRequest::make() ->amountInMajorUnits(150.00) // 150.00 SAR ->callbackUrl(route('checkout.return')) ->source(CreditCardSource::make('Sara A.', '4111111111111111', 12, 2030, '123')) ); return $payment->requires3ds() ? redirect($payment->transactionUrl()) : back();
Why this package
- π― Typed end to end β readonly DTOs and enums, not loose arrays.
- π§± Fluent builders for every payment source and the create-payment / create-invoice requests.
- π Webhooks that just work β auto-registered route outside the
webgroup (no CSRF dance), signature verification, idempotent de-duplication, optional queued processing. - π§Ύ Invoices API β hosted payment pages / payment links in two lines.
- π‘οΈ Secure-by-default checkout β a
verifyCallback()helper that re-fetches and validates the payment before you fulfil an order. - π§ͺ Genuinely testable β a
MoyasarFakedouble, plus 160+ tests, PHPStan level 6, and ~100% type coverage in this very package.
Requirements
| PHP | 8.2, 8.3, 8.4 |
| Laravel | 11, 12, 13 |
Installation
composer require digitaltunnel/moyasar
Then run the installer β it publishes the config and prints next steps:
php artisan moyasar:install # add --migrations for the local mirror tables php artisan migrate # only if you published migrations
Add your keys to .env:
MOYASAR_SECRET_KEY=sk_test_... MOYASAR_PUBLISHABLE_KEY=pk_test_... MOYASAR_WEBHOOK_SECRET=whsec_...
The webhook route registers automatically outside the web middleware group, so CSRF
never applies β you don't need to touch VerifyCsrfToken::$except. Confirm it's live:
php artisan route:list --name=moyasar.webhook
# POST webhooks/moyasar ... VerifyWebhookSignature
Configuration
Every option is environment-driven. Defaults shown.
| Env var | Config key | Default | Purpose |
|---|---|---|---|
MOYASAR_SECRET_KEY |
moyasar.secret_key |
β | Backend Basic-auth key. Never expose. |
MOYASAR_PUBLISHABLE_KEY |
moyasar.publishable_key |
β | Safe for the browser (tokenization, Apple Pay). |
MOYASAR_BASE_URL |
moyasar.base_url |
https://api.moyasar.com/v1 |
Override only for a mock/proxy. |
MOYASAR_TIMEOUT |
moyasar.http.timeout |
30 |
HTTP timeout (seconds). |
MOYASAR_RETRY_TIMES |
moyasar.http.retry.times |
0 |
Transient-failure retries (0 = off). |
MOYASAR_RETRY_SLEEP_MS |
moyasar.http.retry.sleep_ms |
200 |
Delay between retries. |
MOYASAR_WEBHOOK_ENABLED |
moyasar.webhook.enabled |
true |
Auto-register the inbound route. |
MOYASAR_WEBHOOK_PATH |
moyasar.webhook.path |
webhooks/moyasar |
Inbound webhook path. |
MOYASAR_WEBHOOK_NAME |
moyasar.webhook.name |
moyasar.webhook |
Route name. |
MOYASAR_WEBHOOK_SECRET |
moyasar.webhook.secret |
β | Shared secret token to verify deliveries. |
MOYASAR_WEBHOOK_QUEUE |
moyasar.webhook.queue |
false |
false=sync, true=default queue, or a connection name. |
MOYASAR_PERSIST_PAYMENTS |
moyasar.persistence.payments.enabled |
true |
Mirror payments to moyasar_payments. |
MOYASAR_PERSIST_TOKENS |
moyasar.persistence.tokens.enabled |
true |
Mirror tokens to moyasar_tokens. |
MOYASAR_LOG_WEBHOOK_EVENTS |
moyasar.persistence.webhook_events.enabled |
true |
Log + de-dup webhooks via moyasar_webhook_events. |
Note All amounts are integers in the smallest currency unit (halalah).
100.00 SAR = 10000. Use->amountInMajorUnits(100.00)orAmount::toMinorUnits(100.00)to avoid the classic off-by-100 bug.
End-to-end checkout (credit card + 3DS)
The complete, secure flow β create, redirect to 3DS, then verify on return before fulfilling.
use DigitalTunnel\Moyasar\Facades\Moyasar; use DigitalTunnel\Moyasar\Requests\CreatePaymentRequest; use DigitalTunnel\Moyasar\Sources\CreditCardSource; // 1) Create the payment and send the customer to the 3DS challenge. public function checkout(Request $request, Order $order) { $payment = Moyasar::payments()->create( CreatePaymentRequest::make() ->amount($order->total_halalah) // integer halalah ->currency('SAR') ->givenId((string) Str::uuid()) // idempotency key ->callbackUrl(route('checkout.return')) ->description("Order #{$order->id}") ->metadata(['order_id' => (string) $order->id]) ->source( CreditCardSource::make( name: $request->string('name'), number: $request->string('number'), month: $request->integer('month'), year: $request->integer('year'), cvc: $request->string('cvc'), )->with3ds() // default; ->without3ds() to skip ) ); return redirect($payment->transactionUrl()); }
use DigitalTunnel\Moyasar\Exceptions\CallbackVerificationException; // 2) Moyasar redirects back to callback_url with ?id=&status=&message=. // NEVER trust those query params β re-fetch and verify server-side. public function return(Request $request) { $order = Order::findOrFail(/* from your own state/session */); try { $payment = Moyasar::payments()->verifyCallback( id: $request->string('id'), expectedAmount: $order->total_halalah, expectedCurrency: 'SAR', ); } catch (CallbackVerificationException $e) { report($e); // $e->payment is attached return redirect()->route('checkout.failed'); } $order->markPaid($payment->id); return redirect()->route('checkout.success'); }
Prefer webhooks as your source of truth for fulfilment (see below).
verifyCallback()gives the customer an instant, trustworthy result on return; the webhook guarantees you eventually reconcile even if the browser never comes back.
Payment methods
Apple Pay
use DigitalTunnel\Moyasar\Sources\ApplePaySource; // Merchant-validation handshake (from a backend proxy): $session = Moyasar::applePay()->requestSession( validationUrl: $request->input('validationURL'), displayName: config('app.name'), domainName: $request->getHost(), ); return response()->json($session->toArray()); // Charge the Apple Pay token: Moyasar::payments()->create( CreatePaymentRequest::make() ->amount(10000)->givenId((string) Str::uuid()) ->source(ApplePaySource::make($request->input('token'))) );
Samsung Pay
use DigitalTunnel\Moyasar\Sources\SamsungPaySource; Moyasar::payments()->create( CreatePaymentRequest::make()->amount(10000)->givenId((string) Str::uuid()) ->source(SamsungPaySource::make($samsungToken)) );
STC Pay (with OTP follow-up)
use DigitalTunnel\Moyasar\Sources\StcPaySource; $payment = Moyasar::payments()->create( CreatePaymentRequest::make()->amount(10000)->givenId((string) Str::uuid()) ->source(StcPaySource::make(mobile: '0512345678')) ); // After the user enters the OTP they received β returns a hydrated Payment: $payment = Moyasar::payments()->verifyStcPayOtp($payment->transactionUrl(), $otp); if ($payment->isPaid()) { // fulfilled }
Tokenization (card-on-file)
use DigitalTunnel\Moyasar\Sources\TokenSource; // Charge a previously-saved token (tokenize on the client with the publishable key). Moyasar::payments()->create( CreatePaymentRequest::make() ->amount(10000)->callbackUrl(route('checkout.return')) ->source(TokenSource::make('token_abc')->cvc('123')->with3ds()) ); Moyasar::tokens()->find('token_abc'); // status is a typed TokenStatus enum Moyasar::tokens()->delete('token_abc');
Invoices (hosted payment pages)
use DigitalTunnel\Moyasar\Requests\CreateInvoiceRequest; $invoice = Moyasar::invoices()->create( CreateInvoiceRequest::make() ->amountInMajorUnits(250.00) ->description('Annual subscription') ->callbackUrl(route('checkout.return')) ->expiresAt(now()->addDays(3)) ->metadata(['plan' => 'pro']) ); return redirect($invoice->paymentUrl()); // hosted Moyasar page Moyasar::invoices()->find($invoice->id); Moyasar::invoices()->list(['status' => 'paid']); Moyasar::invoices()->bulkCreate([$requestA, $requestB]); Moyasar::invoices()->cancel($invoice->id);
Lifecycle & reconciliation
Moyasar::payments()->find('pay_...'); Moyasar::payments()->update('pay_...', metadata: ['tag' => 'vip']); Moyasar::payments()->refund('pay_...', amount: 5000); // partial; omit for full Moyasar::payments()->capture('pay_...'); // for manual-capture auths Moyasar::payments()->void('pay_...'); // Page through results, or stream every match without managing pages: $page = Moyasar::payments()->list(['status' => 'paid', 'metadata' => ['order_id' => '42']]); foreach (Moyasar::payments()->each(['status' => 'paid']) as $payment) { // walks every page lazily until there are no more }
Webhooks
Listen for the typed events anywhere you register listeners:
use DigitalTunnel\Moyasar\Events\PaymentPaid; Event::listen(PaymentPaid::class, function (PaymentPaid $event) { // $event->payment is a Payment DTO; $event->webhook is the full WebhookPayload Order::findByMoyasarId($event->payment->id)?->markPaid(); });
Available events (each carries $payment + $webhook):
PaymentPaid Β· PaymentFailed Β· PaymentAuthorized Β· PaymentCaptured Β· PaymentRefunded
Β· PaymentVoided Β· PaymentAbandoned Β· PaymentVerified Β· WebhookReceived (catch-all).
Idempotent by design. Moyasar retries non-2xx deliveries up to 6 times. With the
moyasar_webhook_events table enabled, each event is stored by id and claimed before
dispatch, so a retried (or forged) delivery never re-fires your listeners. Keep listeners
idempotent, and for heavy work either queue your listeners (ShouldQueue) or offload the
whole pipeline:
MOYASAR_WEBHOOK_QUEUE=redis # process inbound webhooks on the "redis" connection
Register your webhook
php artisan moyasar:webhook register # uses route('moyasar.webhook') + your secret
php artisan moyasar:webhook list
php artisan moyasar:webhook delete wh_abc
β¦or from code: Moyasar::webhooks()->create(url: ..., events: [...], sharedSecret: ...).
Custom route
Set MOYASAR_WEBHOOK_ENABLED=false and wire it yourself:
Route::post('my-path', \DigitalTunnel\Moyasar\Http\Controllers\WebhookController::class) ->middleware([\DigitalTunnel\Moyasar\Http\Middleware\VerifyWebhookSignature::class]);
Local persistence (opt-in)
If you published the migrations, three Eloquent models mirror Moyasar state for reporting:
use DigitalTunnel\Moyasar\Models\MoyasarPayment; MoyasarPayment::where('status', 'paid')->sum('amount'); // Associate a mirror row with your own model: MoyasarPayment::upsertFromDto($event->payment)->payable()->associate($order)->save();
MoyasarToken and MoyasarWebhookEvent work the same way. Rows are written automatically
on API calls and inbound webhooks unless you disable the relevant persistence.* flag.
Money helpers
use DigitalTunnel\Moyasar\Support\Amount; Amount::toMinorUnits(100.00); // 10000 Amount::toMajorUnits(10000); // 100.0 Amount::format(10000, 'SAR'); // "100.00 SAR" $payment->amountInMajorUnits(); // 100.0
Exception handling
use DigitalTunnel\Moyasar\Exceptions\InvalidRequestException; try { Moyasar::payments()->create($request); } catch (InvalidRequestException $e) { $e->fieldErrors(); // ['source.number' => ['is invalid'], ...] $e->firstErrorFor('source.cvc'); }
The full hierarchy:
MoyasarException (base β extends RuntimeException)
βββ ApiException (any non-2xx β statusCode, errorType, responseBody)
β βββ AuthenticationException (401)
β βββ AccountInactiveException (403 + account_inactive_error)
β βββ NotFoundException (404)
β βββ InvalidRequestException (400/422 β + fieldErrors)
β βββ RateLimitException (429)
β βββ ServerException (5xx)
βββ ConnectionException (network/timeout)
βββ CallbackVerificationException (verifyCallback mismatch β has ->payment)
βββ InvalidWebhookSignatureException (renders HTTP 403)
Testing
Use MoyasarFake to stub the API in your own feature tests:
use DigitalTunnel\Moyasar\Testing\MoyasarFake; it('creates a pending payment', function () { MoyasarFake::fakePaymentCreated(); $this->post('/checkout', [...])->assertRedirect(); MoyasarFake::assertCreated(); }); it('surfaces validation errors', function () { MoyasarFake::fakeValidationError(['source.cvc' => ['is invalid']]); // ... });
Helpers: fakePaymentCreated(), fakeValidationError(), fakeAuthenticationError(),
webhookFixture(), assertCreated(), assertRefunded(), assertSentCount(),
assertNothingSent().
Running the package test suite
composer install composer test # Pest composer analyse # PHPStan (level 6) composer lint:test # Pint composer ci # all of the above
Contributing
See CONTRIBUTING.md. Security issues: SECURITY.md.
License
MIT. See LICENSE.