royalpunk/paybox-bundle
royalpunk/paybox-bundle is a PHP package for integrating Paybox payments into your application. It offers a bundle-style wrapper around Paybox payment flows, helping simplify setup and payment handling so developers can connect their app to Paybox checkout more easily.
Do not composer require this blind. Day-one for a Laravel developer is a 1–2 hour source audit, because the name says "bundle" (Symfony packaging vocabulary) and the metadata gives you nothing else to go on.
Step 1 — Establish what you actually received.
git clone https://support.appi.works/jonathan.rooke/paybox-bundle /tmp/paybox
cat /tmp/paybox/composer.json # framework-bundle vs illuminate/support
ls /tmp/paybox # DependencyInjection/ + Resources/config/ = Symfony
cat /tmp/paybox/LICENSE
find /tmp/paybox -name "*Test.php" # no tests = you write them
The two shapes you will find:
*Bundle.php, DependencyInjection/Extension.php) — it will not auto-register in Laravel. You will not be calling it as a Laravel package; you will either run it through a thin Symfony console/HTTP island or, far more likely, read its signing logic and port ~200 lines.*ServiceProvider, config/, maybe a Facade) — then the normal install applies.Step 2 — Install if and only if it is Laravel-native. Pin a commit SHA, never a branch:
{
"repositories": [
{
"type": "vcs",
"url": "https://support.appi.works/jonathan.rooke/paybox-bundle.git",
"no-api": true
}
],
"require": {
"royalpunk/paybox-bundle": "dev-main#a1b2c3d4e5f6"
}
}
Note the no-api and the credentials requirement — the forge is self-hosted, so your CI image needs a token in .netrc or COMPOSER_AUTH. Budget for that; it is a supply-chain decision, not a convenience.
php artisan vendor:publish --provider="Royalpunk\PayboxBundle\PayboxServiceProvider" --tag=config
Step 3 — The first use case: one signed redirect and one callback. Nothing more. Configure the Paybox merchant triplet plus the HMAC key:
// config/paybox.php
return [
'site' => env('PAYBOX_SITE'), // PBX_SITE
'rank' => env('PAYBOX_RANK'), // PBX_RANK
'identifier' => env('PAYBOX_IDENTIFIER'), // PBX_IDENTIFIER
'hmac_key' => env('PAYBOX_HMAC_KEY'),
'sandbox' => env('PAYBOX_SANDBOX', true),
];
Then, in a throwaway branch against the Paybox preproduction endpoint, exercise exactly five paths before you believe anything: success, decline, 3-D Secure challenge, timeout, duplicate callback. If any of the five is unhandled, you are not adopting a package — you are adopting an integration spec.
Where to look first in the source, in this order: sign() / verify() (the entire risk lives here), the endpoint URL constants (sandbox vs production switching), and the callback controller — specifically whether it writes any state at all.
Never call the bundle from a controller. Wrap it. The whole point of a payment dependency is that it is one adapter among several.
interface PaymentGateway
{
public function initiate(Order $order, string $returnUrl): RedirectResponse;
public function handleCallback(Request $request): PaymentResult;
}
final class PayboxGateway implements PaymentGateway
{
public function __construct(private PayboxClient $paybox) {}
public function initiate(Order $order, string $returnUrl): RedirectResponse
{
$ref = $order->paymentReference(); // persisted BEFORE the redirect
return redirect()->away($this->paybox->getRedirectUrl([
'PBX_TOTAL' => $order->totalInMinorUnits(), // 1234 for 12.34 EUR
'PBX_DEVISE' => '978', // EUR ISO 4217 numeric
'PBX_CMD' => $ref,
'PBX_PORTEUR' => $order->customer_email,
'PBX_RETOUR' => 'auto:A:order;montant:M;ref:R;sign:K',
'PBX_TIME' => now()->toIso8601String(),
'PBX_REPONDRE_A' => route('paybox.ipn'),
]));
}
}
Persist the transaction before redirecting, always. The reference you send as PBX_CMD is the only correlation key you control, and the gateway can call back before your HTTP response has even finished. A payments row with status = pending, reference, amount_minor, currency must exist first — otherwise the first callback either 500s or creates an unlinked row you cannot reconcile.
Callback handler = verify → idempotent transition → reconcile. Never trust the redirect URL the user returns on; treat the IPN as the source of truth and the user-facing return as UX only.
public function handleCallback(Request $request): PaymentResult
{
if (! $this->paybox->verifySignature($request->all())) {
Log::warning('paybox.bad_signature', ['ref' => $request->input('ref')]);
abort(400);
}
return DB::transaction(function () use ($request) {
$payment = Payment::where('reference', $request->input('ref'))
->lockForUpdate()
->firstOrFail();
if ($payment->status !== 'pending') { // replay / duplicate IPN
return PaymentResult::alreadyProcessed($payment);
}
$payment->update([
'status' => $request->input('auto') === '00000' ? 'captured' : 'declined',
'gateway_payload' => Arr::except($request->all(), ['sign']),
]);
return PaymentResult::ok($payment);
});
}
Route the IPN outside web middleware. Paybox has no CSRF token and no session. routes/api.php or an explicit withoutMiddleware([VerifyCsrfToken::class]) on a named route, and exclude it from any auth guard.
Test against fixtures, not the sandbox, in CI. Capture the request bodies from your spike and assert on them:
it('rejects a tampered callback', function () {
$this->post('/paybox/ipn', ['ref' => 'X', 'auto' => '00000', 'sign' => 'deadbeef'])
->assertStatus(400);
});
it('is idempotent on duplicate callbacks', function () {
// post the same signed fixture twice, assert one capture, one no-op
});
Config caching is the classic Laravel trap. If the bundle calls env() inside its service class rather than reading from the published config, it works in local and returns null in production after php artisan config:cache. Run php artisan config:cache locally as a test, not as a deployment step you find out about later.
Keep it behind a flag and reconcile on both sides of the switch. PAYBOX_ENABLED=false in production until the reconciliation job (compare payments against Paybox's merchant back office daily) has run cleanly for a week.
The framework mismatch is the first landmine. "Bundle" plus zero dependents plus a self-hosted forge means the probability this is a Symfony bundle with no Laravel service provider is high. Check composer.json require for symfony/framework-bundle before you plan anything. If it is Symfony, stop trying to make it a Laravel dependency — port the signing and URL-building code into your adapter and leave the Symfony wiring behind.
Signature verification is the only code that matters. Read every line of it. The failure modes are silent in both directions:
== on strings instead of hash_equals) is an authentication bypass.PBX_* fields lets an attacker replay a valid signature with a swapped amount or reference.Write the negative test first: take a known-good fixture, flip one character in the amount, assert rejection. Then flip one character in the signature, assert rejection. If the package has no tests for this, you now own that test.
Missing SCA / 3-D Secure 2. EU card payments require 3DS2 challenge flows and exemption handling. A thin legacy-style wrapper almost certainly does not implement this — it predates the requirement. The 3DS challenge is a browser redirect the bank performs mid-flow, and it changes the shape of your return handler: you get an intermediate A code or a challenge URL, not a final result. If the package does not surface that, you have a compliance problem, not a bug, and the roadmap date moves.
Idempotency is not optional. Paybox retries IPNs. So does the user hitting refresh on the return page. Without the lockForUpdate + status guard above, retries become double captures. The gateway has no idempotency key of its own here — PBX_CMD in your own payments table is the only thing standing between you and duplicate charges.
Timeout is not failure. If your HTTP call times out, the charge may still have succeeded. Never mark an order failed on timeout; mark it unknown and let the reconciliation job resolve it against the merchant back office. Retrying a timeout is how you get two charges.
Amounts are integers in minor units. PBX_TOTAL is cents, 1234 for €12.34. Currency exponent varies (JPY has zero decimals, most have two). Rounding at the boundary between your decimal(10,2) column and the gateway's integer field is where customer-facing discrepancies come from. Store minor units in your payments table and format only at display time.
Do not log the callback payload wholesale. It contains the signature and, depending on flow, card-adjacent fields. Log the reference, the auto code, and the amount — nothing else. Your gateway_payload column should store the payload minus sign and be encrypted at rest if your auditor asks.
The HMAC key is a secret with a rotation story you need to know about. Confirm how Verifone expects rotation (dual-key acceptance window?) before you are forced to rotate under incident pressure. If the package reads a single fixed key, you cannot rotate without downtime.
Support is French-first and there is no community. Escalation runs to the original author (availability unknown) or directly to Paybox/Verifone merchant support. Make sure the on-call rota has French coverage or a translation path, because payment incidents are time-critical and the documentation is not in English.
Extension points are all in your fork, not in the package. There is no plugin registry, no driver abstraction, no events. The extension point is git checkout -b internal/paybox and a named owner. Pin the SHA, add it to your SBOM with the license determination, and write the patch policy down — including the PHP minor-version bumps upstream will never do for you.
The realistic day-to-day use is as a reference implementation. With zero stars, zero dependents, and no visible release cadence, the honest leverage is: read it once, extract the signing and endpoint logic, reimplement ~200 lines behind your PaymentGateway interface, and keep the tests. The cost of doing that is a day; the cost of discovering a signature bug in production is a quarter.
How can I help you explore Laravel packages today?