mongosprout / paymongo-bridge
PayMongo API client and webhook pipeline for Laravel. Brings the plumbing, imposes no billing model.
Requires
- php: ^8.2
- illuminate/contracts: ^11.0|^12.0|^13.0
- illuminate/database: ^11.0|^12.0|^13.0
- illuminate/http: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
Requires (Dev)
- laravel/pint: ^1.18
- orchestra/testbench: ^9.0|^10.0|^11.0
- pestphp/pest: ^3.0|^4.0
README
PayMongo API client and webhook pipeline for Laravel. Brings the plumbing, imposes no billing model.
The package knows how to talk to PayMongo and how to receive a webhook safely. It does not know what a subscription means in your application, so it ships no billing schema and only one table of its own.
Install
composer require mongosprout/paymongo-bridge
PAYMONGO_SECRET_KEY=sk_test_...
PAYMONGO_PUBLIC_KEY=pk_test_...
PAYMONGO_WEBHOOK_SECRET=whsk_...
Live or test mode is read from the secret key's prefix, never configured, so the two can never disagree.
Collecting money
use MongoSprout\PayMongo\Facades\PayMongo; use MongoSprout\PayMongo\Money; $customer = PayMongo::customers()->create([ 'first_name' => 'Maria', 'last_name' => 'Santos', 'email' => 'maria@example.com', 'phone' => '+639171234567', ]); // Hosted checkout, for the wallets that cannot be auto debited. $session = PayMongo::checkoutSessions()->create([ 'line_items' => [[ 'name' => 'Business plan', 'amount' => Money::ofMajor('2000.00')->toCentavos(), 'currency' => 'PHP', 'quantity' => 1, ]], 'payment_method_types' => ['gcash', 'card', 'paymaya', 'grab_pay'], 'reference_number' => $invoice->number, 'metadata' => ['invoice_id' => (string) $invoice->id], 'success_url' => route('billing.return', $invoice), 'cancel_url' => route('billing.cancel', $invoice), ]); return redirect($session->attr('checkout_url'));
Recurring billing works for cards and Maya only. Vault the card on the customer first, then subscribe:
PayMongo::paymentMethods()->attachToCustomer($customerId, $paymentMethodId); PayMongo::subscriptions()->create($customerId, $planId, ['invoice_id' => (string) $invoice->id]);
Receiving webhooks
Register the endpoint once, and store the secret it prints:
php artisan paymongo:webhook:register https://your-app.test/paymongo/webhook
PAYMONGO_WEBHOOK_SECRET accepts several secrets, comma separated. Each registered endpoint has its own
and re-registering mints a new one, so a URL move or a rotation lists old and new together, then drops
the old once its endpoint is disabled. A delivery verifies if any listed secret signs it.
Then listen. Every delivery is signature checked, deduplicated and queued before your listener runs.
use MongoSprout\PayMongo\Webhooks\Events\SubscriptionInvoicePaid; class GrantAccess { public function handle(SubscriptionInvoicePaid $event): void { $invoice = Invoice::find($event->metadata('invoice_id')); // Handlers must be safe to run twice. PayMongo redelivers. $invoice?->markPaid($event->resource()->centavos()); } }
The endpoint authenticates by signature, not by middleware, but consider adding 'throttle:60,1' to
paymongo.webhook.middleware so junk traffic cannot fill your log with rejections.
Deliveries are stored in paymongo_webhook_events, full payload included, and the table grows forever.
Prune it on whatever schedule suits you — but never delete rows younger than the max_age window
(3 days by default). Signatures older than max_age are refused outright, so those rows are free to go;
inside the window the unique event id is what stops a captured delivery from being replayed.
Sending money
Off by default. An application that only collects payments should not carry a payout surface:
PAYMONGO_DISBURSEMENTS_ENABLED=true
$transfers = PayMongo::transfers(); $transfers->createBatch([ $transfers->transfer( sourceNumber: config('paymongo.disbursements.source_account_number'), sourceName: config('paymongo.disbursements.source_account_name'), destinationNumber: $employee->ewallet_number, destinationName: $employee->full_name, destinationBic: 'GXCHPHM2', amount: Money::ofMajor($item->net_pay), referenceNumber: $item->reference_number, ), ]);
Items in a batch settle independently, so reconcile per transfer rather than per batch.
Money
PayMongo speaks integer centavos. Applications often do not. Money converts across that boundary on the
decimal string rather than on a float, so 0.29 cannot quietly become 28 centavos the way
(int) (0.29 * 100) does.
Money::ofMajor('1500.15')->toCentavos(); // 150015 Money::ofCentavos(150015)->toMajor(); // "1500.15" safe for a decimal column
Testing
InteractsWithPayMongo is a plain trait, so it works in Pest and in PHPUnit.
$this->fakePayMongo(); $this->paymongoWebhook('subscription.invoice.paid', metadata: ['invoice_id' => '7'])->assertOk();
The webhook helper signs the exact bytes it sends. Posting through postJson() would re-encode the body
and the signature would never match.