syriable / laravel-payments
A lightweight Laravel package for accepting payments through Stripe, PayPal, and an open ecosystem of community gateway plugins.
Fund package maintenance!
Requires
- php: ^8.3
- illuminate/console: ^12.0|^13.0
- illuminate/contracts: ^12.0|^13.0
- illuminate/database: ^12.0|^13.0
- illuminate/http: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
- spatie/laravel-package-tools: ^1.92
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
- phpstan/phpstan: ^2.0
This package is auto-updated.
Last update: 2026-05-22 21:26:52 UTC
README
Laravel Payments
A lightweight Laravel package for accepting payments through Stripe, PayPal, and an open ecosystem of community gateway plugins.
It does one thing well: it unifies payment gateways behind a small, Laravel-native API. It is not an accounting system, a subscription engine, or a billing platform. It ships a single table to make webhook delivery durable, and otherwise stays out of your schema — it never models your domain for you.
use Syriable\Payments\Data\Checkout; use Syriable\Payments\Facades\Gateway; $result = Gateway::driver('stripe')->checkout(new Checkout( amount: 2500, // minor units — 2500 == $25.00 currency: 'USD', reference: "order_{$order->id}", successUrl: route('orders.success', $order), cancelUrl: route('orders.cancel', $order), )); return redirect($result->redirectUrl);
Why this package
- Tiny core. A manager, a contract, three DTOs, two gateways. You can read the whole thing in an afternoon.
- Laravel-native. Built on
Illuminate\Support\Manager— the same pattern behindCache,Mail, andStorage. Nothing new to learn. - Financially safe by design. Amounts are integer minor units. No floating-point math touches money, anywhere.
- No hard SDK dependencies. Gateways use Laravel's HTTP client. Your
vendor/directory stays lean. - Plugin-friendly. Add a gateway in ~20 lines, in your own Composer package, shipped on your own schedule.
Requirements
- PHP 8.3+
- Laravel 12 or 13
Installation
composer require syriable/laravel-payments
The service provider and the Gateway facade are auto-discovered. Publish the config:
php artisan vendor:publish --tag="laravel-payments-config"
Verified webhooks are persisted before processing (see Webhooks), so publish and run the migration:
php artisan vendor:publish --tag="laravel-payments-migrations"
php artisan migrate
Prefer not to store webhooks? Set webhook.store to
Syriable\Payments\Store\NullWebhookStore::class and skip the migration.
Then set your credentials in .env:
PAYMENT_GATEWAY=stripe STRIPE_SECRET=sk_live_xxx STRIPE_WEBHOOK_SECRET=whsec_xxx PAYPAL_MODE=live PAYPAL_CLIENT_ID=xxx PAYPAL_CLIENT_SECRET=xxx PAYPAL_WEBHOOK_ID=xxx
STRIPE_WEBHOOK_SECRET and PAYPAL_WEBHOOK_ID come from each provider's
dashboard when you register your webhook endpoint (see Webhooks).
Until they are set, incoming webhooks fail signature verification and return
403 — so configure them before going live.
Usage
Checkout
use Syriable\Payments\Data\Checkout; use Syriable\Payments\Facades\Gateway; $checkout = new Checkout( amount: 2500, currency: 'USD', reference: 'order_42', successUrl: 'https://shop.test/success', cancelUrl: 'https://shop.test/cancel', customerEmail: 'buyer@example.com', // optional metadata: ['order_id' => 42], // optional ); // Explicit gateway: $result = Gateway::driver('stripe')->checkout($checkout); // Or the default gateway from config — __call passthrough handles this: $result = Gateway::checkout($checkout); return redirect($result->redirectUrl);
checkout() returns a PaymentResult:
$result->id; // gateway's payment/session id $result->status; // PaymentStatus enum (Pending, RequiresAction, Processing, Paid, Failed, Canceled, PartiallyRefunded, Refunded) $result->redirectUrl; // hosted-checkout URL, if any $result->reference; // your checkout reference, when the gateway echoes it (e.g. on retrieve()) $result->amount; // gateway-reported amount in minor units, when available $result->currency; // gateway-reported ISO 4217 currency, when available $result->raw; // full untouched gateway response
Amounts are always integer minor units.
2500means$25.00,100means$1.00. Never pass a float or a major-unit value like25.00—Checkoutrejects non-positive amounts at construction, but it cannot tell25cents from25dollars. Keeping money as integers is what makes the package financially safe; no floating-point math touches an amount anywhere.
Store
$result->idon your order. This is the gateway's identifier for the payment, and it is how the webhook later reconciles back to your order (see Webhooks). A typical checkout persists it immediately:$order->update([ 'gateway' => 'stripe', 'gateway_payment_id' => $result->id, ]);
Reconciliation
Webhooks can be lost (downtime, deploys, secret rotation). retrieve() pulls
the authoritative state straight from the gateway, so you can reconcile orders
stuck in a non-final state:
$result = Gateway::driver('stripe')->retrieve($order->gateway_payment_id); if ($result->status === PaymentStatus::Paid) { $order->markPaid(); }
retrieve() also returns $reference, $amount, and $currency when the
gateway exposes them, so you can verify a reconciled payment exactly as you
would a webhook.
For the common case — re-emit the canonical event so your existing webhook
listeners fire — use the ReconcilePayment job or the command:
php artisan payment:reconcile stripe pi_xxx # queued (default) php artisan payment:reconcile stripe pi_xxx --sync # inline
use Syriable\Payments\Jobs\ReconcilePayment; ReconcilePayment::dispatch('stripe', $order->gateway_payment_id);
The package doesn't own your orders table, so iterate your own non-final
orders on a schedule and reconcile each. Terminal states re-dispatch
PaymentSucceeded / PaymentFailed / PaymentRefunded; non-terminal states
emit nothing.
Refunds
Refunds are an opt-in capability. Check for it with instanceof — the type system tells you whether a gateway supports it:
use Syriable\Payments\Contracts\Refundable; $gateway = Gateway::driver('stripe'); if ($gateway instanceof Refundable) { $gateway->refund($paymentId); // full refund $gateway->refund($paymentId, 1000); // partial — 1000 minor units }
Webhooks
The package registers one webhook route automatically:
POST /payment-gateways/webhook/{gateway}
Register that URL in each provider's dashboard. The controller verifies the request's signature, then dispatches one of three events. You listen for them in your own application:
use Syriable\Payments\Events\PaymentSucceeded; Event::listen(PaymentSucceeded::class, function (PaymentSucceeded $event) { // $event->event is a normalized WebhookEvent. Reconcile on ->reference // (your own checkout reference, echoed back by the gateway) rather than // ->paymentId: the gateway id can point at different objects across // events (a Stripe session id vs. a payment intent id). $order = Order::where('reference', $event->event->reference)->first(); // Always verify the amount before fulfilling. if ($order && $event->event->amount === $order->total_minor && $event->event->currency === $order->currency) { $order->markPaid(); } });
Events: PaymentSucceeded, PaymentFailed, PaymentRefunded. Each carries a
normalized WebhookEvent with $gateway, $type, $paymentId, $reference,
$amount, $currency, $eventId, and the full verified $payload.
The controller verifies the signature, persists the event, drops duplicates,
and acknowledges immediately; the events are dispatched from a queued
ProcessWebhookEvent job so a slow listener can't make the gateway time out
and retry. Point it at a real queue with webhook.connection /
webhook.queue (defaults to the application's queue).
Verified webhooks are persisted to the payment_webhook_calls table before
processing (status pending → processed/failed), giving you a durable,
auditable record and a place to replay from. Persistence is swappable via the
webhook.store config key — the default is
Store\DatabaseWebhookStore; ship your own Contracts\WebhookStore, or use
Store\NullWebhookStore to disable it.
Invalid signatures return 403 and dispatch nothing. Unknown gateways return
404.
Make your listener idempotent. Gateways legitimately deliver the same webhook more than once. Guard against double-processing — e.g. skip the handler if the order is already marked paid.
Keep the webhook route off the
webmiddleware group.webenables CSRF protection, which rejects server-to-server webhook requests. The default config usesapi; changewebhook.middlewareonly to something equally CSRF-free.
Observability
The package logs the money-movement boundaries — payments.checkout.created,
payments.refund.issued, payments.webhook.received, payments.webhook.duplicate,
and payments.webhook.invalid_signature — with ids, references, and amounts
(never secrets or full payloads). Route them to their own channel:
PAYMENT_LOG_CHANNEL=payments
Leave it unset to use the application's default channel.
Adding a custom gateway
Two ways. For a one-off, register it in AppServiceProvider::boot():
use Syriable\Payments\Facades\Gateway; Gateway::extend('paymob', fn ($app) => new \App\Payments\PaymobGateway( config('payment-gateways.gateways.paymob') ));
For something reusable, ship it as its own Composer package. A gateway plugin is just a package whose service provider calls Gateway::extend():
namespace Vendor\LaravelPaymentsPaymob; use Illuminate\Support\ServiceProvider; use Syriable\Payments\Facades\Gateway; class PaymobServiceProvider extends ServiceProvider { public function boot(): void { Gateway::extend('paymob', fn ($app) => new PaymobGateway( config('payment-gateways.gateways.paymob', []) )); } }
Your gateway class implements the Gateway contract — and, optionally, Refundable:
use Syriable\Payments\Contracts\Gateway; use Syriable\Payments\Contracts\Refundable; final class PaymobGateway implements Gateway, Refundable { public function name(): string { /* ... */ } public function checkout(Checkout $checkout): PaymentResult { /* ... */ } public function retrieve(string $paymentId): PaymentResult { /* ... */ } public function webhook(Request $request): WebhookEvent { /* ... */ } public function refund(string $paymentId, ?int $amount = null): PaymentResult { /* ... */ } }
That's the entire plugin API. No plugin interface, no registry, no manifest.
Testing
Swap in a fake gateway with one call. No HTTP, no real charges:
use Syriable\Payments\Facades\Gateway; it('checks the customer out', function () { $fake = Gateway::fake(); $this->post('/checkout', ['order' => 42]); $fake->assertCheckedOut(fn ($checkout) => $checkout->amount === 2500); });
Available assertions: assertCheckedOut(), assertRefunded(), assertNothingCharged(), assertCheckoutCount().
Configuration
The published config/payment-gateways.php is intentionally small:
return [ 'default' => env('PAYMENT_GATEWAY', 'stripe'), 'webhook' => [ 'enabled' => true, 'prefix' => 'payment-gateways', 'middleware' => ['api'], 'store' => Syriable\Payments\Store\DatabaseWebhookStore::class, ], 'gateways' => [ 'stripe' => [ 'secret' => env('STRIPE_SECRET'), 'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'), ], 'paypal' => [ 'mode' => env('PAYPAL_MODE', 'sandbox'), 'client_id' => env('PAYPAL_CLIENT_ID'), 'client_secret' => env('PAYPAL_CLIENT_SECRET'), 'webhook_id' => env('PAYPAL_WEBHOOK_ID'), ], ], ];
Architecture at a glance
src/
├── Console/ ReconcilePaymentCommand
├── Contracts/ Gateway, Refundable, WebhookStore
├── Data/ Checkout, PaymentResult, WebhookEvent (readonly DTOs)
├── Enums/ PaymentStatus, WebhookEventType, WebhookCallStatus
├── Events/ PaymentSucceeded, PaymentFailed, PaymentRefunded
├── Exceptions/ PaymentException + 4 specific subclasses
├── Gateways/
│ ├── Concerns/ ResilientHttp
│ ├── Stripe/ StripeGateway
│ └── PayPal/ PayPalGateway
├── Http/ WebhookController
├── Jobs/ ProcessWebhookEvent, ReconcilePayment
├── Models/ WebhookCall
├── Store/ DatabaseWebhookStore, NullWebhookStore
├── Support/ PaymentLog
├── Testing/ FakeGateway, FakeGatewayManager
├── Facades/ Gateway
├── GatewayManager.php
└── PaymentsServiceProvider.php
Running the package test suite
composer test
Static analysis and code style:
composer analyse # PHPStan / Larastan composer format # Laravel Pint
Changelog
Please see CHANGELOG.md for details on what has changed recently.
Security
If you discover a security vulnerability, please email security@syriable.dev rather than using the issue tracker.
Webhook handlers verify signatures before parsing — Stripe via HMAC-SHA256,
PayPal via its verification API. A failed verification returns 403 and
dispatches no events.
Credits
License
The MIT License (MIT). See LICENSE.md.