kitano/payment-bundle
Symfony bundle that brings payment processing into your app, wrapping multiple payment gateways behind a shared service interface so you can configure providers, create payments, and handle callbacks and status updates with minimal boilerplate.
Reality check before you composer require: this is a Symfony bundle, not a Laravel package. There is no service provider, no config/payment.php, no facade, and no adapters for Laravel's container, HTTP layer, or Eloquent. You cannot composer require kitano/payment-bundle into a Laravel app and start taking payments. Your three choices are: (1) run it in a Symfony app, (2) treat the repo strictly as a reference implementation of a payment-flow abstraction, or (3) budget a port. For a Laravel team, (2) is the realistic default.
If you are actually on Symfony:
composer require kitano/payment-bundlePaymentInstruction, PaymentSystemInterface, PaymentBridgeInterface, the flow controller, and the state model. Without one of KitanoPaymentSipsBundle (Atos/SIPS), KitanoPaymentFakeBundle, or your own adapter, nothing processes a payment.Kitano\PaymentBundle\KitanoPaymentBundle and the provider bundle in AppKernel.PaymentInstruction Doctrine entity (check Resources/config/doctrine for the mapping).KitanoPaymentFakeBundle. It gives you a working capture → redirect → return → complete loop with no acquirer credentials, so you can learn the flow and build a test harness before touching real money.Where to look first, in order:
PaymentSystemInterface — the contract every provider implements (capture, complete, status translation). Read this before anything else; it is the whole abstraction in one file.PaymentInstruction — the persisted state machine (amount, currency, state, extended_data).PaymentInstructionManager — persistence and state transitions.PaymentBridgeInterface — how the flow hands off to the system.First use case for a Laravel developer: don't try to run the bundle. Mirror PaymentSystemInterface as your own internal contract and stand up a Fake adapter behind it, end-to-end, before choosing a real gateway. That gets you the one genuinely valuable thing this package offers — a provider-agnostic payment lifecycle — without inheriting a Symfony 2.x-era dependency you'll have to own forever.
// Your Laravel-native equivalent, ported from the bundle's concept
interface PaymentSystem
{
public function capture(PaymentInstruction $instruction): Response;
public function complete(PaymentInstruction $instruction, Request $request): void;
public function translateStatus(string $gatewayStatus): string;
}
Instruction-as-state-machine. Every payment is a persisted record holding amount, currency, state (new → pending → completed / failed / canceled), and a free-form extended_data blob. This is the pattern worth stealing. Persist the instruction before redirecting to the gateway so a callback always has something to reconcile against:
$instruction = PaymentInstruction::create([
'amount' => 1999,
'currency' => 'EUR',
'state' => 'new',
'extended_data' => ['order_id' => $order->id],
]);
return $paymentSystem->capture($instruction); // HTTP redirect to gateway
Capture → complete; never trust the return URL. Your callback controller takes the gateway response, calls complete(), and the system updates state and fires events. Bind listeners to the terminal events rather than branching on $instruction->state in the controller — state checks in controllers drift the moment you add a second provider:
Event::listen(PaymentCompleted::class, fn ($e) => $e->getInstruction()->order->markPaid());
Event::listen(PaymentFailed::class, fn ($e) => $e->getInstruction()->order->releaseStock());
Gateway-specific payloads go in extended_data. Acquirer transaction IDs, 3DS blobs, card fingerprints, the raw callback body — none of that earns a column. Keep it in the serialized blob; it survives state transitions and is readable from your translateStatus() implementation.
Verify amount and currency on return. The gateway echoes back an amount; compare it against the stored instruction before completing. Historically this relied on the system bridge doing it — if you write your own bridge, do it explicitly and loudly.
Idempotent callbacks. A gateway will retry. If the instruction is already completed, the complete() path must no-op and return 200, not re-fire payment.completed and not re-capture. Make that a first-class test, not an afterthought.
Testing workflow. Use the Fake bundle as the reference for your own integration test: assert new → completed on the return route, assert a failure status maps to failed, and assert a replayed callback is a no-op. This is the single most reusable artifact in the repo.
Custom system for a real acquirer. Implement PaymentSystemInterface: capture() returns a redirect; complete() reads the return request, validates the signature (HMAC, SIPS seal, whatever the acquirer uses), calls translateStatus(), and updates the instruction. Register it in your own container binding, and keep it behind your internal interface so the acquirer is swappable.
PaymentInstruction states and the acquirer's ledger, drift is silent. Schedule a nightly comparison against the gateway's transaction report.translateStatus() must be exhaustive. Handle every status the acquirer can return, including ones you've never seen — default to a safe failed/pending rather than throwing or silently passing through.extended_data is a serialized blob. Treat its format as an internal contract; if you fork, note that changing serialization breaks historical records. Don't rename keys on live instructions.league/omnipay rather than resurrecting a stale orchestrator.How can I help you explore Laravel packages today?