createch / paycorp-sampath-vault
Sampath Bank (Paycorp) Internet Payment Gateway client for Laravel and plain PHP: hosted redirect payments, real-time payments and card tokenisation (vault).
Requires
- php: ^7.3 || ^8.0
- ext-curl: *
- ext-json: *
- illuminate/support: ^5.5 || ^6.0 || ^7.0 || ^8.0 || ^9.0 || ^10.0 || ^11.0 || ^12.0 || ^13.0
Requires (Dev)
- orchestra/testbench: ^3.5 || ^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.0 || ^10.0 || ^11.0
- phpunit/phpunit: ^9.5.10 || ^10.5 || ^11.0 || ^12.0 || ^13.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Sampath Bank (Paycorp) Internet Payment Gateway client for Laravel and plain PHP: hosted redirect payments, real-time payments, and card tokenisation (vault).
Compatibility
One release line covers every supported Laravel version. There is no separate
tag per framework version to pick between — install ^2.0 and Composer resolves it
against whatever Laravel you are on.
| Package | PHP | Laravel | Status |
|---|---|---|---|
^2.0 |
7.3 – 8.4 | 5.5 · 6 · 7 · 8 · 9 · 10 · 11 · 12 · 13 | Supported |
^1.4 |
5.6 – 8.1 | 5.5 – 8 | End of life, see the security notice below |
This breadth is possible because the package touches only ServiceProvider,
Facade and the config repository — APIs that have not changed across that whole
range.
Every version in the table is exercised in CI, in one of two ways:
| Laravel | How CI verifies it |
|---|---|
| 6 · 7 · 8 · 9 · 10 · 11 · 12 · 13 | The full suite under orchestra/testbench, on PHP 7.4 → 8.4, with both --prefer-lowest and --prefer-stable |
| 5.5 · 5.8 | A bootstrap check that registers the provider against a real Illuminate container, resolves every binding and the facade, and re-signs all 90 frozen golden HMAC vectors |
Laravel 5.5–5.8 gets the second treatment because orchestra/testbench 3.5 pins
phpunit ^6.5, while this suite needs assertions added in phpunit 9.1 — an
incompatibility between two dev tools, not a limitation of the package. The
bootstrap check runs with --no-dev, so neither tool is installed, and it
asserts the thing that actually matters on an old framework: that the signature
bytes are identical. A digest that differs is a payment the gateway rejects.
Every shipped file is additionally linted on PHP 7.3 and 7.4, the declared floor.
Upgrading from 1.x: the wire bytes are unchanged
2.x rewrote the signing and transport internals, so the obvious question is
whether the gateway still sees the same request. It does, and that is checked
rather than asserted: tests/Differential/wire-parity.sh runs v1.4 and the
current tree side by side, each posting through real curl to a local capture
server, and compares the actual byte stream for twelve scenarios — the nine
signed operations plus non-ASCII, empty-optional and zero-fee edge inputs. The
comparison covers the request body with a field-level and key-order diff, plus
the HMAC, AUTHTOKEN and Content-Type headers. Only msgId and
requestDate are replayed, since those are the two fields 1.x generated
nondeterministically.
It runs in CI on PHP 7.4 and 8.4. If an upgrade ever changed what the bank receives, that job fails before the release does.
A caveat about Laravel 11 and earlier
Laravel 5.5 through 11 are end of life, and every release in those lines
carries unpatched security advisories. Modern Composer refuses to install an
advisory-affected package, so on those framework versions composer install
fails before this package is even considered:
Root composer.json requires laravel/framework 11.*, found laravel/framework[...]
but these were not loaded, because they are affected by security advisories
That is a property of the framework, not of this package — the constraint here
does not exclude them, and the CI matrix proves the API still works by relaxing
policy.advisories.block for those rows only. But if you are on Laravel 11 or
below, upgrading the framework is the actual fix; this package supporting it
does not make running it safe.
Laravel 12 and 13 install and test with the advisory policy fully enabled.
Security notice for 1.x users
Releases up to and including v1.4 committed live production credentials to
the repository, and those tags are published on Packagist. If you ever installed
this package at 1.x, treat the following as compromised and ask Sampath/Paycorp to
rotate them:
- the
authtokenandhmac_secretthat shipped insrc/config/PaycorpSampathVault.php - the two merchant client ids that shipped alongside them
- the credentials in the
src/Paycorplib/GatewayIT/*sample scripts, one of which pointed at the production endpoint
Rewriting git history does not undo this — the tags are already mirrored by
Packagist and by everyone who ran composer install. Rotation is the only fix.
1.x also disabled TLS certificate verification (CURLOPT_SSL_VERIFYPEER => false),
so every card number and CVV it sent travelled over a connection that accepted any
certificate. 2.x verifies certificates by default. See UPGRADE.md.
Requirements
- PHP 7.3+
ext-curl,ext-json- Composer
Installation
composer require createch/paycorp-sampath-vault
The service provider and the PaycorpSampathVault facade alias are registered
automatically by Laravel package discovery. If you previously listed them by hand
in config/app.php, you can leave those entries in place — duplicate
registration is a no-op.
Configuration
Add to .env:
SAMPATH_SERVICE_ENDPOINT=https://sampath.paycorp.com.au/rest/service/proxy SAMPATH_AUTHTOKEN= SAMPATH_HMAC= SAMPATH_CURRENCY=LKR SAMPATH_TOKENIZE_CLIENT_ID= SAMPATH_PURCHASE_CLIENT_ID= SAMPATH_RETURN_URL=https://your-app.test/payments/return
To change timeouts, pin a timezone, or supply a CA bundle, publish the config file:
php artisan vendor:publish --tag=paycorp-sampath-vault-config
Every setting and what it does is documented inline in
config/paycorp-sampath-vault.php.
env()is called only inside that config file. Do not readSAMPATH_*withenv()from your own application code: underphp artisan config:cacheit returnsnull. That was a real bug in 1.x and it only showed up in production.
Usage
Hosted payment page
Open a session, redirect the cardholder, then settle when they return.
use createch\PaycorpSampathVault\PaycorpSampathVault; $gateway = app(PaycorpSampathVault::class); $result = $gateway->initRequest([ 'clientRef' => (string) $order->id, 'comment' => 'Order ' . $order->id, 'total_amount' => 1010, // cents 'service_fee_amount' => 0, 'payment_amount' => 1010, ]); if (! $result['status']) { report(new RuntimeException($result['msg'])); return back()->withErrors('Payment could not be started.'); } session(['paycorp_reqid' => $result['reqid']]); return redirect()->away($result['payment_page_url']);
Paycorp redirects back to SAMPATH_RETURN_URL. Settle the session there:
$result = $gateway->completeRequest(['reqid' => $request->query('reqid')]); if ($result['status'] && $result['ResponseCode'] === '00') { // Store $result['Token'] to charge this card again later without the PAN. $order->markPaid($result['TxnReference'], $result['Token']); }
Real-time payment against a stored token
The Token from completeRequest() lets you charge the same card again without
ever handling the card number — this is the point of the vault.
$result = $gateway->realTimePayment([ 'clientRef' => (string) $order->id, 'comment' => 'Renewal ' . $order->id, 'token' => $customer->paycorp_token, 'expire_at' => $customer->paycorp_expiry, // e.g. 1228 'amount' => 1010, // cents ]);
Handling the three possible outcomes
This is the most important change in 2.x. A payment call has three results, not two:
$result = $gateway->realTimePayment($data); if ($result['status']) { // The gateway answered. Inspect ResponseCode: '00' is approved, // anything else is a decline with ResponseText explaining why. return $result['ResponseCode'] === '00' ? 'approved' : 'declined'; } if ($result['outcome'] === 'unknown') { // The request may or may not have been processed: a dropped connection, // a timeout, an unreadable response. DO NOT retry blindly and DO NOT // tell the customer it failed. Queue it for reconciliation against the // Paycorp settlement report. ReconcilePaycorpTransaction::dispatch($order, $data['clientRef']); return 'pending'; } // outcome === 'failed': the gateway definitively rejected it, no money moved. return 'failed';
In 1.x a dropped connection returned 'status' => true with blank fields, so a
network outage was indistinguishable from a completed payment.
Exceptions instead of status arrays
For new code, typed exceptions are easier to get right than checking array keys:
SAMPATH_THROW_ON_ERROR=true
use createch\PaycorpSampathVault\Exceptions\ConfigurationException; use createch\PaycorpSampathVault\Exceptions\GatewayErrorException; use createch\PaycorpSampathVault\Exceptions\MalformedResponseException; use createch\PaycorpSampathVault\Exceptions\PaycorpException; use createch\PaycorpSampathVault\Exceptions\TransportException; try { $result = $gateway->realTimePayment($data); } catch (TransportException | MalformedResponseException $e) { // Outcome UNKNOWN. Reconcile. } catch (GatewayErrorException $e) { // The gateway rejected the request: bad HMAC, unknown clientId. Log::warning('Paycorp rejected the request', ['code' => $e->getErrorCode()]); } catch (ConfigurationException $e) { // Credentials missing, or a non-HTTPS endpoint. } catch (PaycorpException $e) { // Anything else from this package. }
Exception messages are passed through the redactor, so they are safe to log.
Raw card details
PaycorpSampathRealTimePayment accepts a PAN and CVV directly. This brings your
whole application into PCI DSS scope. Prefer the tokenised flow above.
use createch\PaycorpSampathVault\PaycorpSampathRealTimePayment; $result = app(PaycorpSampathRealTimePayment::class)->realTimePayment([ 'card_type' => 'VISA', 'card_holder_name' => 'A N OTHER', 'card_number' => $request->input('card_number'), 'secure_id' => $request->input('cvv'), 'expire_at' => '1228', 'amount' => 1010, 'clientRef' => (string) $order->id, ]);
Never log, cache, or persist these values anywhere.
Vault operations
storeCard, retrieveCard, updateCard, verifyToken and deleteToken are on
the low-level client:
use createch\PaycorpSampathVault\Paycorplib\GatewayClient\GatewayClient; use createch\PaycorpSampathVault\Paycorplib\GatewayClientVault\DeleteTokenRequest; $request = new DeleteTokenRequest(); $request->setClientId(config('paycorp-sampath-vault.tokenize_client_id')); $request->setToken($customer->paycorp_token); $response = app(GatewayClient::class)->getVault()->deleteToken($request);
deleteToken, updateCard and verifyToken were fatal on PHP 8 in 1.x.
Testing your own integration
The package ships a fake transport, so you can test your payment flows without touching the live gateway:
use createch\PaycorpSampathVault\Contracts\HttpTransportInterface; use createch\PaycorpSampathVault\Testing\FakeHttpTransport; public function test_a_successful_payment_marks_the_order_paid(): void { $transport = (new FakeHttpTransport())->willRespondWithJson([ 'responseData' => [ 'txnReference' => 'TXN-1', 'responseCode' => '00', 'responseText' => 'APPROVED', ], ]); $this->app->instance(HttpTransportInterface::class, $transport); $this->post('/checkout', [...])->assertOk(); $this->assertTrue($this->order->fresh()->isPaid()); // Assert on what actually went to the gateway. $this->assertSame(1010, $transport->lastRequestPayload()['requestData']['transactionAmount']['paymentAmount']); }
willFail() simulates the network failure that used to look like a success:
$transport = (new FakeHttpTransport())->willFail('connection reset'); // ... assert your code reconciles rather than marking the order paid or failed.
Architecture
Every collaborator is an interface bound in the container, so you can replace any one of them without forking the package:
| Contract | Default | Purpose |
|---|---|---|
HttpTransportInterface |
CurlTransport |
The only class that touches the network |
SignerInterface |
Sha256HmacSigner |
Request HMAC |
EncoderInterface |
Latin1Encoder |
Bit-exact utf8_decode() replacement |
ClockInterface |
SystemClock |
requestDate stamping |
MessageIdGeneratorInterface |
RandomMessageIdGenerator |
CSPRNG msgId |
RedactorInterface |
SensitiveDataRedactor |
Strips PANs, CVVs, tokens, secrets |
$this->app->bind(HttpTransportInterface::class, MyInstrumentedTransport::class);
Why Latin1Encoder exists
Every HMAC this package has ever sent was computed over utf8_decode($payload),
not over the UTF-8 bytes. utf8_decode() was deprecated in PHP 8.2 and removed
in PHP 9. Signing the UTF-8 bytes instead would change the digest for every
request containing a non-ASCII comment or cardholder name, and the gateway would
reject them.
Latin1Encoder reimplements PHP's php_next_utf8_char() state machine, including
the non-obvious part: how many bytes a decode failure consumes depends on whether
the following bytes look like continuation bytes or a fresh character. It is
verified byte-identical to utf8_decode() across 163,488 well-formed and
malformed inputs, and 90 frozen HMAC golden vectors — captured from the real
utf8_decode() — gate every change to it.
mb_convert_encoding() is not a drop-in substitute: it agrees on well-formed
input but diverges on malformed input, and it depends on the global
mbstring.substitute_character ini setting, which an application can change
underneath you and so alter every signature.
Development
composer install vendor/bin/phpunit # all suites vendor/bin/phpunit --testsuite=Unit # Unit | Contract | Integration
Static analysis runs at two levels: the whole package at PHPStan level 5, and everything written for 2.x at level 9 (maximum).
phpstan analyse -c phpstan.neon.dist phpstan analyse -c phpstan-strict.neon.dist
No test contacts the live gateway. Response fixtures are recorded once and
scrubbed; the suite is driven entirely through FakeHttpTransport.
Note
Read the Paycorp technical specification and understand the workflow before using this package. It handles the transport and the signing; the payment logic and reconciliation are yours.
The PAYMENT_BATCH operation is deliberately not implemented. It never worked in
1.x and now throws UnsupportedOperationException rather than sending a guessed
wire format for a batch of real debits. Open an issue with the Paycorp batch
specification if you need it.
License
MIT