ahmedlaggoun / moyasar
Security-first Moyasar payment gateway integration for Laravel.
Requires
- php: ^8.2
- ext-json: *
- illuminate/bus: ^10.0|^11.0|^12.0
- illuminate/http: ^10.0|^11.0|^12.0
- illuminate/queue: ^10.0|^11.0|^12.0
- illuminate/support: ^10.0|^11.0|^12.0
Requires (Dev)
- laravel/pint: ^1.13
- orchestra/testbench: ^8.0|^9.0|^10.0
- pestphp/pest: ^2.0|^3.0
- phpstan/phpstan: ^1.11
README
Moyasar payment gateway integration for Laravel, built with the security constraints of card payments as the design driver rather than a section at the end. PHP 8.2+, Laravel 10/11/12.
The constraint that shapes everything
Moyasar's terms state that sending cardholder data to your backend is prohibited and grounds for terminating your agreement. Cards are collected in the browser or the mobile SDK, against the publishable key, and never reach your server.
So this package offers no way to do it. There is no method anywhere that
accepts a PAN, CVC or expiry — not a discouraged one, not a commented-out one.
The only payment-creation path exposed server-side is charging an existing
token, which contains no card data. If you find yourself wanting a
createPayment($cardNumber, ...), the integration has gone wrong upstream.
Install
composer require ahmedlaggoun/moyasar php artisan vendor:publish --tag=moyasar-config
MOYASAR_ENV=test MOYASAR_SECRET_KEY=sk_test_xxxxx MOYASAR_PUBLISHABLE_KEY=pk_test_xxxxx MOYASAR_WEBHOOK_SECRET= MOYASAR_WEBHOOK_PATH=webhooks/moyasar
Package discovery registers the provider. The webhook route registers itself
only when MOYASAR_WEBHOOK_SECRET is set — an unauthenticated payment
webhook is worse than no webhook.
The one flow that matters
The customer pays in the browser and is redirected back to your callback_url
with query parameters appended:
https://yourapp.com/payments/return?id=79cced57-…&status=paid&message=Succeeded
Take the id. Discard status and message. They are in the address bar.
A customer can change status=failed to status=paid before your route ever
sees the request, so any check that reads them is checking a value the attacker
supplied.
use AhmedLaggoun\Moyasar\Application\Moyasar; use AhmedLaggoun\Moyasar\Domain\Exceptions\PaymentVerificationException; public function return(Request $request, Moyasar $moyasar) { $order = Order::findOrFail($request->session()->get('order_id')); try { $payment = $moyasar->verify($request->query('id'), $order->total()); } catch (PaymentVerificationException $e) { report($e); return redirect()->route('checkout.failed'); } $order->markPaid($payment->id); return redirect()->route('orders.show', $order); }
verify() fetches the payment with your secret key and asserts status,
amount and currency together, then throws rather than returning false — a
missed return value should not become a free order. Checking status alone lets
someone pay SAR 1.00 for a SAR 1,000.00 order, because the amount is set in the
browser-side form config.
Note the order id comes from the session, not the query string. Otherwise one valid payment can be replayed against a different order.
Render the form with the only key that may leave your server:
<script> Moyasar.init({ element: '.mysr-form', amount: {{ $order->total()->minor }}, currency: '{{ $moyasar->currency() }}', publishable_api_key: '{{ $moyasar->publishableKey() }}', callback_url: '{{ route('checkout.return') }}', }); </script>
Amounts
Every amount is an integer in the currency's minor unit, wrapped in Money.
There is no fromFloat(), deliberately — 0.1 + 0.2 is not 0.3 in binary
floating point, and a one-halala drift is a reconciliation failure.
use AhmedLaggoun\Moyasar\Domain\Data\Money; Money::sar(10000); // SAR 100.00 Money::fromDecimalString('19.99'); // 1999 halalas Money::fromDecimalString('1.500', 'KWD'); // KWD uses 3 decimals
Currency is part of equality, so a USD 100.00 payment can never satisfy a SAR 100.00 order.
Webhooks
use AhmedLaggoun\Moyasar\Application\Events\MoyasarPaymentUpdated; Event::listen(MoyasarPaymentUpdated::class, function (MoyasarPaymentUpdated $event) { if (! $event->verified) { return; } $order = Order::where('payment_id', $event->payment->id)->first(); $order?->markPaid(); });
Understand what Moyasar's webhook authentication does and does not prove.
There is no HMAC signature. Moyasar echoes a shared secret inside the request
body. A valid secret_token proves the sender knows the secret — it does not
prove the body is unmodified, because nothing signs it, and the same secret
rides on every event to every endpoint you register.
The package therefore:
- compares the secret in constant time (a naive
===leaks it a byte at a time to anyone who can measure response latency) - fails closed when no secret is configured
- rejects events whose
liveflag contradicts your environment, so a test event can never settle a real order - de-duplicates by event id before processing — Moyasar retries up to six
times over roughly four hours, so
payment_paidarriving six times for one payment is normal, not exceptional - re-fetches the payment from the API and dispatches that, never the webhook body. The body tells you which payment to look at; the API tells you what is true about it
- responds
202immediately and does the re-fetch on the queue, because Moyasar requires a 2xx before slow work and retries anything else
Even with a verified event, call assertMatches() against your own order
total. Knowing a payment is paid is not knowing it paid for this order.
Register the endpoint in the Moyasar dashboard pointing at
https://yourapp.com/webhooks/moyasar, with the same shared secret you put in
MOYASAR_WEBHOOK_SECRET.
Refunds, captures and voids
$moyasar->capture($paymentId); // full $moyasar->capture($paymentId, Money::sar(3000)); // partial $moyasar->void($paymentId); $moyasar->refund($paymentId, Money::sar(2000));
These are never retried automatically. A retried refund is a second refund, and
an HTTP client that cannot tell a refund from a fetch will eventually issue one.
get() retries; post() does not. On an ambiguous failure the gateway re-reads
the payment and returns it if the operation had in fact landed — the recovery
flow Moyasar documents — and otherwise rethrows.
Prefer void over refund inside the window: it releases the hold instantly and avoids processing fees. Authorized holds are voidable for about 14 days on mada; a paid or captured charge for roughly two hours.
Charging a saved card
$moyasar->charge( tokenId: $user->card_token, amount: Money::sar(4900), idempotencyKey: "sub-{$subscription->id}-{$period->format('Y-m')}", description: 'Monthly subscription', );
The idempotency key maps to Moyasar's given_id and becomes the payment id.
It must be derived from the thing being paid for and identical across
retries — an invoice id, a subscription period. A fresh UUID per attempt turns
the guarantee off, which is exactly the failure it exists to prevent. This is
what makes a timeout safe to retry: you either get the original payment back or
it is created once.
Delete the token the moment a customer removes the payment method:
$moyasar->forgetToken($user->card_token);
Structure
config/moyasar.php configuration + env keys
routes/moyasar.php webhook route
src/Domain/ framework-free core
Contracts/PaymentGateway.php the ports a payment provider fills
Contracts/TokenGateway.php
Data/ Money, Payment, PaymentSource,
Token, WebhookEvent, Page
Enums/ PaymentStatus, TokenStatus,
WebhookEventType, CardCompany,
Environment
Exceptions/ including PaymentVerificationException
src/Application/ use cases
Moyasar.php the entry point your code calls
Events/MoyasarPaymentUpdated.php
Jobs/ProcessMoyasarWebhook.php re-fetch, then dispatch
src/Infrastructure/ everything that touches the outside
Contracts/MoyasarTransport.php the HTTP port
Contracts/WebhookVerifier.php the verification port
Gateways/MoyasarGateway.php vendor adapter
Http/HttpTransport.php wire protocol, asymmetric retry
Webhooks/SharedSecretVerifier.php
Webhooks/MoyasarWebhookController.php
Providers/MoyasarServiceProvider.php wiring + boot-time validation
src/Support/ shared kernel: pure array and
redaction helpers
Dependencies point inward, and this is enforced rather than aspirational:
Domain imports nothing from Application or Infrastructure, Application
imports nothing from Infrastructure, and only Infrastructure knows that
Moyasar exists.
Two honest caveats the folder names would otherwise paper over. Domain is
free of the Laravel framework but uses Illuminate\Support\Collection as a
return type; that ships in illuminate/collections, a standalone library, so
the layer still runs outside Laravel — it is not zero-dependency. And
Application uses the queue and event traits, so it is Laravel-bound by
design: ProcessMoyasarWebhook exists precisely to move slow work onto a
queue, which is a framework concern.
WebhookVerifier sits in Infrastructure/Contracts, not Domain/Contracts,
for a concrete reason: its method signature takes an Illuminate\Http\Request.
An interface that names an HTTP request object is not a domain concept, and
putting it in Domain would make the layer boundary decorative. The two ports
the domain does own — PaymentGateway and TokenGateway — are the ones a
second payment provider would implement.
Configuration safety
The provider refuses to boot on:
- a
sk_live_*key withMOYASAR_ENV=test, or the reverse — this mismatch is how staging ends up charging real cards - a publishable key in the secret key slot
- a non-HTTPS base URL
- an unparseable environment value
All at boot, loudly, rather than at the first live charge.
Other security notes
- The secret key never leaves the server. It lives in a private property and
only ever appears in an
Authorizationheader.$moyasar->publishableKey()is the only key intended for a view. - Restrict the secret key by IP in the Moyasar dashboard. It is the single best mitigation if a key leaks from a log or a backup.
- Nothing sensitive is logged. The redactor strips keys, secrets, tokens, CVC, cardholder name, masked PAN, mobile and payer IP from any context this package emits. A cardholder name beside a masked PAN in your log aggregator pulls that stack into PCI DSS scope.
- Resource ids are validated before entering a URL path, so a caller-supplied id cannot traverse to another endpoint.
- The webhook route is stateless — no session, no CSRF, no cookies.
- If a key may have leaked, regenerate it in the dashboard first and investigate afterwards.
Documentation quirks handled
- Moyasar's event table spells the failure event
payment_faild; their create-webhook example usespayment_failed. Both are accepted, so a fix on their side will not silently start dropping failure events. - A declined charge returns 201, not 4xx, with the failure in the payment's
statusandmessage. HTTP status is not a payment outcome. - When an issuer expires an authorization hold, the status stays
authorizedand is never updated.authorizedis not a reliable signal that a capture will still succeed, which is whyisTerminal()excludes it. - List endpoints return 40 records per page, newest first, with a
metablock.
Testing
Every dependency is a narrow interface, so a fake transport replaces HTTP entirely. The suite covers the tamper cases specifically: wrong amount, wrong currency, unsettled status, forged webhook secret, wrong environment, and path traversal in a resource id.
composer test # Pest composer analyse # PHPStan level 6 composer format # Pint
Changelog
See CHANGELOG.md. Semantic versioning: a breaking change to any
interface in src/Domain/Contracts/ is a major bump.
License
MIT. See LICENSE.