NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Shops for Laravel

Payments & shipping

Payment and shipping are driver contracts; the package ships no vendor SDK. The defaults work zero-config — a null gateway that always succeeds, and free shipping. Bind your own implementation for production.

Payment gateway

use RoundlyConsulting\Money\Money;
use RoundlyConsulting\Shops\Contracts\PaymentGateway;
use RoundlyConsulting\Shops\Orders\Order;
use RoundlyConsulting\Shops\Payments\PaymentResult;

final class AcmePayGateway implements PaymentGateway
{
    public function charge(Order $order): PaymentResult
    {
        $due = $order->gatewayAmount();   // total minus store credit — never getFinalPrice()

        // … charge $due->minor() in $due->currency()->code with your provider …

        return PaymentResult::success($due, reference: $chargeId);
    }

    public function refund(Order $order, Money $amount): PaymentResult
    {
        // … refund with your provider …

        return PaymentResult::success($amount, reference: $refundId);
    }
}
// config/shops.php
'payment' => [
    'gateway' => AcmePayGateway::class,
    // ...
],
'shipping' => [
    'method' => FlatRateShipping::class,
],

// Or bind the contract in a service provider — a later binding wins:
$this->app->bind(\RoundlyConsulting\Shops\Contracts\PaymentGateway::class, AcmePayGateway::class);

Charging

use RoundlyConsulting\Shops\Facades\Shops;

$result = Shops::order($order)->charge();   // action form: Actions\Orders\ChargeOrderAction

if (! $result->successful) {
    return back()->withErrors(['payment' => $result->message]);
}

// The order is now Paid — do not call markPaid() again.
$result->amount;      // Money charged through the gateway
$result->reference;   // your provider's reference
  • Only a New or InProgress order is charged, decided under the order’s row lock before any store credit or gateway call: charging a Paid, Fulfilled, Canceled or Refunded order — a double-clicked “Pay”, or a second request holding a stale copy — throws IllegalStatusTransitionException with nothing charged.
  • When store credit is enabled and not yet applied, and the customer is Creditable, credit is debited first.
  • A zero balance (credit covered the order, or it is free) skips the gateway and succeeds with a zero amount.
  • Otherwise the gateway’s charge() runs. It receives the Order and must charge $order->gatewayAmount() — never the final price, which would charge the credit share twice.
  • On success the order moves New → InProgress (if still New) → Paid. The whole charge — credit, gateway call, move to Paid — runs in one transaction holding the order’s lock, so a concurrent second charge waits and is then refused. Keep your gateway’s charge() to the payment call itself; OrderPaid listeners run after the commit.
  • A failed (declined) charge leaves the status unchanged and keeps no store credit. The amount charged includes the order’s snapshotted shipping.
use RoundlyConsulting\Shops\Payments\PaymentResult;

new PaymentResult(bool $successful, Money $amount, ?string $reference = null, ?string $message = null);

PaymentResult::success($amount, reference: 'ch_123');
PaymentResult::failure($amount, message: 'Card declined');

Refunds

Refunds are host-driven by design: the package never calls the gateway’s refund(). You decide the amount (at most $order->gatewayAmount()) and whether money goes back to the card at all — then transition the order:

use RoundlyConsulting\Shops\Contracts\PaymentGateway;
use RoundlyConsulting\Shops\Facades\Shops;

// The gateway only took gatewayAmount() — the store-credit share never went through it.
$result = app(PaymentGateway::class)->refund($order, $order->gatewayAmount());

if ($result->successful) {
    Shops::order($order)->refund();   // Paid|Fulfilled → Refunded, fires OrderRefunded
}

// Give back any store-credit share yourself (when refund_to_store_credit is off):
$customer->modifyCreditsMoney($order->store_credit_applied, bucket: 'store_credit');

With payment.refund_to_store_credit on, skip the gateway refund — Shops::order($order)->refund() alone credits the whole order total back as store credit, and doing both refunds the buyer twice.

Shipping

A ShippingMethod quotes a cost for an order and a destination. The default FreeShippingMethod quotes zero in the order currency with the label Free shipping:

use RoundlyConsulting\Money\Money;
use RoundlyConsulting\Shops\Contracts\ShippingMethod;
use RoundlyConsulting\Shops\Orders\DataTransferObjects\Address;
use RoundlyConsulting\Shops\Orders\Order;

final class FlatRateShipping implements ShippingMethod
{
    public function quote(Order $order, Address $destination): Money
    {
        return $destination->countryIso === 'DE'
            ? Money::ofMinor(490, $order->currency)
            : Money::ofMinor(1290, $order->currency);
    }

    public function label(): string
    {
        return 'Standard delivery';
    }
}
use RoundlyConsulting\Shops\Facades\Shops;

$cost = Shops::order($order)->quoteShipping($order->shipping_address);   // Money

// The action form: app(Actions\Orders\QuoteShippingAction::class)->execute($order, $address)

Checkout quotes the shipping address through the bound method and snapshots the result onto the order, where it is part of the final price and the charge — see Placing orders. quoteShipping() prices a destination on demand without changing the order.

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.