paymentsuite/free-payment-bundle
Adds a free payment method to PaymentSuite, letting Symfony/PHP e-commerce apps complete zero-total orders without touching a real gateway. Lightweight and easy to configure, it's a handy fallback for testing, promotions, or fully discounted carts.
Read this first: it is not a Laravel package. paymentsuite/free-payment-bundle is a read-only subtree split of the FreePayment component from the PaymentSuite monorepo. There is no ServiceProvider, no config/paymentsuite.php, and nothing for package discovery to find. What you actually get is roughly three files of real logic: a PaymentMethodInterface implementation, a controller, and a DI extension + routing. For a Laravel shop, the value is the pattern, not the dependency.
Read in this order before writing any code:
README.md — it points back to PaymentSuite/PaymentSuite; issues and PRs do not live here.PaymentMethod/FreePaymentMethod.php — the entire point: it returns a PaymentMethodView that redirects to its own execute route instead of an external gateway.DependencyInjection/Configuration.php — usually just an enabled flag under the paymentsuite tree.Resources/config/routing.* — route names are namespaced paymentsuite_free_payment_*; check them before wiring redirects.Check installability in seconds, then stop:
composer why-not paymentsuite/free-payment-bundle
If it demands --ignore-platform-reqs or pulls symfony/http-kernel into your app, that is your kill signal. Do not bridge a Symfony bundle into Laravel via a shim — the DI + event-dispatcher graph costs more than the behavior is worth.
Minimal Laravel port (this is the actual "getting started" for a Laravel team):
// config/payment.php
return [
'methods' => [
'free' => ['enabled' => env('PAYMENT_FREE_ENABLED', false)],
],
];
// app/Payments/FreePaymentMethod.php
final class FreePaymentMethod implements PaymentMethod
{
public function process(Order $order): PaymentResult
{
abort_unless(config('payment.methods.free.enabled'), 404);
abort_unless($order->total()->isZero(), 422, 'Free method is zero-amount only.');
return PaymentResult::success($order, provider: 'free');
}
}
First use case: a zero-total cart — free product, 100%-off coupon, trial signup, comped B2B seat, or a dev/staging checkout — where you want the existing order state machine to run without touching a gateway.
1. Staging/test checkout driver (highest day-to-day value). Enable it only in local/testing so feature tests drive the full checkout path without HTTP fakes:
$this->post('/checkout')->assertRedirect('/orders/1/complete');
$this->assertDatabaseHas('orders', [
'id' => 1,
'status' => 'paid',
'payment_method' => 'free',
]);
2. Explicit zero-total branch — you decide, not the package. The bundle just accepts; it does not choose when it applies. Put the decision in a resolver, not scattered conditionals:
final class PaymentMethodResolver
{
public function resolve(Order $order): PaymentMethod
{
if ($order->total()->isZero() && config('payment.methods.free.enabled')) {
return app(FreePaymentMethod::class);
}
return app($order->preferredGateway());
}
}
3. Never expose it as a user-selectable radio button. Gate it server-side: either the order total is zero (computed by your code, not a request parameter) or the caller is an admin route. A "free" method in the public method list is a revenue bypass waiting to happen.
4. Emit the same domain events as a real gateway. Downstream listeners (receipts, entitlements, provisioning) should not care which provider ran:
PaymentSucceeded::dispatch($order, transaction: Transaction::synthetic(
orderId: $order->id,
provider: 'free',
amount: 0,
));
5. Idempotency on retry. Zero-amount orders still get double-submitted (back button, retried job). Guard on state, not on the gateway:
if ($order->status === OrderStatus::Paid) {
return PaymentResult::alreadyPaid($order);
}
6. Admin/comped flow. For B2B comped seats and manually approved carts, expose an admin action that flips the order to the free path rather than letting the customer pick it:
Route::post('/admin/orders/{order}/comp', CompOrderController::class)
->middleware(['auth', 'can:comp,order']);
7. Observability from day one. Every invocation logs order ID, resolved amount, and actor, so a finance reconciliation gap is traceable:
Log::info('free_payment.invoked', [
'order_id' => $order->id,
'amount' => $order->total()->minorUnits(),
'user_id' => auth()->id(),
'source' => $source, // 'checkout' | 'admin'
]);
8. Parallel run in staging. Keep your existing paid gateways wired and selectable; free sits alongside behind a flag. Run both through the same contract tests so the checkout UI, emails, and fulfilment paths are provider-agnostic.
9. Port the semantics, not the code. The reusable asset is the state-transition shape: an order is marked paid with a synthetic transaction, events fire, and the wrapper exposes the result. Reproduce that shape against your own PaymentMethod contract; do not copy the Symfony service tags.
PAYMENT_FREE_ENABLED=false, set it explicitly per environment, and add a deploy-time assertion that production has it off unless intentionally on.--ignore-platform-reqs is a trap. If you needed it once, it becomes permanent and masks every other version conflict in your lock file. Prefer the port over the override.enabled flag. If you need per-customer eligibility, amount caps, or audit trails, those belong in your own resolver and policy classes around the gateway, not inside it.paymentsuite_free_payment_execute) and routing import first — a missing route import looks identical to a failed payment in logs.How can I help you explore Laravel packages today?