AkiraAkira.dev
4 min read

Laravel SISP 2.0 shipped almost no features

The 2.0 release changed 126 files and added little a user can see. It spent the whole budget on extension points instead.

also in FR PT

Laravel SISP 2.0 changed 126 files across 47 commits, added about 4000 lines and removed 1400. Almost none of that is a feature you can point a customer at.

That was the plan.

A payment package is not judged by how many things it does. It is judged by what happens the first time your checkout needs something the package did not anticipate. In 1.x the answer was usually a fork. In 2.0 the answer is a config array. That is the whole release.

The flow was the private part

Here is the 1.x payment controller constructor:

// src/Http/Controllers/PaymentController.php (1.0.3)
public function __construct(
    private CreateIdempotentPaymentTransactionAction $createPayment,
    private PreparePaymentAction $preparePayment,
    private CreateAndStorePaymentTransactionAction $createTransaction,
    private RenderPaymentFormBasedOnConfigAction $renderForm,
    private CheckRateLimitAction $checkRateLimit,
    private CheckBlacklistAction $checkBlacklist,
    private StoreRequestMetadataAction $storeMetadata,
    private LoadConfig $config,
) {}

Eight actions, and the order they run in is written into the body of __invoke(). Every step is a good class on its own. The sequence is not a class at all. It is 81 lines of controller that you cannot reach.

So if your merchant needed a fraud check between the blacklist lookup and the rate limiter, you had exactly two moves: fork the package, or wrap the route and hope you did not break idempotency. Both are the same bug filed later.

In 2.0 the sequence is data:

// config/sisp.php
'pipelines' => [
    'payment' => [
        Akira\Sisp\Pipelines\Payment\Pipes\EnsureIpIsNotBlacklisted::class,
        Akira\Sisp\Pipelines\Payment\Pipes\EnforceRateLimits::class,
        Akira\Sisp\Pipelines\Payment\Pipes\ApplyPaymentIntent::class,
        Akira\Sisp\Pipelines\Payment\Pipes\BuildPaymentRequest::class,
        Akira\Sisp\Pipelines\Payment\Pipes\PersistTransaction::class,
        Akira\Sisp\Pipelines\Payment\Pipes\CaptureRequestMetadata::class,
    ],
    'callback' => [
        Akira\Sisp\Pipelines\Callback\Pipes\ResolveTransaction::class,
        Akira\Sisp\Pipelines\Callback\Pipes\ValidateFingerprint::class,
        Akira\Sisp\Pipelines\Callback\Pipes\EnsureCallbackMatchesTransaction::class,
        Akira\Sisp\Pipelines\Callback\Pipes\ApplyTransactionStatus::class,
        Akira\Sisp\Pipelines\Callback\Pipes\DispatchPaymentEvents::class,
    ],
],

Eleven pipes, both directions of the flow, reorderable. Your fraud check is a class that implements PaymentPipe and a line in that array. The controller is down to 51 lines and two dependencies, and one of them is the pipeline.

The cost is real and I want to name it. That array is now a public contract. I can no longer reorder those stages between minor versions because someone else’s pipe sits between two of mine. Publishing a seam means you stop owning it.

Builders are for the caller

The second thing 2.0 bought is a way to ask for a payment without knowing how the package stores one.

$paymentRequest = Sisp::payment()
    ->amount(1500.0)
    ->currency('132')
    ->customerEmail('buyer@example.cv')
    ->locale('pt')
    ->build();

$transaction = Sisp::refund($transaction)
    ->amount(500.0)
    ->reason('partial_return')
    ->process();

In 1.x you assembled the value objects yourself, which meant the package’s internal shape was your maintenance problem. Every field I renamed was a change in your code. The builder is not sugar. It is the boundary that lets the value objects keep moving.

RefundBuilder has four methods: amount(), full(), reason(), process(). That is the entire refund surface. Small surfaces are the point.

The bill arrives at upgrade time

Making a private seam public breaks the people who already found the private one. That happened here.

In 1.x, callback validation went through the Sisp facade, so test suites faked it by swapping the service in the container:

// 1.x, no longer works
app()->instance(\Akira\Sisp\Sisp::class, new class {
    public function validateCallback($payload): bool { return true; }
});

2.0 puts a contract there instead, and you bind that:

// 2.0
app()->instance(CallbackFingerprintValidator::class, new class implements CallbackFingerprintValidator {
    public function handle(CallbackPayload $payload): bool { return true; }
});

Every test suite that stubbed callbacks failed on upgrade. I could have kept the old path working alongside the new one. I did not, because two ways to fake a fingerprint check in a payment package is how you end up with a production bind you forgot about. A loud break beats a quiet one when money is involved.

”This is a lot of architecture for a redirect”

That is the honest counter, and it is the one I would raise. SISP is a form post and a callback. Eleven classes to move a customer to a bank page reads like ceremony.

Look at what a pipe is:

// src/Pipelines/Payment/Pipes/EnforceRateLimits.php
final readonly class EnforceRateLimits implements PaymentPipe
{
    public function __construct(private CheckRateLimitAction $checkRateLimit) {}

    public function handle(PaymentContext $context, Closure $next): PaymentContext
    {
        $this->checkRateLimit->handle(identifier: $context->request->ip());

        return $next($context);
    }
}

Twenty-two lines counting the namespace and imports, one dependency, one call. The abstraction is smaller than the controller paragraph it replaced. Nothing was added. The sequence was moved from a place you could not reach to a place you can.

The platform jump came along for the ride: PHP 8.5 and Laravel 13, with #[Bind] and #[Singleton] on the contracts and #[Fillable] on the models. That part is upkeep, not the argument.

Configure it instead of forking it. That is the whole of 2.0.

share