syriable/laravel-payments

A lightweight Laravel package for accepting payments through Stripe, PayPal, and an open ecosystem of community gateway plugins.

Maintainers

Package info

github.com/syriable/laravel-payments

pkg:composer/syriable/laravel-payments

Fund package maintenance!

laravel-payments

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.1.0 2026-05-20 13:20 UTC

This package is auto-updated.

Last update: 2026-05-20 18:41:48 UTC


README

Syriable Payments

Latest Version on Packagist Tests Total Downloads PHP Version License

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 behind Cache, Mail, and Storage. 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. 2500 means $25.00, 100 means $1.00. Never pass a float or a major-unit value like 25.00Checkout rejects non-positive amounts at construction, but it cannot tell 25 cents from 25 dollars. Keeping money as integers is what makes the package financially safe; no floating-point math touches an amount anywhere.

Store $result->id on 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 web middleware group. web enables CSRF protection, which rejects server-to-server webhook requests. The default config uses api; change webhook.middleware only 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.