paynl/sdk
PHP SDK for integrating Pay.nl payments into your application. Provides convenient access to Pay.nl APIs for payment methods, transactions, refunds, and order handling, with a clean object-oriented interface for PHP and Laravel projects.
paynl/sdk is Pay.nl's official PHP wrapper around the Pay.nl REST APIs. In Laravel terms it's a raw library, not a package: no service provider, no publishable config, no facade. Budget an hour to write your own thin integration layer around it.
composer require paynl/sdk
You need two credentials from the Pay.nl merchant dashboard before you can do anything: an API token (AT-..., created under your account/API tokens) and a sales location / service ID (SL-...). The token must have rights on that sales location, and payment methods must be enabled on the sales location — otherwise your first call silently returns an empty method list or fails with a vague API error.
The longest-lived configuration surface is the static Paynl\Config (the 2.x-style API most docs and Stack Overflow answers use):
// app/Providers/AppServiceProvider.php (boot)
use Paynl\Config;
Config::setApiToken(config('services.paynl.token'));
Config::setServiceId(config('services.paynl.service_id'));
Version check first: the SDK has had major-version rewrites. After
composer require, openvendor/paynl/sdk/src/and confirm the entry classes and whether configuration is static (Paynl\Config) or a client object. Pin the major incomposer.json; the README on GitHub tracks the latest branch, not necessarily the one you installed.
First use case — a redirect payment:
use Paynl\Transaction;
$transaction = Transaction::start([
'amount' => 19.99,
'returnUrl' => route('paynl.return'),
'exchangeUrl' => route('paynl.exchange'),
'paymentMethodId' => 10, // iDEAL; see Paymentmethods::getList()
'description' => 'Order #1042',
]);
// Persist $transaction->getTransactionId() (TX-...) against your order
return redirect($transaction->getRedirectUrl());
Where to look first, in order:
Paynl\Paymentmethods::getList() — dumps every method/sub-option enabled on your sales location, with IDs. This is the fastest way to confirm your credentials and configuration are correct.vendor/paynl/sdk/src/Api/Transaction/RequestAdd.php — the authoritative list of accepted Transaction::start() keys. The README is thinner than this file.var_dump for debugging.Persist the transaction ID, not the redirect. Treat Transaction::start() as creating an order on Pay.nl's side. Store getTransactionId() on your order row before redirecting; everything afterwards (return, webhook, refund) keys off that ID. Never trust ?order_id= or any query parameter on the return URL as proof of payment.
Split return and exchange handlers. The return URL is the customer's browser coming back — good for UX, worthless for state. The exchange (webhook) URL is Pay.nl's server calling you. Both should call Transaction::get($id) and drive your order state machine from the API result:
Route::post('/paynl/exchange', function (Request $request) {
$txId = $request->input('order_id') ?? $request->input('transactionId');
$tx = Transaction::get($txId);
if ($tx->isPaid()) {
// mark paid, fulfil, fire events — idempotently
} elseif ($tx->isPending()) {
// leave pending; exchange will be called again
} elseif ($tx->isCanceled()) {
// release stock / cancel order
}
return response('TRUE', 200); // Pay.nl expects a 200 acknowledgement
});
The exchange is retried, so make the handler idempotent (guard on current local state, not on "did the webhook arrive"). Respond fast — do heavier work in a queued job if fulfilment is slow.
Status is not binary. isPaid() is the happy path, but you also need isPending(), isCanceled(), isRefunded() and, for methods that authorize rather than capture, an explicit approve/capture step. Build an explicit enum/mapping in your app rather than sprinkling if ($tx->getStatus() == 100) through controllers, and log the raw status for unknown values.
Choose methods by amount, not by hardcoding IDs. Paymentmethods::getMethodsForAmount($amount) filters out methods that are disabled or outside min/max for that amount (gift cards, Klarna, etc. have very different limits). Use it to build the checkout list, then pass the chosen paymentMethodId — and for methods with sub-options (bank for iDEAL, issuer for gift cards) pass the option ID through as well.
Refunds are a separate lifecycle. A refund call creates a refund object with its own status; it is not synchronous with the money arriving. Track refunds in their own table keyed by refund ID, and reconcile the sum of successful refunds against the original amount rather than assuming one refund per transaction:
$refund = Transaction::refund($txId, [
'amount' => 5.00,
'description' => 'Partial refund — damaged item',
]);
Laravel glue. Bind a small PaynlService in the container that owns configuration and wraps Transaction/Paymentmethods/Refund calls into methods returning your own DTOs. This gives you one place to configure, one place to add logging, and a mockable seam for tests (the SDK's static calls are painful to fake otherwise). For multi-tenant apps with different sales locations, don't rely on boot-time statics — see the gotcha below.
Other surfaces worth knowing: Instore for terminal/QR payments, Giftcard for activation and balance, Voucher for voucher creation/redemption. They follow the same static-call shape, so once Transaction is wired the rest is mostly payload discovery.
Static config is process-global. Paynl\Config stores credentials in static properties. This is fine for a single-tenant web request and a landmine under Octane, long-running queue workers, or any multi-tenant setup: whichever tenant configured last wins. Re-set the token/service ID at the start of every job/request that touches Pay.nl, or wrap each call in a service that sets and restores config.
Enable methods on the sales location, not just the account. The most common "the SDK is broken" report is actually a dashboard problem: the API token is valid, but the payment method isn't activated on that SL-.... Paymentmethods::getList() returning less than you expect is the tell.
Amounts are floats in euros. The SDK converts to cents internally, but float rounding will bite you on totals built from many lines. Keep integer cents in your domain and divide by 100 only at the SDK boundary. Also respect per-method min/max — getMethodsForAmount() exists because Pay.nl rejects out-of-range amounts at start time.
Return URL spoofing is trivial. Anyone can hit /paynl/return?order_id=.... The only safe pattern is: return handler looks up the transaction via API, then redirects to a "thank you / pending / failed" page based on the API status. Fulfilment happens from the exchange handler or from a shared state-transition service, never from the return URL alone.
Exchange handler reliability. It must be publicly reachable over HTTPS, must not be behind auth/CSRF, must return 200 quickly, and must tolerate duplicate calls and out-of-order calls (a cancel arriving after a paid is possible in edge cases — reconcile, don't blindly overwrite). If Pay.nl can't reach it, transactions sit in "paid but not processed" and you'll be reconciling manually.
Exceptions are typed and worth catching specifically. The SDK throws Paynl\Error\Api (API-level rejection, usually with an error ID you should log and map to a user-facing message), Paynl\Error\Required (missing/invalid parameter — a programming bug, let it bubble in dev), and a base Paynl\Error\Error. Catch Api explicitly in the checkout flow so a Pay.nl outage doesn't 500 the cart; log the error ID, it's what Pay.nl support will ask for first.
Timeouts and HTTP behaviour. Calls are curl-based; if the SDK exposes Config::setCurlOptions() (it does in 2.x), set a sane CURLOPT_TIMEOUT and CURLOPT_CONNECTTIMEOUT. Without it, a slow Pay.nl response can hold a PHP-FPM worker for a long time during checkout.
Pin the major version and read the changelog before upgrading. The low star count and zero dependents aren't a signal of quality here — it's a vendor SDK and the vendor maintains it — but it does mean there's little community documentation for edge cases, and major versions have changed namespaces/entry points. Treat an upgrade as a mini-migration, not a composer update.
Test against sandbox credentials, not test cards. Pay.nl test mode is a separate token/sales location rather than a magic amount. Keep sandbox and live credentials in separate env vars, and assert on your own order state transitions in tests rather than on live API responses.
Log the raw response. When a status or refund looks wrong, the SDK objects expose the underlying data; logging the full response alongside your order ID will save you a support ticket. The dashboard's per-transaction view is the ground truth when your logs and Pay.nl disagree.
How can I help you explore Laravel packages today?