paymentsuite/payment-core-bundle
Core payment bundle for PaymentSuite that provides shared abstractions, payment method handling, and integration points for PHP payment workflows. It centralizes common payment logic so gateways and checkout flows can plug in consistently, avoid duplicated code, and stay modular and reusable.
What you're installing. paymentsuite/payment-core-bundle is not a payment gateway. It's the shared domain layer of the PaymentSuite family: payment-method abstraction, order bridging, the payment dispatcher/events, and the DI plumbing that lets gateway bundles (payments-redsys, payments-stripe, payments-paypal, …) plug in. You install it once, then install one or more method bundles on top. If you're not using (or maintaining) a PaymentSuite-based app, you don't want this package — see Gotchas.
Prerequisites. Symfony 2.3–2.8-era skeleton (AppKernel, app/config/config.yml), PHP 5.x-compatible runtime, Composer. There is no Symfony 3/4/5 support in the 2014 release line; expect a fork-and-patch job if you need to run it on anything modern.
Install and register.
composer require paymentsuite/payment-core-bundle
// app/AppKernel.php
$bundles = [
// ...
new PaymentSuite\PaymentCoreBundle\PaymentCoreBundle(),
// gateway bundles go after it
new PaymentSuite\Payments\RedSysBundle\PaymentsRedSysBundle(),
];
# app/config/config.yml
payment_core:
currency: EUR
The bundle itself ships no routes — the HTTP flow (execute/callback controllers) lives in PaymentWebBundle, and each gateway bundle ships its own routing. Import only what you install.
Where to look first, in this order:
PaymentSuite\PaymentCoreBundle\PaymentMethodInterface — the contract every gateway implements (getPaymentName(), option getters/setters).PaymentSuite\PaymentCoreBundle\PaymentMethodWrapper — the object you actually call setOrder()/setAmount()/setCurrency() on before processing.Order\OrderBridgeInterface and Order\PaymentBridgeInterface — the two interfaces your app's entities must implement.Services\PaymentMethodManager (loadPaymentMethod()) and Services\PaymentDispatcher (fires PaymentEvents::PAYMENT_SUCCESS / PAYMENT_FAILED / PAYMENT_PREPARED).DependencyInjection\Compiler\RegisterPaymentMethodsPass — explains why a gateway that "isn't there" really isn't registered.First end-to-end use case. Wire your Order entity to the core, load a method by name, hand it the order and amount, dispatch it:
// src/AppBundle/Entity/Order.php
use PaymentSuite\PaymentCoreBundle\Order\OrderBridgeInterface;
use PaymentSuite\PaymentCoreBundle\Order\PaymentBridgeInterface;
class Order implements OrderBridgeInterface
{
/** @var Payment */
private $payment;
public function getPaymentBridge(): PaymentBridgeInterface
{
return $this->payment;
}
public function setPaymentBridge(PaymentBridgeInterface $payment)
{
$this->payment = $payment;
return $this;
}
}
// in a controller or your own CheckoutService
use PaymentSuite\PaymentCoreBundle\Exception\PaymentException;
$method = $this->get('paymentsuite.payment.method.manager')
->loadPaymentMethod('redsys'); // alias registered by the gateway bundle
$method
->setOrder($order)
->setAmount(1999) // minor units — 19.99 EUR
->setCurrency('EUR');
try {
$this->get('paymentsuite.payment.manager')->processPayment($method);
} catch (PaymentException $e) {
// gateway rejected / misconfigured
}
processPayment() produces the gateway response (usually a redirect or a form). The gateway's own callback controller later re-enters the stack and fires the success/failure event, which is where your order actually gets marked paid.
Bridge, don't inherit. PaymentCore never touches your Order directly — it goes through OrderBridgeInterface::getPaymentBridge(). Keep the bridge as a thin, dumb entity (Payment with transaction_id, amount, status, method, raw_response). No business logic there; it's ORM-coupled and painful to unit test.
Method-agnostic application code. Everything above the facade should depend only on PaymentMethodInterface. In practice you write one small wrapper service and never touch PaymentCore from controllers again:
class PaymentProcessor
{
public function __construct($methodManager, $paymentManager, $orderManager) { ... }
public function start(Order $order, string $gateway): PaymentMethodInterface
{
$method = $this->methodManager->loadPaymentMethod($gateway);
$method->setOrder($order)->setAmount($order->getTotalCents())->setCurrency('EUR');
$this->paymentManager->processPayment($method);
return $method;
}
}
Swapping RedSys for Stripe then becomes a config change plus one bundle, not a refactor.
Event listeners are where the domain happens. The core is deliberately silent about what "paid" means. Register listeners and keep them idempotent:
// services.yml
app.payment_listener:
class: AppBundle\EventListener\PaymentListener
tags:
- { name: kernel.event_listener, event: payment.success, method: onSuccess }
- { name: kernel.event_listener, event: payment.failed, method: onFailed }
// PaymentListener
public function onSuccess(PaymentEvent $event)
{
$order = $this->orderManager->getOrder(); // or pull from the event's bridge
if ($order->isPaid()) {
return; // IPN replays are normal
}
$order->setPaidAt(new \DateTime());
$this->orderManager->orderPaid($order);
$this->invoiceService->generate($order);
$this->mailer->sendConfirmation($order);
}
Use the same listener for stock release, coupon consumption, and analytics. One listener per concern, all on the same event, keeps them independently testable.
Selection at runtime. loadPaymentMethod($alias) is keyed by the tag alias each gateway bundle registers. Build a config-driven allowlist (payment_methods: [redsys, stripe]) and validate against $methodManager->getAvailableMethods() (or by catching the load exception) rather than letting users post arbitrary aliases.
Testing. Don't boot gateways in tests. Either build a fake PaymentMethodInterface implementation, or register a stub service under the paymentsuite.payment_method tag with your test alias. Test listeners by constructing PaymentEvent with a mocked wrapper — they're plain PHP objects, no container needed.
Integration tips.
Payment row per attempt, not per order; you want an audit trail when a customer retries with a different card or gateway.processPayment() — most gateway callbacks arrive out-of-band with no session.It is abandoned. Last release April 2014, 25 stars, zero dependents, Symfony 2 only. There are no upstream security patches, no PHP 8 compatibility (old DI conventions, no return types, @expectedException-era tests), and no PSR-4/composer.json cleanup done for you. Practical stance: vendor-fork it, pin the fork, and budget a migration. For new projects use Payum, symfony/workflow + a PSP SDK, or Stripe/Adyen's own libraries.
Service IDs are version-dependent. Documentation and blog posts disagree on names (paymentsuite.payment.manager vs payment.manager, payment.method.manager vs paymentsuite.payment.method.manager). Never trust a snippet — confirm with php app/console container:debug | grep -i payment (Symfony 2.x) and read the bundle's Resources/config/services.yml.
"Method not found" usually means the compiler pass didn't see it. RegisterPaymentMethodsPass collects services tagged paymentsuite.payment_method. If loadPaymentMethod('foo') throws, check three things in order: the gateway bundle is registered in AppKernel after PaymentCoreBundle; the tag has the correct alias attribute; and the gateway's own extension was loaded (its config block must be present in config.yml, even if empty).
Events are not guaranteed by the core. PaymentDispatcher only fires what the concrete gateway calls. Some gateway bundles dispatch on the callback controller, some inside processPayment(), some only in their success controller. Read the gateway bundle's code — a missing payment.success dispatch is the classic "order stays pending forever" bug.
Double-dispatch on IPN/webhook endpoints. Gateways retry notifications. There's no idempotency layer in PaymentCore; you must guard in your listener (unique index on transaction_id, or an isPaid() early return). Test this by invoking the callback URL twice.
Currency lives in payment_core, amount lives in the wrapper. They're set independently, and a mismatch (currency: EUR config, setCurrency('USD') on the wrapper) will surface as an opaque gateway error, not a validation error. Pick one source of truth — config — and don't pass currency per call.
Amount units are minor units. setAmount(1999) means 19.99. Passing a float from $order->getTotal() silently produces 100x charges. Keep a getTotalCents(): int on your order.
OrderManager may be session-backed. Several 1.x builds keep the current order in the session rather than re-fetching by ID. That breaks in multi-tab checkouts, after session GC, and in headless callback flows. If you hit it, don't fight it — resolve the order from your own repository in the listener via the transaction ID on the bridge.
Bridges are read by listeners after the fact. getPaymentBridge() must return a persisted entity before the success event fires, or invoice generation and order updates will operate on nulls. Flush after processPayment(), not before the listener runs.
Debugging. The core logs almost nothing. Add one listener per event that logs paymentName, amount, order ID, and a correlation ID; that single addition cuts most PaymentSuite support time by half. Enable the Symfony profiler around the callback to inspect the request the gateway actually sent.
Extension points worth knowing. Implement PaymentMethodInterface + a tagged factory service for an in-house gateway; decorate PaymentMethodManager to add logging or method allowlisting; listen to PAYMENT_PREPARED if you need to snapshot the order before it's sent to the PSP.
Don't put logic in the bridge entities and don't let controllers call PaymentDispatcher directly — always go through the gateway's flow so event ordering stays consistent with what the gateway bundle expects.
How can I help you explore Laravel packages today?