ahmedlaggoun/moyasar

Security-first Moyasar payment gateway integration for Laravel.

Maintainers

Package info

github.com/ahmed-laggoun/Moyasar

pkg:composer/ahmedlaggoun/moyasar

Transparency log

Statistics

Installs: 2

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-08-19 19:27 UTC

This package is auto-updated.

Last update: 2026-08-19 19:37:10 UTC


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.

tests

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 live flag 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_paid arriving 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 202 immediately 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 with MOYASAR_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 Authorization header. $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 uses payment_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 status and message. HTTP status is not a payment outcome.
  • When an issuer expires an authorization hold, the status stays authorized and is never updated. authorized is not a reliable signal that a capture will still succeed, which is why isTerminal() excludes it.
  • List endpoints return 40 records per page, newest first, with a meta block.

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.