Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Payment Paypal Bundle Laravel Package

jms/payment-paypal-bundle

Symfony bundle that adds PayPal support to the JMS PaymentCore library, letting you process Express Checkout payments through a unified payment workflow with ready-made services, configuration, and plugin integration.

View on GitHub
Deep Wiki
Context7

Getting Started

This bundle plugs PayPal Express Checkout into the JMS Payment stack: it ships a PayPal API client, a PaymentSystem implementation, and an ExpressCheckoutPlugin that drives the SetExpressCheckout → redirect → DoExpressCheckoutPayment flow through JMSPaymentCoreBundle's instruction/transaction state machine. You don't call PayPal directly in normal use; you drive the core payment.plugin_controller and let the bundle translate state transitions into NVP API calls.

Be clear about what this is: a Symfony bundle, not a framework-agnostic library. In a Laravel app you cannot use it as-is — the realistic options are (a) a Symfony sidecar service that owns the payment flow, or (b) reading it as a reference implementation for NVP calls. The steps below are Symfony-flavoured because that's the only supported integration path.

Minimum setup:

composer require jms/payment-core-bundle jms/payment-paypal-bundle

As of 1.2.0 the bundle requires PHP >= 5.6 and supports Symfony through 4.x (Symfony 4 support and service aliases for auto-wiring were added in this release; nothing beyond 4.x is supported). composer require will pick 1.2.0 if your PHP/Symfony constraints allow it — pin explicitly if you care.

// Symfony <=3: app/AppKernel.php
new JMS\Payment\CoreBundle\JMSPaymentCoreBundle(),
new JMS\Payment\PaypalBundle\JMSPaymentPaypalBundle(),

// Symfony 4: config/bundles.php
JMS\Payment\PaypalBundle\JMSPaymentPaypalBundle::class => ['all' => true],
# config.yml (<=3) or config/packages/jms_payment_paypal.yaml (4)
jms_payment_paypal:
    username:  "%paypal_api_username%"
    password:  "%paypal_api_password%"
    signature: "%paypal_api_signature%"   # API signature, not a certificate
    debug:     true                       # sandbox endpoint

Then create the core bundle's Doctrine schema (PaymentInstruction, Payment, Transaction, Credit) with doctrine:schema:update or an export, and register the payment system name you intend to use (paypal_express_checkout) in the core bundle's payment_systems config.

Where to look first:

  1. DependencyInjection/Configuration.php — every config key, no guessing.
  2. Resources/config/*.xml — the real service ids (jms_payment_paypal.client, the plugin, the payment system) and the auto-wiring aliases added in 1.2.0. Read the alias targets before you type-hint anything; the alias list is the authoritative contract.
  3. Plugin/ExpressCheckoutPlugin.php — the exact transition points (approve, complete) and what it reads off the instruction.
  4. PaymentSystem/PaypalExpressCheckoutPaymentSystem.php — amount/currency handling and supported operations.

First use case — the standard checkout redirect:

$instruction = new PaymentInstruction($amount, 'EUR', 'paypal_express_checkout');
$instruction->setPaymentSystemName('paypal_express_checkout');
$em->persist($instruction);
$em->flush();

$result = $this->get('payment.plugin_controller')->approve($instruction->getId(), $amount);

if ($result->isSuccessful() && $result->getPluginResponse()->getRedirectUrl()) {
    return $this->redirect($result->getPluginResponse()->getRedirectUrl()); // to PayPal
}

On 1.2.0 / Symfony 4 you can inject instead of fetching from the container:

use JMS\Payment\CoreBundle\PluginController\PluginControllerInterface;

public function __construct(private PluginControllerInterface $pluginController) {}
// ...
$result = $this->pluginController->approve($instruction->getId(), $amount);

On the return URL (?token=...&PayerID=...) you call complete() for the same instruction; that is where DoExpressCheckoutPayment happens and the transaction is recorded. Deposit/capture afterwards goes through the core deposit flow as usual.

Implementation Patterns

Treat PluginController as the only entry point. Everything else is an implementation detail. Your controller should do: create instruction → approve → redirect → complete → deposit, and persist your own Order entity with a paymentInstruction relation so you can reload context after the PayPal round-trip.

Now that 1.2.0 ships auto-wiring aliases, prefer constructor injection. In Symfony 4 (and 3.4 with autowiring) type-hint the plugin controller interface rather than calling $this->get('payment.plugin_controller'). This is the single biggest day-to-day improvement in the release: it makes your checkout service trivially replaceable in tests without touching the container, and it removes the ContainerAware-era coupling the bundle used to force on you. Verify the exact interface/alias names in Resources/config/*.xml — the aliases mirror the service ids, not new class names.

Model the return leg as a separate, idempotent route. PayPal returns with a token; look the instruction up by token (store it in extendedData when you create it, or map token → instruction id in your own table). If complete() is called twice — double-click, back button, PayPal retry — catch the resulting exception and render the already-completed order rather than starting a new one.

Use the state machine instead of if cascades. PaymentInstruction::getState() transitions through NEW → APPROVING → APPROVED → DEPOSITING → DEPOSITED (plus FAILED/CANCELED). Gate your UI on state, not on your own flags, and hook order fulfilment on DEPOSITED rather than on "PayPal said OK".

Configure multiple systems behind one interface. If you later add a second gateway, keep your controller generic: resolve the payment system name from the checkout request, create the instruction with it, and let each plugin handle its own redirect/form response. PluginResponse gives you either getRedirectUrl() or getForm() — write a small renderer for both so new gateways don't touch your controller.

Integration tips:

  • Sandbox credentials plus a sandbox buyer account; test the cancel path as seriously as the success path.
  • Keep debug: true in dev via a parameter — but never let it leak into prod config, since it silently sends real traffic to the sandbox.
  • Log the NVP request/response at debug level for the jms_payment channel; it's the only practical way to debug 104xx errors.
  • Wrap the injected PluginControllerInterface in your own thin service (or decorate it) so tests can substitute a fake returning canned Result/PluginResponse objects — the bundle is still very hard to unit test end-to-end, aliases or not.
  • When upgrading an existing install to 1.2.0, diff Resources/config/*.xml against what you had: service ids are stable but the aliases are new, and any local service definition that shadowed the old ids may now conflict with an alias.

Gotchas and Tips

Symfony 4 support is real but it is a ceiling, not a floor lift. 1.2.0 added Symfony 4 (and bumped the minimum PHP to 5.6), so the previous "won't boot on Symfony 4 without work" warning no longer applies. What it does not do: add Symfony 5/6 support, ship a Flex recipe, or modernise the internals — expect pre-final-era idioms, manual bundle registration, and no auto-configured bundle config beyond the aliases. If you're planning a Symfony 5+ application, this bundle is still a dead end and you should budget for the migration up front.

This is a 2019-era bundle on a deprecated PayPal API. Last release January 2019, and it speaks PayPal's Classic NVP/SOAP API (Express Checkout). PayPal has retired onboarding for new Classic integrations; existing grandfathered accounts may still work, but you cannot count on this for new products. Treat it as a legacy integration and budget a migration to the REST Orders v2 API (paypal/paypal-checkout-sdk or Omnipay). Pin your Composer constraints and be prepared to vendor/fork it — the PHP 5.6 floor means the code carries constructs you'll be working around, and nothing in 1.2.0 changes that.

API signature only. Config expects username/password/signature. If your PayPal account only issues a certificate credential, this bundle has no path for you.

Return/cancel URLs must be absolute and reachable by PayPal. Relative routes silently fail. Also watch SameSite cookie settings and session lifetime: if the session dies during the redirect, your return controller can't reconstruct the cart even though the payment succeeded — persist enough state on the instruction, not just in session.

Money is a trap. PayPal rejects some currencies with unexpected decimal precision (JPY has 0, most have 2). Pass amounts consistently (string/decimal, not float arithmetic) and validate the currency against what the payment system supports before creating the instruction.

Failure handling is exception-based. PluginController throws JMS\Payment\CoreBundle\Exception\PaymentException on plugin failure, and PayPal-side failures (10486 buyer funding source, 10422 duplicate invoice, expired token) surface there. Catch, mark the instruction failed, and offer a retry link — don't let a 500 page be the buyer's last impression. Note that a constructor-injected controller doesn't change this contract; the aliases point at the same implementation.

No refunds, voids, IPN or webhook handling. This bundle covers checkout capture only. Refunds, disputes, and reconciliation need to be done via the PayPal dashboard or a separate REST integration; if you need IPN-style notifications you're building that yourself. The 1.2.0 changelog is purely Symfony-4/autowiring plumbing — no functional gaps were closed.

Credentials in logs. The debug logging that makes 104xx errors debuggable will also print your API credentials. Mask them in your Monolog processor before this ever runs anywhere near production.

Extension points if you must stay: override the jms_payment_paypal.client service definition to change timeout/endpoint behaviour, decorate ExpressCheckoutPlugin to add custom extendedData to the instruction before transitions, or implement PluginInterface for a bespoke gateway alongside it. The new aliases make decoration and substitution cleaner than before, but they don't add events — this is still service-definition-level work.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
ignibyte/aic-framework
glavweb/data-schema-bundle
glavweb/datagrid-bundle
tacticmedia/scim-server
uni-method/frame
neika-ai/sdk
adachsoft/php-code-style
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
frankyso/ipaymu
3mdikram/worldmoneywords
snowsoft/config
itmosoft/laravel-subscriptions
larva/laravel-ai-qwen
adachsoft/api-version-bundle