digitaltunnel/moyasar

Moyasar payment gateway SDK for Laravel β€” credit card, Apple Pay, Samsung Pay, STC Pay, tokenization, webhooks.

Maintainers

Package info

github.com/digital-tunnel/moyasar

pkg:composer/digitaltunnel/moyasar

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.1 2026-05-29 04:45 UTC

This package is auto-updated.

Last update: 2026-05-29 04:45:40 UTC


README

Latest Version Tests Quality Downloads License

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 web group (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 MoyasarFake double, 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) or Amount::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.