orderly/paypal-ipn-bundle
A Symfony bundle for integrating PayPal Instant Payment Notifications (IPN). It validates incoming IPN callbacks and dispatches events, letting your application handle payment status updates through configurable listeners and services.
This is a Symfony2 bundle, not a Laravel package, and the repository is archived. There is no ServiceProvider, no config/paypal-ipn.php, and no facade to composer require into a Laravel app. Treat it as a reference implementation for PayPal IPN handling rather than a drop-in dependency.
Where to look first in the source:
$_POST from PayPal and posts it back to PayPal's cmd=_notify-validate endpoint to confirm authenticity. This is the only genuinely non-obvious piece of logic in the bundle.event() / listener or a queued job.First use case to reproduce in Laravel: a single POST /webhooks/paypal route that (a) reads the raw request body, (b) posts it back to PayPal with cmd=_notify-validate, (c) writes the raw payload and validation verdict to an ipn_logs table, and (d) if VERIFIED, dispatches a job. That is the entire functional surface of this bundle.
The bundle's structure maps cleanly onto a small Laravel module. The patterns worth copying:
Postback validation as a dedicated service. Isolate the "send raw body back to PayPal, check for the literal string VERIFIED" logic in a single class (App\Services\PayPalIpnValidator) with the endpoint as a config value. The bundle does this via a Symfony service with a configurable sandbox flag; mirror that with config/services.php:
'paypal' => [
'ipn_url' => env('PAYPAL_IPN_URL', 'https://ipnpb.paypal.com/cgi-bin/webscr'),
],
Log-before-act. The bundle writes the notification to storage before firing events. Do the same: insert an ipn_logs row (raw body, txn_id, payment_status, verified boolean, received_at) inside the controller, then hand off. This gives you an audit trail for disputed payments and a replay source when something breaks.
Idempotent processing keyed on txn_id. PayPal retries IPNs. Add a unique index on txn_id (or ipn_track_id) and use insertOrIgnore / a firstOrCreate guard so duplicate deliveries are no-ops. The bundle's entity-per-IPN model naturally accommodates this; a raw save() in Laravel does not.
Validate-then-dispatch. Only after receiving VERIFIED (and after checking receiver_email, payment_status = Completed, mc_currency, and mc_gross against your order) should you fire side effects. In Laravel terms: controller → validator → dispatch(new ProcessPayPalPayment($payload)) on the queue. Return 200 OK to PayPal immediately; never do order fulfilment synchronously in the webhook request.
Event-driven extension. The bundle exposes a post-validation event so consumers don't modify vendor code. Reproduce with event(new PayPalIpnVerified($payload)) and multiple listeners, so one listener handles fulfilment, another handles notifications, another syncs accounting. This keeps the webhook controller thin as your PayPal surface grows.
Symfony\Component\HttpFoundation idioms. Don't try to shoehorn it in; port the ~200 lines of meaningful logic.INVALID responses is building the postback with http_build_query($request->all()) — parameter order and URL-encoding shift, and PayPal compares bytes. Use $request->getContent() verbatim, prefixed with cmd=_notify-validate&.https://ipnpb.paypal.com/cgi-bin/webscr (sandbox: ipnpb.sandbox.paypal.com). Older tutorials point at www.paypal.com, which still responds but is legacy — the bundle predates ipnpb, so don't copy its URL blindly.VERIFIED is case-sensitive and must match exactly. PayPal returns the literal string with no body decoration; anything else (HTML error page, timeout body, empty response) means treat as invalid.VERIFIED only means "PayPal sent this." Compare receiver_email to your configured merchant, mc_gross and mc_currency to the order total, and payment_status to the expected value before fulfilling.INVALID responses usually means a deployment changed proxy/body handling (e.g. TrimStrings middleware or PHP's post_max_size) and you're no longer echoing the original bytes.How can I help you explore Laravel packages today?