Repository navigation
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-bcmathis no longer needed.- A minimal configuration now works:
currency,localeandvalidate_ssldefault toUSD,en_USandtrue.payment_actionandnotify_urlwere never used and have been removed from the config (existing configs keep working).
Security
- Webhook certificate URL must be HTTPS.
verifyWebHookLocally()acceptedhttp://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. UsewithIdempotencyKey()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()andupdateDispute()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 anyjson_decode($response['error'])workaround. - No more
JsonExceptionon success: empty 204 responses return[]; malformed success bodies are returned as errors (or thrown asPayPalApiExceptionwithwithExceptions()). Accept-Languageis sent: the configuredlocale(defaulten_US) now actually reaches PayPal, so errormessagetexts follow it. The machine-readablename/issuecodes 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-Aftervalues above 10 s are no longer waited for; the 429 is returned instead. generateQRCodeInvoice()defaults to 500×500, throwsInvalidArgumentExceptionfor sizes outside 150–500 px, and returns the base64 PNG (every successful call used to come back as an error).- Invoice notifications:
send_recipient = falseis now respected (the customer was emailed anyway). - Order experience context:
setShippingAddressChangeCallback()andsetStoredPaymentSource()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, andcreateWebExperienceProfile(). - 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,BillingPlanBuilderputs trial cycles first, and aContent-Typeset withsetRequestHeader()only applies to the next request.
Fixes
- Token refresh:
getAccessToken()reused the previous HTTP method, so refreshing after a GET sentGET /v1/oauth2/token. - Orders: valid confirm-payment-source body (no
processing_instruction, no[]payment source). - Payments: empty
invoice_id/note_to_payerare omitted instead of rejected. - Subscriptions & plans: plan pricing updates send the required
billing_cycle_sequence;BillingPlanBuilderaccepts the API'sCANCELsetup fee action; subscriptionquantityand 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_REFUNDomits the amount,REFUND_WITH_RETURNcan include the return address). - Vault:
listPaymentSourceTokens()defaults to the API's maximum page size of 5. - Webhooks:
createWebHook()always sendsevent_typesas 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, useaddTrackingForOrder().deletePaymentSetupToken(): PayPal has no such endpoint; setup tokens expire automatically.setupOrderConfirmation()'s$processing_instruction(ignored).BillingPlanBuilder::withSetupFee(..., 'CANCEL_SUBSCRIPTION'): useCANCEL(the old value is mapped).setCurlConstants(),defineCurlConstant()andServices\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.