ahmed-abd0 / laravel-paymob
A Cashier-inspired Laravel package for Paymob payments, saved cards, subscriptions, refunds, webhooks, transaction inquiry, and Quick Links.
Requires
- php: ^8.2
- illuminate/cache: ^11.0|^12.0|^13.0
- illuminate/console: ^11.0|^12.0|^13.0
- illuminate/contracts: ^11.0|^12.0|^13.0
- illuminate/database: ^11.0|^12.0|^13.0
- illuminate/encryption: ^11.0|^12.0|^13.0
- illuminate/events: ^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)
- laravel/pint: ^1.24
- orchestra/testbench: ^9.0|^10.0|^11.0
- pestphp/pest: ^3.8|^4.0
- pestphp/pest-plugin-laravel: ^3.2|^4.0
This package is auto-updated.
Last update: 2026-07-16 13:28:44 UTC
README
A community-built, Cashier-inspired Paymob package for Laravel 11, 12, and 13. It wraps the current Paymob Intention, saved-card, subscription, refund/void/capture, transaction inquiry, and Quick Link APIs while keeping a reliable local billing state.
This package is not an official Paymob package. Replace the Composer vendor name before publishing under your own organization.
Why this design
The package has two layers:
- Raw API resources for direct access to every endpoint.
- Billing models and workflows for subscriptions, idempotent callbacks, local access checks, trials, grace periods, payment methods, and synchronization.
Redirect responses are never treated as proof of payment. Signed backend callbacks and Paymob inquiry APIs are the source of truth.
Installation
composer require ahmed-abd0/laravel-paymob php artisan vendor:publish --tag=paymob-config php artisan migrate
Add Paymob\Laravel\Concerns\Billable to the model that owns subscriptions:
use Paymob\Laravel\Concerns\Billable; class User extends Authenticatable { use Billable; }
Configure .env:
PAYMOB_REGION=egypt PAYMOB_CURRENCY=EGP PAYMOB_SECRET_KEY= PAYMOB_PUBLIC_KEY= PAYMOB_API_KEY= PAYMOB_HMAC_SECRET= PAYMOB_CARD_3DS_INTEGRATION_ID= PAYMOB_MOTO_INTEGRATION_ID= PAYMOB_SUBSCRIPTION_WEBHOOK_SECRET=a-long-random-secret PAYMOB_REDIRECTION_URL=https://app.example.com/billing/return
Supported regions: egypt, ksa, uae, and oman.
Create a Paymob subscription plan
use Paymob\Laravel\Data\SubscriptionPlanData; use Paymob\Laravel\Enums\PlanFrequency; use Paymob\Laravel\Facades\Paymob; $plan = Paymob::plans()->create(new SubscriptionPlanData( name: 'Clinic Pro Monthly', amountCents: 49900, integration: (int) config('paymob.integrations.moto'), frequency: PlanFrequency::MONTHLY, reminderDays: 2, retrialDays: 2, webhookUrl: route('paymob.webhooks.subscription', [ 'secret' => config('paymob.webhooks.subscription_secret') ]) ));
Plans are stored locally in paymob_plans and keep the full remote response for diagnostics.
Start a subscription checkout
$checkout = $user->newSubscription('default', $plan) ->billing([ 'first_name' => 'Ahmed', 'last_name' => 'Abdo', 'email' => 'ahmed@example.com', 'phone_number' => '+201000000000', 'country' => 'EG' ]) ->metadata(['tenant_id' => $tenant->id]) ->checkout(); return $checkout->redirect();
For a trial:
$checkout = $user->newSubscription('default', $plan) ->trialDays(14) ->amount(100) ->checkout();
When Paymob provides a dedicated 3DS verification integration, pass it explicitly for verification-only enrollment:
$checkout = $user->newSubscription('default', $plan) ->trialDays(14) ->amount(100) ->paymentMethods([(int) config('paymob.integrations.verification_3ds')]) ->checkout();
The local subscription starts as incomplete. It becomes active only after a verified successful callback.
Subscription checks
$user->subscribed('default'); $user->subscription('default')?->active(); $user->subscription('default')?->onTrial(); $user->subscription('default')?->onGracePeriod(); $user->subscription('default')?->pastDue();
valid() returns true while active, on trial, or in a cancellation/suspension grace period.
Manage a subscription
$subscription = $user->subscription('default'); $subscription->suspend(); $subscription->resume(nextBilling: '2026-08-15'); $subscription->updateBilling([ 'amount_cents' => 59900, 'ends_at' => '2027-07-15', 'next_billing' => '2026-08-15' ]); $subscription->cancel(); $subscription->sync(); $subscription->sync(withRelations: true);
Paymob cancellation is permanent. Creating a fresh subscription is required after cancellation.
Manage cards
$cards = Paymob::subscriptions()->cards($subscription->paymob_id); Paymob::subscriptions()->changePrimaryCard($subscription->paymob_id, $cardId); Paymob::subscriptions()->deleteCard($subscription->paymob_id, $cardId);
To add a secondary card, create an intention with subscriptionv2_id:
use Paymob\Laravel\Data\BillingData; use Paymob\Laravel\Data\IntentionData; use Paymob\Laravel\Data\Item; $data = new IntentionData( amount: 100, billingData: BillingData::fromArray($user->paymobBillingData()), paymentMethods: [(int) config('paymob.integrations.card_3ds')], subscriptionId: (int) $subscription->paymob_id ); $data->items(new Item('Card verification', 100)); $response = Paymob::subscriptions()->addCard($data);
Card tokens are encrypted using Laravel's application key. Webhook payloads are also encrypted at rest.
One-off intentions
$data = new IntentionData( amount: 10000, billingData: BillingData::fromArray($user->paymobBillingData()), paymentMethods: [(int) config('paymob.integrations.default')], specialReference: 'ORDER-1001' ); $data->items(new Item('Order 1001', 10000)); $response = Paymob::intentions()->create($data); $url = Paymob::intentions()->checkoutUrl($response['client_secret']);
Saved cards
Customer-initiated payment:
$response = Paymob::savedCards()->customerInitiated($data, [$storedToken]);
Merchant-initiated payment:
$intention = Paymob::savedCards()->merchantInitiatedIntention($motoIntention); $paymentToken = data_get($intention->toArray(), 'payment_keys.0.key'); $charge = Paymob::savedCards()->payMoto($storedCardToken, $paymentToken);
Never expose saved tokens to frontend code.
Refund, void, and capture
Paymob::payments()->refund($transactionId, 5000); Paymob::payments()->void($transactionId); Paymob::payments()->capture($transactionId, 10000);
Transaction inquiry
Paymob::transactions()->byOrder(orderId: $paymobOrderId); Paymob::transactions()->byOrder(merchantOrderId: 'ORDER-1001'); Paymob::transactions()->find($transactionId);
Quick Links
$link = Paymob::quickLinks()->create([ 'amount_cents' => 10000, 'payment_methods' => config('paymob.integrations.default'), 'full_name' => 'Ahmed Abdo', 'email' => 'ahmed@example.com', 'phone_number' => '+201000000000', 'description' => 'Invoice 1001', 'reference_id' => 'INV-1001', 'is_live' => false, 'notification_url' => route('paymob.webhooks.transaction') ]); Paymob::quickLinks()->cancel($link['id']);
Webhooks
The package registers:
POST /paymob/webhooks
POST /paymob/webhooks/transaction
POST /paymob/webhooks/token
POST /paymob/webhooks/subscription?secret=...
Transaction and token callbacks use HMAC-SHA512 validation. Subscription callbacks use a secret embedded in the registered callback URL because the supplied subscription collection does not define a subscription-specific HMAC calculation.
Processing is:
- HMAC/secret verified before state changes.
- Payload hash deduplicated.
- Webhook row locked before processing.
- Transactions and card tokens upserted.
- Subscription state changed atomically.
- Events dispatched after commit.
- Failed callbacks return an error so Paymob can retry.
Available events:
Paymob\Laravel\Events\WebhookHandled Paymob\Laravel\Events\TransactionUpdated Paymob\Laravel\Events\SubscriptionUpdated Paymob\Laravel\Events\PaymentMethodUpdated
If the callbacks use Laravel's web middleware, exclude these paths from CSRF validation. The default package routes use the api middleware group.
Synchronization and cleanup
php artisan paymob:sync-subscriptions --plans php artisan paymob:sync-subscriptions --with-relations php artisan paymob:prune-webhooks php artisan paymob:prune-webhooks --days=30
Schedule synchronization as a reconciliation fallback:
use Illuminate\Support\Facades\Schedule; Schedule::command('paymob:sync-subscriptions --plans')->hourly(); Schedule::command('paymob:prune-webhooks')->daily();
Raw endpoint coverage
| Area | Methods |
|---|---|
| Intentions | create, update, retrieve, checkout URL |
| Subscription plans | create, list, update, suspend, resume |
| Subscriptions | create, retrieve, update, suspend, resume, cancel |
| Subscription details | last transaction, transactions, cards |
| Subscription cards | add, delete, change primary |
| Saved cards | token enrollment, CIT, MIT/MOTO |
| Post-payment | refund, void, capture |
| Inquiry | order/merchant reference, transaction ID |
| Quick Links | create, cancel |
Important production rules
- Use integers in minor currency units (
10000means100.00). - Ensure the intention amount equals the sum of
items[].amount. - Test keys only work with test integration IDs; live keys only work with live integration IDs.
- A recurring plan requires a MOTO integration; enrollment normally requires an online 3DS integration.
- Treat callbacks and inquiry responses as authoritative, not browser redirects.
- Keep
APP_KEY, Paymob keys, HMAC secret, and tokenized cards outside source control. - Use HTTPS for every callback and redirect URL.
- Only GET requests are retried automatically; non-idempotent payment operations are not replayed by default.
- Add your own authorization around customer billing-management endpoints.
Custom models
Override model classes in config/paymob.php. Custom models should extend the package model so the relationships, casts, and lifecycle methods remain available.
Testing
composer install
composer test
composer lint