Skip to content

3.2.0 - Guzzle 8, spec-conformant requests, security fixes and new endpoints

Latest

Choose a tag to compare

@bashgeek bashgeek released this 02 Oct 07:45

A large maintenance release: Guzzle 8 support, security hardening, seven new API methods, and a full audit of every request against PayPal's official OpenAPI specs, which turned up and fixed dozens of requests PayPal would reject or silently ignore.

There are no removed methods and no required signature changes. Some return values and default behaviours changed where the old behaviour was a bug, so please read Behaviour changes before upgrading.

Requirements

  • Guzzle 8 is now supported: guzzlehttp/guzzle ^7.15.2|^8.0.1, guzzlehttp/psr7 ^2.12.3|^3.0. The raised minimums exclude Guzzle/PSR-7 versions with published security advisories.
  • ext-bcmath is no longer needed.
  • A minimal configuration now works: currency, locale and validate_ssl default to USD, en_US and true. payment_action and notify_url were never used and have been removed from the config (existing configs keep working).

Security

  • Webhook certificate URL must be HTTPS. verifyWebHookLocally() accepted http:// certificate URLs on PayPal hosts, which let an on-path attacker supply their own signing certificate. The certificate fetch also no longer follows redirects, times out after 10 s and only caches real PEM certificates.
  • No more credential leaks between calls. Request bodies, Basic-auth credentials and per-request headers stayed on the long-lived provider. After a failed token request (with withExceptions()), every later call was sent with Basic auth; JSON bodies were re-sent on later GET requests and token refreshes.
  • Retries can no longer double-charge. POST/PATCH requests (capture, refund, payouts, ...) are only retried on 5xx/429/timeouts when they carry a PayPal-Request-Id. Use withIdempotencyKey() to make them retryable:
$provider->withIdempotencyKey()->capturePaymentOrder($orderId);

New

// Orders v2 shipment trackers
$provider->updateTrackingForOrder($orderId, $trackerId, [['op' => 'replace', 'path' => '/notify_payer', 'value' => true]]);
$provider->cancelTrackingForOrder($orderId, $trackerId);

// Disputes
$provider->appealDispute('PP-D-27803', ['/path/to/receipt.pdf'], [['evidence_type' => 'PROOF_OF_REFUND', 'evidence_info' => ['refund_ids' => ['1CX12345AB678901C']]]]);
$provider->provideDisputeSupportingInfo('PP-D-27803', 'The item was delivered on time.', ['/path/to/tracking.pdf']);
$provider->provideDisputeEvidence('PP-D-27803', $files, [['evidence_type' => 'PROOF_OF_FULFILLMENT', 'notes' => '...']]); // evidence details

// Payments
$methods = $provider->findEligiblePaymentMethods(['customer' => ['country_code' => 'US'], 'purchase_units' => [...]]);
$provider->refundCapturedPaymentInFull('2GG279541U471931P');
$provider->captureAuthorizedPayment($authorizationId, '', 10.00, '', false); // partial capture, keep the authorization open

// Webhooks
$provider->simulateWebHookEvent('PAYMENT.CAPTURE.COMPLETED', 'webhook-id');
$provider->verifyWebHook([... 'webhook_event' => $request->getContent()]); // raw body, posted back unchanged

// Fastlane: browser-safe client token from v1/oauth2/token
$token = $provider->generateFastlaneClientToken(['example.com'])['access_token'];

// Plans and lists
$provider->listPlansForProduct('PROD-XXCD1234QWER65782');
$provider->showOrderDetails($orderId, ['payment_source']);
$provider->showSubscriptionDetails($subscriptionId, ['last_failed_payment', 'plan']);
$provider->listEvents(['event_type' => 'PAYMENT.CAPTURE.COMPLETED']);
$provider->listDisputes(['dispute_state' => 'REQUIRED_ACTION']);
$provider->showBatchPayoutDetails('FYXMPQTX4JC9N', page: 2, page_size: 100);

Further optional parameters: updateInvoice() can suppress the recipient/merchant emails, makeOfferToResolveDispute() accepts a return address and invoice ID, addTaxes() an $inclusive flag, addPricingScheme() an explicit billing cycle $sequence, setStoredPaymentSource() a $usage, setShippingAddressChangeCallback() the callback $events, generateQRCodeInvoice() an $action, and listUsers() paging.

Behaviour changes

Please check these when upgrading:

  • Arrays instead of JSON strings: sendInvoice(), captureSubscriptionPayment() and updateDispute() return decoded arrays ([] instead of '' for empty responses).
  • Decoded errors everywhere: methods that return raw success bodies (updateOrder(), cancelSubscription(), updatePlan(), deleteInvoice(), ...) now return JSON API errors as arrays in $response['error'] / getPayPalError(), as documented. Drop any json_decode($response['error']) workaround.
  • No more JsonException on success: empty 204 responses return []; malformed success bodies are returned as errors (or thrown as PayPalApiException with withExceptions()).
  • Accept-Language is sent: the configured locale (default en_US) now actually reaches PayPal, so error message texts follow it. The machine-readable name/issue codes are unchanged.
  • Taxes are applied: addTaxes() was sent where PayPal ignores it; subscriptions created with it are now actually taxed. Tax rates keep their precision (8.875 stays 8.875).
  • Amounts follow the currency: JPY, HUF and TWD are sent without decimals (PayPal rejected them before). Plan prices and setup fees are rounded instead of truncated.
  • Retries: see Security. In addition, Retry-After values above 10 s are no longer waited for; the 429 is returned instead.
  • generateQRCodeInvoice() defaults to 500×500, throws InvalidArgumentException for sizes outside 150–500 px, and returns the base64 PNG (every successful call used to come back as an error).
  • Invoice notifications: send_recipient = false is now respected (the customer was emailed anyway).
  • Order experience context: setShippingAddressChangeCallback() and setStoredPaymentSource() now send PayPal's actual fields (order_update_callback_config, payment_source.<method>.stored_credential), and only the experience context fields a payment source supports are sent.
  • Webhook verification: verifyIPN() posts the raw request body back to PayPal instead of a re-encoded copy.
  • Plan pricing updates: without an explicit sequence, processBillingPlanPricingUpdates() looks up the plan (one extra GET) to update the right billing cycles.
  • Idempotency keys are added automatically where PayPal requires them: createOrderWithPaymentSource() with a payment source, and createWebExperienceProfile().
  • Smaller changes: listUsers() without arguments sends no (invalid) filter, listSubscriptionTransactions() defaults to the last 30 days, listDisputes()/listPlans() cap the page size at the endpoint maximum, BillingPlanBuilder puts trial cycles first, and a Content-Type set with setRequestHeader() only applies to the next request.

Fixes

  • Token refresh: getAccessToken() reused the previous HTTP method, so refreshing after a GET sent GET /v1/oauth2/token.
  • Orders: valid confirm-payment-source body (no processing_instruction, no [] payment source).
  • Payments: empty invoice_id/note_to_payer are omitted instead of rejected.
  • Subscriptions & plans: plan pricing updates send the required billing_cycle_sequence; BillingPlanBuilder accepts the API's CANCEL setup fee action; subscription quantity and tax percentages are sent as strings; empty descriptions are omitted.
  • Invoicing: payment/creation date search filters use date-times; four missing invoice statuses are accepted.
  • Disputes: all offer types work (REPLACEMENT_WITHOUT_REFUND omits the amount, REFUND_WITH_RETURN can include the return address).
  • Vault: listPaymentSourceTokens() defaults to the API's maximum page size of 5.
  • Webhooks: createWebHook() always sends event_types as an array.
  • Locale: the configured locale was wiped by the constructor and never sent.

Deprecations

To be removed in the next major version:

  • addBatchTracking(): PayPal deprecated the batch endpoint, use addTrackingForOrder().
  • deletePaymentSetupToken(): PayPal has no such endpoint; setup tokens expire automatically.
  • setupOrderConfirmation()'s $processing_instruction (ignored).
  • BillingPlanBuilder::withSetupFee(..., 'CANCEL_SUBSCRIPTION'): use CANCEL (the old value is mapped).
  • setCurlConstants(), defineCurlConstant() and Services\Str: no longer used.

Notes

A few multipart details aren't fully specified by PayPal's spec and follow PayPal's published samples: the JSON input part used for dispute evidence, appeals and supporting information, and the multipart format of the QR code response. Please report anything unexpected from the sandbox.