ezsystems/payment-core-bundle
JMSPaymentCoreBundle provides a unified Symfony API for integrating multiple payment backends. It handles payment and transaction persistence, retries, sensitive-data encryption, and supports many providers out of the box.
Reality check first: this bundle is archived and targets Symfony 2.x/3.x (PHP 5.x–7.1 era). You should only adopt it to maintain or extend an existing app. For new work, the modern equivalents are Payum or league/omnipay. If you are on it, read the ReadTheDocs pages for the core bundle and whichever backend plugin you use (PayPal, AuthorizeNet, etc.) in parallel — the core API is thin, the backend plugin is where behaviour lives.
Install
composer require jms/payment-core-bundle
# plus one backend, e.g.
composer require jms/payment-paypal-bundle
Register in app/AppKernel.php:
new JMS\Payment\CoreBundle\JMSPaymentCoreBundle(),
new JMS\Payment\PaypalBundle\JMSPaymentPaypalBundle(), // or your backend
Create your persisted entities. The bundle ships mapped superclasses; you are expected to subclass them so you control the table names, indexes and any extra columns:
// src/Acme/PaymentBundle/Entity/PaymentInstruction.php
namespace Acme\PaymentBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
use JMS\Payment\CoreBundle\Entity\PaymentInstruction as BaseInstruction;
/**
* @ORM\Entity
* @ORM\Table(name="payment_instruction", indexes={@ORM\Index(columns={"created_at"})})
*/
class PaymentInstruction extends BaseInstruction
{
}
Do the same for Payment, Transaction, CreditCard (and StoredPaymentProfile/extended-data entities if you use recurring payments or extendedData), then point Doctrine at them so the bundle's classes resolve to yours:
# app/config/config.yml
doctrine:
orm:
resolve_target_entities:
JMS\Payment\CoreBundle\Entity\PaymentInstruction: Acme\PaymentBundle\Entity\PaymentInstruction
JMS\Payment\CoreBundle\Entity\Payment: Acme\PaymentBundle\Entity\Payment
JMS\Payment\CoreBundle\Entity\Transaction: Acme\PaymentBundle\Entity\Transaction
JMS\Payment\CoreBundle\Entity\CreditCard: Acme\PaymentBundle\Entity\CreditCard
First use case — "approve and deposit" after checkout. Everything goes through the plugin controller service, jms_payment_core.plugin_controller. It owns transaction creation, retry logic, and encryption, so never build Transaction objects by hand.
$ppc = $this->get('jms_payment_core.plugin_controller');
$instruction = $ppc->createPaymentInstruction(
(new PaymentInstruction())
->setAmount(49.99) // numeric string, not float arithmetic
->setCurrency('EUR')
->setPaymentSystemName('paypal_express_checkout')
->setExtendedData(['return_url' => $returnUrl])
);
// Usually a redirect here (plugin returns a response/redirect object)
$response = $ppc->approveAndDeposit($instruction->getPayment()->getId(), 49.99);
if ($response->isRedirect()) {
return $this->redirect($response->getRedirectUrl());
}
Then persist the instruction (it cascades to payment/transactions), store the instruction id in the session, and verify state on the return URL — see the flow in the docs' "Getting Started" example. Your first working end-to-end is: form → create instruction → approve/deposit → redirect → return controller → assert $payment->getState().
Redirect-based checkout (the 90% case).
// checkoutAction: create + persist instruction, remember id in session
$ppc->createPaymentInstruction($instruction);
$em->persist($instruction);
$em->flush();
$response = $ppc->approveAndDeposit($instruction->getPayment()->getId(), $instruction->getAmount());
// completeAction (return URL): reload from DB, never trust query params
$instruction = $ppc->getPaymentInstruction($request->query->get('instructionId'));
$payment = $instruction->getPayment();
if ($payment->getState() === Payment::STATE_APPROVED
&& $payment->getApprovingAmount() == $instruction->getAmount()) {
// mark order paid; idempotent — check your own order flag too
}
Note the plugin controller does not flush for you. Persist/flush after createPaymentInstruction, and flush again if you mutate state via the controller.
Resume the interrupted transaction. When the user abandons a redirect and comes back, don't create a second payment:
$pending = $ppc->getPendingTransaction($paymentId);
if ($pending) {
// re-fetch/replay the pending approval, or surface "payment not completed"
}
Retries. Retry is built into the controller's transaction logic (it inspects previous failed/dependent transactions). Use $ppc->approve() / $ppc->deposit() separately when the backend supports split authorize+capture (auth at checkout, capture at shipment). Use reverseApproval() / reverseDeposit() / credit() for refunds rather than issuing a new payment.
Recurring / stored profiles. If the backend supports it, call createRecurringPaymentProfile() with a CreditCard (or stored profile) and persist the returned profile id against your customer. Subsequent charges reference that profile — the bundle will not manage scheduling for you; put that in a cron/command.
Backend selection. paymentSystemName is a string matching the backend's alias. Configure the backend bundle with credentials, and drive the value from a form (ChoosePaymentMethodType with a payment_systems option builds a radio/select of available systems for you). For card data use the bundled CreditCardType form; the CreditCard entity is what gets encrypted.
Write your own backend plugin when a gateway isn't covered:
class AcmePlugin extends AbstractPlugin implements PaymentPluginInterface
{
public function approve(Transaction $transaction) { /* call API, throw on failure */ }
public function deposit(Transaction $transaction) { /* ... */ }
public function approveAndDeposit(Transaction $transaction) { /* ... */ }
public function isIndependentCreditSupported() { return false; }
}
Tag it (jms_payment_core.plugin with an alias) so the plugin controller can route by paymentSystemName. Look at JMSPaymentPaypalBundle as the reference implementation — it is the best documentation for the interface's intended semantics.
Cross-cutting concerns: decorate, don't fork. Log every request/response, add audit trails, or normalise error messages by decorating jms_payment_core.plugin_controller in your services config and delegating to the inner service.
Testing. Mock the plugin controller in controller tests and assert your own order-state transitions; use a fake plugin (paymentSystemName: test) for functional tests so you never hit a gateway. The bundle's own test suite shows a NullPlugin-style setup worth copying.
composer.json, and treat any composer update as a review event. The encryption service in older tags depends on mcrypt (removed in PHP 7.2) — newer tags moved to OpenSSL. Verify which branch you installed, because the config keys and service ids differ between 1.3/1.4/master.Payment::state directly. States (STATE_PENDING, STATE_APPROVED, dependent amounts, etc.) are driven by transaction results. Set them via the controller and always check both state and getApprovingAmount()/getDepositingAmount() — partial approvals are a real case with PayPal.approveAndDeposit($paymentId, ...). Call persist() + flush() before invoking the plugin; otherwise you get "no payment with id" or duplicate-payment bugs on redirect.getPaymentInstruction()/getPendingTransaction() rely on identifiers you provide; keep the instruction id in the session and in the return URL, and make the return action idempotent (browsers retry, users double-click).bcmath in your own code, and assert $instruction->getCurrency() matches your order currency — mismatch is a silent gateway error.extendedData is an untyped array column. Nothing validates it. Write your own constraints/pre-submit form listeners, and remember it round-trips through the backend, so don't stash objects there.$transaction->getResponse() / reason codes.PaymentInstruction with its Payment and all Transactions (getTransactions()), then inspect each transaction's state, type, reason code and raw response. Most "it did nothing" reports are a transaction stuck in STATE_PENDING after a lost return, or a failed preApproved dependent amount.payment_instruction_id, state and created_at in your subclasses — the bundled superclasses ship no indexes, and the joins get slow quickly.orderId, reference columns), a custom PaymentPluginInterface implementation, and service decoration of jms_payment_core.plugin_controller. Avoid editing anything under vendor/ — you will never upgrade again.Payment/gateway concepts; extract your gateway calls behind a small interface now so swapping out the bundle later is a one-implementation change rather than a rewrite.How can I help you explore Laravel packages today?