kmj/paypalbridgebundle
KMJ PayPalBridgeBundle is a Symfony bundle that simplifies PayPal payment integration, providing configurable API clients, checkout flow support, and IPN handling for PHP applications.
Start by correcting the premise: this is a Symfony 2 bundle, not a Laravel package. There is no service provider, no config/paypal.php, no facade, and no composer require-and-go path. Symfony 2 hit EOL in 2018, and the bundle's symfony/symfony constraints will deadlock against any modern illuminate/* tree. So "using this package day-to-day" in a Laravel codebase means one specific thing: mining it as a reference implementation for how to wire paypal/rest-api-sdk-php's ApiContext into a container, then writing the ~60 lines of Laravel glue yourself.
Read the repo in this order:
DependencyInjection/Configuration.php — the authoritative config tree. This tells you exactly which keys a PayPal integration needs (client_id, client_secret, mode/sandbox, log settings, HTTP connection timeouts). Everything else in the repo is derived from this file.DependencyInjection/*Extension.php + Resources/config/services.yml — shows how the ApiContext is constructed, which arguments are injected, and what service id consumers reference. This is your translation source.composer.json — pins the SDK generation and PHP floor. This is the single most important file for deciding whether the pattern is still viable.README.md — usually thin; treat the code as the docs.Your first use case, in Laravel terms, is a credential-configured ApiContext singleton plus one gateway class:
// config/paypal.php
return [
'client_id' => env('PAYPAL_CLIENT_ID'),
'client_secret' => env('PAYPAL_CLIENT_SECRET'),
'mode' => env('PAYPAL_MODE', 'sandbox'), // 'sandbox' | 'live'
'log_enabled' => env('PAYPAL_LOG', false),
'log_level' => env('PAYPAL_LOG_LEVEL', 'INFO'),
'http_timeout' => env('PAYPAL_HTTP_TIMEOUT', 30),
];
// app/Providers/PayPalServiceProvider.php
public function register(): void
{
$this->app->singleton(ApiContext::class, function ($app) {
$c = $app['config']['paypal'];
$cred = new OAuthTokenCredential($c['client_id'], $c['client_secret'], [
'mode' => $c['mode'],
]);
$ctx = new ApiContext($cred, 'Request-' . Str::uuid());
$ctx->setConfig([
'mode' => $c['mode'],
'log.LogEnabled' => (bool) $c['log_enabled'],
'log.FileName' => storage_path('logs/paypal.log'),
'log.LogLevel' => $c['log_level'],
'http.ConnectionTimeOut' => $c['http_timeout'],
]);
return $ctx;
});
$this->app->bind(PaymentGateway::class, PayPalGateway::class);
}
Register the provider, then inject PaymentGateway — never ApiContext directly — into controllers, jobs, and listeners.
Bind behind an interface, not the SDK class. The bundle hands you a container-managed ApiContext; that's the only idea worth keeping. Bind PaymentGateway to PayPalGateway so the SDK types stay in one directory. This is what makes the eventual SDK swap (to paypal/paypal-server-sdk) a one-file change rather than a codebase-wide grep.
Translate the bundle's service ids one-to-one. Map each Symfony service id in services.yml to a container key: paypal.api_context → ApiContext::class, paypal.payment.service → a PayPalGateway method. Doing the mapping on paper first surfaces which services you actually need — usually two or three out of the whole file.
Standard three-step checkout flow. The bundle does not add checkout logic; you write it against the SDK:
// 1. Create — persist the PayPal id before redirecting
$payment = $gateway->createPayment($order); // returns approval link
$order->update(['paypal_payment_id' => $payment->getId()]);
// 2. Execute — on the return route, with the PayerID from the query string
$result = $gateway->executePayment(
$order->paypal_payment_id,
$request->query('PayerID')
);
// 3. Reconcile — confirm amount/currency/state before fulfilling
Never trust the redirect alone: re-fetch the payment and assert state === 'approved', and compare transactions[0].amount.total and currency against your stored order before shipping anything. Amount tampering between create and execute is the classic paypal-REST-SDK failure.
Mirror the config-driven environment switch. The bundle's mode key maps to an SDK config option. Keep that exact shape so PAYPAL_MODE=sandbox in .env.testing and live in production is the only difference between environments — no code branches.
Use context binding for multi-merchant. If you ever need more than one credential set, $this->app->when(CheckoutController::class)->needs(ApiContext::class)->give(...) is the Laravel analogue to Symfony's per-service arguments. It keeps the "construct once per credential set" invariant the bundle was built around.
Reference the bundle's logging config verbatim. log.LogEnabled, log.FileName, log.LogLevel are the SDK's own options, not bundle inventions — copy them as-is into setConfig(). Point log.FileName at a dedicated channel, not your main app log.
Test with a fake gateway. Bind a FakePaymentGateway in tests that returns canned Payment objects. The bundle has no test doubles; because you're behind an interface, you get them for free — and you avoid the SDK's exception hierarchy leaking into feature tests.
Do not install it. composer require kmj/paypalbridgebundle will fail resolution, and --ignore-platform-reqs puts an EOL framework and a retired payments SDK into a production dependency graph. Reading the source costs an hour; adopting it costs an owner forever.
The underlying SDK is the real risk, not the bundle. paypal/rest-api-sdk-php is retired by PayPal in favour of the OpenAPI-generated paypal/paypal-server-sdk. If you're reading this repo, check composer.json first: if it pins the v1 SDK, you are looking at an integration pattern you should not copy wholesale into new work. Copy the DI shape; use the current SDK underneath.
ApiContext carries mutable state. It holds the credential and a per-request config, and setConfig() mutates the shared instance. Bound as a singleton, one request mutating config can poison the next in long-running workers (Octane, queue daemons). Either treat it as immutable after construction, or bind it as scoped in an Octane-aware app.
env() returns null after config:cache. This is the most common post-deploy breakage for payments. Always read credentials via config('paypal.client_id'), never env() outside config/. Add a boot-time assertion that both credentials are non-empty and fail loudly.
Never log client_secret. Enabling log.LogEnabled makes the SDK dump request context, which can include auth headers. Either keep logging off in production, or route log.FileName to a tightly permissioned file and scrub it. The bundle offers no redaction — assume you must add it.
Amounts: strings, not floats. The SDK accepts floats and will happily round. Store and pass order totals as strings ('19.99') and do arithmetic with BCMath or integer cents. Currency mismatch between create and execute is a silent failure in executePayment.
Idempotency is yours to add. The bundle exposes no idempotency handling. Send a PayPal-Request-Id header (or the SDK's request-id config) on every create/refund so a network timeout and a retry don't produce two charges. This is table stakes for the payment path and is the first thing to add after porting.
Webhooks: fail closed. If you port webhook handling, verify signatures server-side and reject on any verification error — including "verification endpoint unreachable." Note that signature verification details differ across SDK generations, so a copied implementation is a common place where ports accidentally fail open.
Absolute URLs are mandatory. return_url and cancel_url must be fully qualified and match what's registered in the PayPal app. A relative URL works in sandbox tooling and fails in production. Generate them with route(..., absolute: true).
Catch the right exceptions. The SDK throws PayPalConnectionException (transport) separately from PayPalException (API error). Handle them differently: connection errors are retryable, API errors usually are not. Wrapping both in one catch is what makes PayPal incidents hard to debug.
Extension point is the interface, not the bundle. There is no "extend the Symfony bundle" path worth taking from Laravel. If you need behaviour the SDK lacks (retries, circuit breaking, audit trail), add it in your PayPalGateway decorator — e.g. RetryingPaymentGateway wrapping PayPalGateway — and leave the SDK untouched.
Write down the exit trigger. Because the pattern you're porting sits on a retired SDK, record the replacement condition when you write it: "migrate to paypal-server-sdk by " or "when subscriptions/vaulting enter scope." A payment path with no recorded migration trigger becomes permanent by default.
How can I help you explore Laravel packages today?