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: 2

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0

v1.0.0 2026-05-22 21:25 UTC

This package is auto-updated.

Last update: 2026-05-22 21:26:52 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 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 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"

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. 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,
]);

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 pendingprocessed/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 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.

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.