Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Sermepa Omnipay Laravel Package

nazka/sermepa-omnipay

Omnipay gateway for Sermepa (Redsys), the Spanish payment processor used by banks like BBVA and Santander. Adds Sermepa payment support to any Omnipay-based PHP application, handling signed requests, redirects and responses.

View on GitHub
Deep Wiki
Context7

Getting Started

1. Install and pin it. This is a small, single-maintainer driver — a floating constraint is a liability.

composer require nazka/sermepa-omnipay
composer show nazka/sermepa-omnipay   # check the omnipay/common constraint it pulls in

2. Read the classes before the README. The package is tiny; the request/response classes are the documentation:

  • SermepaGateway — endpoints, setTestMode(), setMerchantCode(), setMerchantKey(), setTerminal(), setCurrency()
  • SermepaPurchaseRequest — how amount/order map to Ds_Merchant_* and where the HMAC-SHA256 signature is built
  • SermepaPurchaseResponse — isRedirect(), getRedirectData(), isSuccessful()
  • SermepaCompletePurchaseRequest — how the callback POST is validated

3. Decide the Omnipay major version up front. Most nazka/* gateways target Omnipay 2.x (getRedirectResponse() returns a Symfony response). If your app is on Omnipay 3 (PSR-7, getRedirectUrl()), the gateway will instantiate but the response API won't line up. Either pin Omnipay 2 for this package or plan to fork. Do this before writing controller code — it determines the shape of every call site.

4. Centralize config — never inline keys or the terminal:

// config/services.php
'sermepa' => [
    'merchant_code' => env('SERMEPA_MERCHANT_CODE'), // FUC, 9 digits
    'terminal'      => env('SERMEPA_TERMINAL', '1'),
    'merchant_key'  => env('SERMEPA_MERCHANT_KEY'),  // secret key from the bank
    'currency'      => '978',                        // EUR, numeric ISO
    'test'          => env('SERMEPA_TEST', true),
],

5. First use case — redirect purchase. Two routes: a browser return URL and a server-to-server notify URL.

public function checkout(Order $order)
{
    $response = app('sermepa')->purchase([
        'amount'      => number_format($order->total_cents / 100, 2, '.', ''),
        'orderNumber' => $order->sermepa_order,   // 4-12 chars, see gotchas
        'description' => "Order {$order->id}",
        'returnUrl'   => route('payment.return'),
        'notifyUrl'   => route('payment.notify'),
    ])->send();

    if ($response->isRedirect()) {
        return $response->getRedirectResponse(); // Omnipay 2
    }
    return back()->withErrors($response->getMessage());
}

No queues, no SDK, no Redis: signed form POST to Redsys → user pays → signed POST comes back.

Implementation Patterns

Bind the gateway once. Constructing it inline in each controller is how test-mode and keys drift between the return and notify endpoints:

$this->app->singleton('sermepa', function () {
    $g = Omnipay::create('Sermepa');
    $g->setMerchantCode(config('services.sermepa.merchant_code'));
    $g->setMerchantKey(config('services.sermepa.merchant_key'));
    $g->setTerminal(config('services.sermepa.terminal'));
    $g->setCurrency(config('services.sermepa.currency'));
    $g->setTestMode(config('services.sermepa.test'));
    return $g;
});

Persist the order before redirecting. The orderNumber you send is your only handle when the callback arrives. Store it on the order row, with status = pending, before you build the purchase request. Treat the amount and order number as the primary key for reconciling the callback.

Handle notify and return separately — the notify is authoritative. Redsys POSTs server-to-server (notify) and also redirects the browser (return). Users close tabs; networks drop returns. Your state machine should advance on the notify, and the return URL should only poll for the updated status and render a result page.

public function notify(Request $request, PaymentService $payments)
{
    $response = app('sermepa')->completePurchase()->send();
    // `$response->isSuccessful()` verifies the Redsys signature and result code
    $payments->markFromCallback($response); // idempotent, keyed on orderNumber
    return response('OK', 200);
}

Idempotency is mandatory. Redsys retries notify. Wrap the state transition in a transaction guarded by the order status (pending → paid), and return 200 OK on the duplicate rather than reprocessing or re-fulfilling.

Wrap it in a PaymentService. Keep controller code free of Omnipay types so a fork or gateway swap doesn't ripple through the codebase:

class PaymentService
{
    public function start(Order $order): RedirectResponse { /* ... */ }
    public function complete(array $post): PaymentResult { /* validates + maps */ }
}

Use a sandbox spike with a test merchant contract. Redsys sandbox credentials and the "secret key" are issued per environment; production keys are a different value. Test mode only changes the endpoint, not the key, so a real secret in test mode still fails signature checks.

Fork-and-own pattern for production. Vendor the source under packages/, add contract tests around signature generation and callback validation, and keep a thin composer.json path-repository. Given 21 stars and 0 dependents, this is the realistic adoption pattern.

Gotchas and Tips

merchant_key is not your public key — it is the base64 secret from the bank, used with a 3DES-derived key. The signature scheme (Ds_MerchantParameters base64 + HMAC-SHA256 over a key derived from the order number via 3DES) is the single most error-prone part. A bad signature produces an opaque Redsys error page with no diagnostic — the callback simply never validates. If completePurchase() always fails, log the raw POST, the computed signature, and the expected one; the failure is nearly always key/order-number mismatch, not the driver.

orderNumber is not free-form. Redsys requires it to start with 4 digits, be 4–12 characters, and be unique across the merchant. It must be stable — the same value flows back in the callback and is used to derive the encryption key. Don't send an auto-increment id if it can restart or collide across terminals; derive a deterministic format (e.g. date('ymd') . str_pad($id, 6, '0')) and store it.

Amount formatting. Send a decimal string with a dot (12.34), not cents and not a locale-formatted comma. number_format($cents / 100, 2, '.', '') is the safe recipe. Redsys treats it in major currency units.

Currency is a numeric ISO code (978 EUR), not a symbol or 'EUR'. Wrong code → signature valid but transaction rejected.

Test mode vs. environment. setTestMode(true) swaps the endpoint URL to the sandbox; it does not swap keys. Configure sandbox credentials separately and never let a production key sit in the test config that a deploy can pick up.

getRedirectData() vs. getRedirectResponse(). If you're rendering the form yourself (e.g. inside Blade rather than returning the Symfony response), you need the POST target and the Ds_MerchantParameters / Ds_Signature fields, plus Ds_SignatureVersion. Get these from getRedirectData(), not by poking at protected properties.

Duplicate and out-of-order callbacks. Redsys can send the notify before the user lands on the return URL, and retries can arrive out of order. Your callback handler must tolerate the notify arriving twice and the return arriving after it. Never let the return handler overwrite a paid status.

Verify amounts in the callback. Signed != correct. Confirm the amount and currency in the validated response match what you expected for that order number; this catches tampering and order-number mixups.

3DS/SCA lives on the Redsys side, not in the driver. The redirect flow supports the challenge, but you won't find SCA negotiation in this package — abandonments surface as a Redsys result code on the callback. Map result codes to your own status and surface the message.

Debugging the redirect. When the form POST lands on Redsys' error page, it's almost always one of: wrong endpoint (test vs. prod), bad merchant/terminal, malformed order number, or invalid signature. Check in that order; the driver is rarely the culprit once it has been forked and pinned.

Dependency risk is real. Verify PHP 8.x compatibility and the omnipay/common constraint before the first composer require. With no CI and 0 dependents, assume the source needs type fixes and expect to fork. Pin the tag, own the fork, and add a PaymentService seam so the driver is replaceable.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
elmogy/larafast
xeviant/laravel-paystack
gorilladash/laravel-website-sdk
martialbe/flysystem-oss
chondal/3312client
jeroen-g/flickr
opscale-co/nova-genbi
itsjustvita/laravel-retention
tequilarapido/trackit
gregsphotography/site-builder-filament
cabaoctavio/laravel-query-log
gregsphotography/site-builder-components
io-digital/clickatell
snowsoft/laravel-admin
oxygen/auth
relicsoftware/barakah-payment-gateway
tobya/work-with-composer
equipc/laravel-email-validator
equipc/laravel-email-database-log
adedsec/nova-translated-labels