lexik/paybox-bundle
LexikPayboxBundle integrates the Paybox payment gateway into Symfony applications, simplifying payment request generation, callback handling, and response verification through configurable services and ready-to-use controllers.
Accept the framework mismatch up front. This is a Symfony bundle with zero Laravel packages among its dependents. You will not composer require this into a Laravel app and have it work cleanly. Treat it as reference implementation material and a protocol dictionary, not a dependency. The realistic day-one move is:
# Do NOT install into your Laravel app.
# Instead, clone it next to your project to read the flow:
git clone https://github.com/lexik/LexikPayboxBundle /tmp/paybox-reference
Read these three files first, in this order:
Paybox/System/Paybox.php (or the equivalent service class) — this is where setParameters(), getUrl(), and verifyResponse() live. This is 80% of what you actually need to port.DependencyInjection/Configuration.php — the canonical list of config keys (site, rank, identifier, key, production, servers, timeout). Copy this into your config/services.php structure.lexik/paybox vendor lib it wraps — the raw request/response signing code is framework-agnostic and the cleanest thing to lift (MIT-licensed).Your first use case — the redirect-style payment. Once you've ported a PayboxClient class, the shape of a Laravel controller looks like this:
class PayboxController extends Controller
{
public function __construct(private PayboxClient $paybox) {}
public function redirect(Order $order)
{
$params = $this->paybox->buildParameters([
'PBX_TOTAL' => $order->total_cents, // cents, always
'PBX_DEVISE' => 978, // EUR
'PBX_CMD' => 'ORDER-'.$order->id,
'PBX_PORTEUR' => $order->user->email,
'PBX_RETOUR' => config('paybox.retour'),
]);
$params['PBX_SIGN'] = $this->paybox->sign($params);
return view('paybox.redirect', [
'url' => $this->paybox->getUrl(),
'params' => $params,
]);
}
}
The Blade view just POSTs hidden inputs to $url. That's the entire first milestone — you redirect to Paybox's preprod endpoint with a correctly signed payload.
Where to look next: once the redirect works, the callback/IPN controller is the real integration. Everything else is plumbing.
One adapter, one seam. Put every piece of Paybox knowledge behind a single PayboxClient (or PaymentGateway port) interface. Controllers, jobs, and webhook handlers should never touch PBX_* constants directly. This is the same "thin controller, dedicated service" pattern the bundle's own users recommend — and it's what lets you swap to an official Verifone SDK later without touching business code.
interface PaymentGateway {
public function buildRedirect(Order $order): RedirectPayload;
public function verify(string $rawQuery): VerifiedResult;
}
The PBX_RETOUR string is a contract, not a parameter. When you copy the bundle's example (montant:M;ref:R;auto:A;erreur:E;idtrans:S;sign:K), you are declaring the exact fields and order that Paybox will echo back — and the HMAC is computed over that declaration. Store it as a single config value (config('paybox.retour')) and reference it from both the request builder and the verifier. Never type it twice.
Two callback paths, two trust levels. Paybox sends the user back via browser redirect to PBX_EFFECTUE / PBX_REFUSE / PBX_ANNULE (GET, user-controlled, do not trust for fulfillment), and separately calls PBX_REPONDRE_A server-to-server (POST). Model them as:
// GET return — cosmetic, for UX
Route::get('/paybox/return', [PayboxController::class, 'return']);
// POST IPN — source of truth
Route::post('/paybox/ipn', [PayboxController::class, 'ipn'])
->withoutMiddleware([VerifyCsrfToken::class]);
Fulfill only on the IPN. The return URL just shows "thanks, awaiting confirmation."
Idempotency keyed on PBX_CMD + idtrans. The bundle itself does not solve replay; you must. Wrap the IPN in a DB transaction with a unique index on (order_id, idtrans) or a Redis SETNX guard. IPNs will retry if you return non-200.
Queue the IPN processing, return 200 fast. Paybox retries on slow/non-2xx responses. Verify the signature synchronously (cheap), persist the raw payload, dispatch a job, return 200 OK. The job does the order state transition, invoice generation, and email.
Test against preprod with signed fixtures. In Laravel:
it('accepts a valid IPN signature', function () {
$payload = $this->paybox->buildSignaturePayload([
'auto' => 'A', 'erreur' => '00000', 'idtrans' => 'S',
'montant' => '1999', 'ref' => 'ORDER-1234',
]);
$response = $this->post('/paybox/ipn', $payload);
$response->assertOk();
$this->assertDatabaseHas('orders', ['id' => 1234, 'status' => 'paid']);
});
Port the bundle's verifyResponse() algorithm verbatim — this is where regressions hide.
Keep environments identical except credentials. Same PBX_RETOUR, same currency, same amount logic in preprod and prod. Only the TPE identifier, site, rank, and HMAC key change. This is stated directly in the bundle's docs and is the single biggest source of "works in staging, fails in prod" incidents.
It's archived — budget for a fork on day one. Last release January 2019, no security patching, no PHP 8 compatibility guarantee. If you extract code, vendor it into your own namespace (App\Payments\Paybox\*) with a LICENSE attribution and a code comment linking upstream. Do not add lexik/paybox-bundle to composer.json.
Amounts are in cents, always. €19.99 is 1999. Add a Money cast or a total_cents accessor on the Order model so the conversion is impossible to get wrong at the call site.
Currency is 978 (EUR) for essentially every Paybox contract. Don't parameterize it unless you've confirmed multi-currency support in the merchant agreement — sending 840 for USD to a EUR-only TPE fails silently or rejects.
Signature verification is the security model. No other defense exists on the IPN. Before persisting any payment state, recompute the HMAC over the exact PBX_RETOUR field order and compare. On mismatch:
if (! hash_equals($expected, $received)) {
Log::warning('Paybox HMAC mismatch', [
'raw' => $request->getQueryString(),
'expected' => $expected,
'received' => $received,
]);
abort(403);
}
Use hash_equals, never ===. Most real-world "signature failures" are one of: reordered PBX_RETOUR, wrong HMAC key (preprod vs prod), or PBX_TIME drift.
HMAC algorithm must match the key. Modern Paybox contracts are HMAC-SHA512. A SHA-1-era key won't work with a SHA-512 declaration — you'll get a "valid-looking" signature that fails at Paybox. Check PBX_HASH in the bundle's service and set it explicitly to SHA512.
Server URL trailing slash. The bundle's servers config entries must end in /, or the generated getUrl() produces a broken URL that Paybox rejects with a generic error. When you port config, keep the slash or use rtrim(...).'/'.
Unknown parameters vanish without error. The underlying lexik/paybox library filters unknown keys. A typo like PBX_MONTANT instead of PBX_TOTAL just... doesn't get sent. Debug by dumping getParameters() (or your port's equivalent) immediately before rendering the redirect form and diffing against the Paybox reference list. Consider adding a PAYBOX_DEBUG_PARAMS logger in dev.
CSRF and route middleware. The IPN is a POST from Paybox's servers — it has no session cookie and no CSRF token. Exclude /paybox/ipn from VerifyCsrfToken explicitly and add it to the api middleware group or a custom group without the web stack, so you don't accidentally require an auth session.
PBX_REPONDRE_A may arrive before the browser redirect. Don't assume the user is back when the IPN lands. Order state should be correct from the IPN alone, and the return-page view reads order status from the DB rather than from query params.
Decorate the port, don't edit the port. Need logging, a forced PBX_TIME, or a custom HTTP client for server-side calls? Do it in a decorator around your PaymentGateway interface. That same decorator is the seam where you swap in Verifone's official SDK when this becomes maintenance debt — which, given the package's status, it probably already is.
How can I help you explore Laravel packages today?