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/contracts: ^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
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 never touches your database, and it never forces a schema on 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"
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 | Paid | Failed | Refunded $result->redirectUrl; // hosted-checkout URL, if any $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, ]);
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. // $event->event->paymentId is the *gateway's* id — the same value you // stored as gateway_payment_id when the checkout was created. Order::where('gateway', $event->event->gateway) ->where('gateway_payment_id', $event->event->paymentId) ->first() ?->markPaid(); });
Events: PaymentSucceeded, PaymentFailed, PaymentRefunded. Each carries a
normalized WebhookEvent with $gateway, $type, $paymentId, and the full
verified $payload.
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.
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 and/or Capturable:
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 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'], ], '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/
├── Contracts/ Gateway, Refundable, Capturable
├── Data/ Checkout, PaymentResult, WebhookEvent (readonly DTOs)
├── Enums/ PaymentStatus
├── Events/ PaymentSucceeded, PaymentFailed, PaymentRefunded
├── Exceptions/ PaymentException + 4 specific subclasses
├── Gateways/
│ ├── Stripe/ StripeGateway
│ └── PayPal/ PayPalGateway
├── Http/ WebhookController
├── 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.