iabduul7/laravel-moyasar

A modern Laravel package for Moyasar payment gateway integration. Built for Saudi Arabian e-commerce.

Maintainers

Package info

github.com/iabduul7/laravel-moyasar

pkg:composer/iabduul7/laravel-moyasar

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.1.0 2026-05-24 05:50 UTC

This package is auto-updated.

Last update: 2026-05-24 08:50:08 UTC


README

Tests PHPStan Latest Version License

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 Money value object, a specialised exception hierarchy, the Sadad source type, and MoyasarTestCards. 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_token verification, 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. HasMoyasarPayments trait + polymorphic moyasar_payments table + automatic webhook→DB sync. Attach payments to Order, 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_id and 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) and amount_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 stcpay source with a Saudi mobile number (05xxxxxxxx, +9665xxxxxxxx, or 009665xxxxxxxx).
  • 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 generic token-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.