payum/payum-bundle
PayumBundle integrates Payum payment processing into Symfony, supporting 35+ gateways with YAML configuration, storage integration, and a secure capture controller. It powers payments for Sylius and works with any Symfony app.
Do not install this in a Laravel app. payum/payum-bundle is Symfony-only glue: it registers DI services, compiler passes, routes via Symfony routing, a Twig form type (CreditCardType), and a profiler collector. Nothing in it is usable outside symfony/framework-bundle. What you actually want is the framework-agnostic engine underneath it, payum/core, plus one gateway package.
composer require payum/core payum/stripe # or payum/paypal-express-checkout-nvp, payum/authorize-net, etc.
# NOT payum/payum-bundle
Read in this order:
storages, security.token_storage, gateways. Mentally translate that YAML into a Laravel config array.payum.gitbook.io/payum — Get it started and Core concepts. The Symfony page is the closest thing to a config reference; everything below the config layer is portable.Payum\Core\Model\Payment, Payum\Core\Request\Capture, Payum\Core\Request\GetHumanStatus — read those four classes and you understand 80% of the API.makasim/PayumBundleSandbox — useful purely to see the request/response flow (capture → redirect → done → status), even though the code is Symfony.First use case — prove the loop with offline. Before touching a real PSP, wire the offline gateway and a payments table, execute a Capture, then a GetHumanStatus. This forces you to solve the two things that will bite you later (redirect replies and persistence) with zero credentials and zero network.
$payment = Payment::create(['number' => uniqid(), 'total_amount' => 123, 'currency_code' => 'EUR']);
$gateway->execute(new Capture($payment)); // offline: no redirect, just flips details
$gateway->execute($status = new GetHumanStatus($payment));
$status->getValue(); // 'captured'
Then swap the config for a real factory and the same two lines still work. That is the whole selling point of the package.
PayumBuilder is the DI-free equivalent of the bundle's payum.yaml. Bind the result as a singleton so token storage and gateway config are shared.
// app/Providers/PayumServiceProvider.php
$this->app->singleton(Payum::class, function () {
return (new PayumBuilder())
->addDefaultStorages() // filesystem storage for Payment/Token — dev only
->addGateway('offline', ['factory' => 'offline'])
->addGateway('stripe', [
'factory' => 'stripe_checkout',
'publishable_key' => config('services.stripe.key'),
'secret_key' => config('services.stripe.secret'),
])
->setTokenStorage(new EloquentTokenStorage())
->getPayum();
});
Expose a thin PaymentGateway service (capture(), status(), refund()) wrapping $payum->getGateway($name) — never let GatewayInterface leak into controllers. It makes the later swap to a fake gateway trivial.
The bundle's filesystem storage is a demo affordance. In Laravel, back it with a payments table where Payum's mutable blob lives in a json column:
class Payment extends Model implements \Payum\Core\Model\PaymentInterface {
use \Payum\Core\Model\PaymentTrait; // provides getDetails/setDetails etc.
protected $casts = ['details' => 'array'];
protected $fillable = ['number','total_amount','currency_code','description','client_email','details'];
}
Implement Payum\Core\Storage\StorageInterface (support, find, updateModel, deleteModel) — extending AbstractStorage gives you the last two. Two methods of real work.
Capture does not return anything. It either mutates details or throws a reply exception carrying the next step. Translate replies into HTTP:
try {
$gateway->execute(new Capture($payment));
return redirect()->route('checkout.done', $payment); // offline / no-op
} catch (HttpRedirect $e) {
return redirect()->away($e->getUrl()); // off-site PSP
} catch (HttpResponse $e) { // on-site form / 3DS
return response($e->getContent(), $e->getStatusCode());
} finally {
$payment->save(); // ALWAYS persist details
}
ReplyToReplyException is the escape hatch when an action needs to run after the browser comes back — new ForceReply(new Capture($payment)).
Both are the same three steps: verify the token, re-execute the request, recompute status.
?token=…): use TokenFactory::createToken() to build return URLs, and an HttpRequestVerifier to validate them. Tokens must be single-use — delete after verify.NotifyUnsafe/Notify and treat it as untrusted — the whole point of the token is preventing a stranger from forcing a capture.200 fast; do the bookkeeping in a queued job keyed on payment_id.GetHumanStatus maps gateway details to new | pending | authorized | captured | refunded | canceled | failed | expired | unknown. Run it on read (a PaymentStatus accessor or a small status() method) rather than denormalising into a column — the mapping changes with gateway versions and a stale column will silently lie to you. Treat unknown as "look at the raw details", not as a failure.
Authorize, Refund, Cancel, Payout are distinct request classes; not every factory ships actions for all of them. Check the gateway's docs package before you promise refunds in a UI. Compose a supports() helper on your service around the factory name.
HttpRedirect, HttpResponse, HttpPostRedirect all extend Payum\Core\Reply\Reply, which extends \Exception. Any execute() call without a try/catch that returns a 500 in production is a missing catch, not a gateway bug. Wrap it once in a helper and it disappears forever.catch. Eloquent's dirty tracking misses in-place array mutation — reassign ($payment->details = $payment->details) or save() unconditionally in finally.Token and id_property. If you keep filesystem storage for tokens in dev, id_property must match the model field your code reads back. In production use a DB-backed token storage with a TTL; tokens are bearer credentials for a capture.HttpRequestVerifier is not in core any more. It moved out of Payum\Core\Bridge\Symfony into the bundle in 2.6.0 — so in Laravel you implement HttpRequestVerifierInterface yourself (read the token query param, look it up, Token::getTargetUrl()), or feed it a PSR-7 request via nyholm/psr7.123 means €1.23. Never let a float near it. The bundle once shipped an iso4217 service for this; it was removed in 2.1.0 — use moneyphp/money or a plain integer cast.UPGRADE-3.0.md.payum/*-gateway packages lag. Run composer why-not before upgrading PHP on a payments codebase — a broken gateway package is not a hotfix you want mid-sprint.payum/payum is a meta-package. Require payum/core plus only the gateways you actually use; the all-in-one pulls a large tree of abandoned PSP SDKs that will fail security audits and PHP upgrades.PayumCollector) is Symfony-only. Replace it with a decorator over GatewayInterface that logs each request class, the factory name, and elapsed time, and behind a config flag dumps details before/after. That is the single highest-value 30 lines you will write against this package.Payum\Core\Action\ActionInterface (with supports() / execute()) and register via PayumBuilder::addAction(), plus addGatewayFactory() for a custom factory. This is how you inject idempotency keys, audit rows, or your own notification dispatch without forking a gateway.CreditCardType / CreditCardExpirationDateType forms and ObtainCreditCardAction assume Symfony forms and a session-stashed ObtainCreditCard reply. In Laravel you rebuild this in Blade/Livewire and implement the reply handling — budget for it; it is the largest chunk of the bundle you lose.offline and the sandbox are for CI, not staging demos. Wire an offline gateway in config/payum.php under app()->environment('testing') so feature tests can assert status transitions without mocks.PaymentBundle integration is a far better reference than the bundle's own docs.How can I help you explore Laravel packages today?