What Laravel SISP 3.0 broke, and why
A merchant reference was a timestamp. A refund never left the process. The first migration could never finish on MySQL. 3.0 closes all three.
Until the 2.2 patch, anyone who knew the merchant reference and session of a pending payment could mark it failed. A POST to the callback route carrying a fingerprint that did not match was enough: the transaction moved to failed, its invoice was cancelled, PaymentFailed fired, the sender’s transaction id and message type were stored, and the refusal fields SISP had written were cleared.
The reference was R20260523235959. A prefix and a timestamp to the second.
Laravel SISP 3.0 changed 141 files across 128 commits. Seven of those commits carry a !. Six are fixes, the seventh hardens a default, and none of them adds surface.
Whoever knocks is not SISP
Three of the seven breaks trust the incoming request less than 2.x did.
The default generators append ten random uppercase characters to the timestamp:
// src/Actions/Generators/MerchantReferenceGeneratorAction.php
public function __invoke(): string
{
return 'R'.now()->format('YmdHis').Str::upper(Str::random(10));
}
R20260523235959 becomes R20260523235959K7M2QX9TBV. Fifteen characters to twenty-five, and two payments started in the same second stop colliding, which was the other half of the same bug.
The callback validates the fingerprint before the pipeline runs, and a rejected callback writes nothing at all:
// src/Http/Controllers/CallbackController.php
if ($this->rejectsFingerprint($payload)) {
Log::warning('SISP callback rejected: the fingerprint does not match.', [
'merchant_ref' => $payload->merchantRef,
]);
return redirect(config('sisp.redirect_url', '/'));
}
The transaction, its attempt, its invoice and the request metadata are left alone, and no event fires. The fix itself shipped in the 2.2 patch; 3.0 carries it as a declared break, because anything that watched for merchant_response = 'invalid_callback_fingerprint' to spot a forged callback now watches that log line instead.
The result page is the third break. It renders only behind a temporary signed URL valid for thirty minutes, and its Inertia prop no longer carries merchant_session. Reference plus session is still enough to cancel a pending payment through the unsigned cancellation callback, and the page was handing over one of the two. The unpredictable reference is the mitigation, so an install that keeps the 2.x generators keeps the hole.
The refund never left the process
RefundTransactionAction built a signed RefundRequest, stored it, moved the transaction to refunded, and dispatched TransactionRefunded. It made no outbound request. It could not: the only HTTP client in this package is TransactionStatusClient, which asks SISP about a transaction’s status and nothing else.
A listener on that event had every reason to tell the customer the money was on its way. It was not.
So 3.0 renames the operation rather than pretending to send it. RefundTransactionAction becomes RecordRefundAction, TransactionRefunded becomes RefundRecorded, RefundBuilder::process() becomes RefundBuilder::record(), and the route answers Refund recorded. Issue it in the SISP back office if you have not already. A test holds that line:
// tests/Actions/RecordRefundReachesNoGatewayTest.php
it('sends nothing to the gateway when a refund is recorded', function (): void {
Http::preventStrayRequests();
Http::fake();
resolve(RecordRefundAction::class)->handle(recordableTransaction(), 100.0, 'customer_request');
Http::assertNothingSent();
});
A SISP refund is issued in the back office and confirmed in the daily reconciliation file. The package keeps the books. Saying that in the class name costs one breaking change and removes an entire category of support conversations.
A status lives in a row, so lock the row
Cancellation refused completed and cancelled, which left failed and refunded transactions free to be moved to cancelled. 3.0 refuses all four. The row lock is older: 2.2 already read the transaction back with lockForUpdate() inside a database transaction and checked the locked row, so two concurrent cancellations could not both win. What 3.0 adds on top is the fourth and fifth refused status, and the invoice moving in the same transaction.
InvoiceStatus gains a refunded case: a full refund moves the invoice there; a partial refund leaves both the transaction and the invoice where they are. An exhaustive match over that enum without a default arm raises UnhandledMatchError the first time a refunded invoice reaches it. A report that silently stopped counting those invoices would have been the worse outcome.
The migration that never ran on MySQL
The initial migration indexed four columns together:
// database/migrations/create_laravel_sisp_table.php
$table->index(['merchant_ref', 'merchant_session', 'status', 'message_type']);
Four varchar(255) columns in utf8mb4 need 4080 bytes of key. InnoDB allows 3072. No fresh install could finish migrating on MySQL or MariaDB for the whole of 2.x, and nobody saw it because the pipeline ran SQLite, which enforces no key-length limit at all.
The index is merchant_session alone now, and a new job runs the migrations against MySQL 8 and PostgreSQL 16 on every push. It found a second defect on its first run: migrate:rollback died on MySQL with error 1553. foreignId()->constrained() had MySQL create its own index on transaction_id, the guard saw that index and skipped the dedicated one, and the foreign key ended up depending on the unique index the rollback was dropping.
A pipeline that runs only the engine nobody deploys tests itself, not the installs it is supposed to protect.
Seven breaks and no aliases
The fair objection is that seven breaking changes in one release is a cost pushed onto everybody who installed 2.x, and that class_alias with a deprecation cycle would have been kinder.
For the renames it would have been worse. Code calling event(new TransactionRefunded(...)) fatals on a class that no longer exists, and a fatal is a fifteen-minute fix. A listener left on an aliased old name keeps compiling and stops firing, with no error and no log, on a refund path somebody is counting on. The upgrade guide asks for three things in order: rename the dispatchers, drain the queue before installing, fix the test doubles. The event’s shape has not changed, so nothing else moves.
A payment package has no business owning a verb it never performs.