nesthus / vipps-laravel
Unofficial Laravel bridge for nesthus/vipps-php: config with enforced timeouts, webhook middleware and events, artisan commands, and a Socialite driver for Vipps Login.
Requires
- php: ^8.3
- guzzlehttp/guzzle: ^7.8
- illuminate/cache: ^11.0 || ^12.0 || ^13.0
- illuminate/console: ^11.0 || ^12.0 || ^13.0
- illuminate/contracts: ^11.0 || ^12.0 || ^13.0
- illuminate/http: ^11.0 || ^12.0 || ^13.0
- illuminate/routing: ^11.0 || ^12.0 || ^13.0
- illuminate/support: ^11.0 || ^12.0 || ^13.0
- laravel/socialite: ^5.12
- nesthus/vipps-php: ^0.2
- psr/log: ^3.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- orchestra/testbench: ^9.0 || ^10.0 || ^11.0
- pestphp/pest: ^3.0 || ^4.0
- pestphp/pest-plugin-laravel: ^3.0 || ^4.0
This package is auto-updated.
Last update: 2026-08-16 00:01:14 UTC
README
Unofficial Laravel bridge for nesthus/vipps-php:
config with enforced timeouts, a Vipps facade, webhook middleware and events,
a vipps:webhooks artisan command, and a Socialite driver for Vipps Login.
Important
This is an unofficial package. It is not affiliated with, endorsed or supported by Vipps MobilePay AS. Vipps and MobilePay are trademarks of Vipps MobilePay AS. When presenting the payment option to your users, follow the official brand guidelines: https://brand.vippsmobilepay.com/.
What the bridge adds
The SDK stays framework-free; this package does the Laravel wiring so your app never touches PSR plumbing:
- One
Nesthus\Vipps\Vippssingleton in the container, built fromconfig/vipps.php, behind a facade. Bindings are lazy — an app with no Vipps credentials boots fine and only fails (loudly, with the missing field named) when something actually resolves Vipps. - Mandatory transport deadlines. The provider refuses non-positive timeouts at resolve time. Guzzle waits forever by default, and a payment call with no deadline can wedge a queue worker for good — so the deadline is not an option you may set, it is config you may only tune.
- Access tokens shared between workers through a PSR-16 bridge over a Laravel cache store, instead of every queue worker minting its own.
- A one-line webhook receiver — signature-verified deliveries become Laravel events; forged ones are rejected before your code runs.
php artisan vipps:webhooksto register/list/delete subscriptions.- Socialite driver
vippsfor Vipps Login (OIDC).
This README covers the Laravel surface. For API semantics — reserve/capture, agreements and charges, idempotency keys, error handling — the SDK README is the reference, and everything there applies unchanged here.
Requirements
- PHP 8.3+
- Laravel 11–13 (CI exercises 12 and 13)
laravel/socialite^5.12 (installed automatically — it is a dependency)
Install
composer require nesthus/vipps-laravel
Both packages are on Packagist (nesthus/vipps-laravel,
nesthus/vipps-php), so a
plain composer require is all it takes. The service provider and the
Vipps facade alias are auto-discovered.
Configuration
php artisan vendor:publish --tag=vipps-config
All four credential values come from the sales unit's developer section in the
merchant portal — one set per
environment. Test keys only work against the test host and vice versa, so
VIPPS_ENVIRONMENT must match the keys.
| Variable | Default | Meaning |
|---|---|---|
VIPPS_CLIENT_ID |
(empty) | Sales unit client id |
VIPPS_CLIENT_SECRET |
(empty) | Sales unit client secret |
VIPPS_SUBSCRIPTION_KEY |
(empty) | Ocp-Apim-Subscription-Key |
VIPPS_MERCHANT_SERIAL_NUMBER |
(empty) | Sales unit MSN |
VIPPS_ENVIRONMENT |
test |
test (apitest.vipps.no) or production (api.vipps.no) |
VIPPS_TIMEOUT |
15 |
Whole-request deadline, seconds. Mandatory — see below |
VIPPS_CONNECT_TIMEOUT |
5 |
TCP/TLS handshake deadline, seconds. Mandatory — see below |
VIPPS_SYSTEM_NAME |
app name | Sent as Vipps-System-Name; identifies your app in Vipps' logs |
VIPPS_SYSTEM_VERSION |
Laravel version | Sent as Vipps-System-Version |
VIPPS_WEBHOOK_SECRET |
(empty) | Signing secret from vipps:webhooks register. Empty means "webhooks not configured": the receiver answers 404 |
VIPPS_TOKEN_CACHE_STORE |
app default store | Cache store for shared access tokens |
VIPPS_LOGIN_REDIRECT_URI |
(empty) | OAuth callback URL for Vipps Login — must exactly match one registered in the portal |
VIPPS_LOGIN_SCOPES |
openid name email phoneNumber |
Space-separated OIDC scopes for the Socialite driver |
Warning
The timeouts are mandatory and enforced. Resolving Vipps with a
non-positive VIPPS_TIMEOUT or VIPPS_CONNECT_TIMEOUT throws a
LogicException immediately. This is deliberate: Guzzle waits forever
by default, and a payment call with no deadline doesn't fail — it silently
wedges the queue worker that made it, which is far harder to notice than a
boot-time exception naming the config key.
Usage
Vipps:: proxies to the container singleton, so the facade, injected
Nesthus\Vipps\Vipps instances and app(Vipps::class) all share one client
and one token cache. Each snippet below is the Laravel-flavoured minimum —
follow the deep links for the full flow, its DTOs and the rules that go with
it (most importantly: mint and persist your own idempotency keys before the
request goes out — the SDK requires one on every mutating call, and
explains why).
Recurring agreements (full guide)
use Nesthus\Vipps\Amount; use Nesthus\Vipps\Laravel\Facades\Vipps; use Nesthus\Vipps\Recurring\Interval; use Nesthus\Vipps\Recurring\NewAgreement; use Nesthus\Vipps\Recurring\Pricing; $created = Vipps::recurring()->createAgreement(new NewAgreement( pricing: Pricing::legacy(Amount::fromMajor(49)), // 49.00 NOK per charge interval: Interval::months(1), productName: 'Premium', merchantRedirectUrl: route('subscription.return'), merchantAgreementUrl: route('subscription.show'), // where the user can manage/cancel ), $idempotencyKey); // Persist $created->agreementId next to your key, THEN: return redirect()->away($created->vippsConfirmationUrl);
The redirect back proves nothing about approval, and an active agreement moves no money by itself — your scheduled job creates every charge. Both rules are covered in the SDK guide.
One-off payments (full guide)
use Nesthus\Vipps\Amount; use Nesthus\Vipps\Epayment\CreatePayment; use Nesthus\Vipps\Laravel\Facades\Vipps; $created = Vipps::epayment()->createPayment(new CreatePayment( amount: Amount::fromMajor(249, 50), // 249.50 NOK — integer minor units, no floats reference: 'order-2026-000123', // your permanent id: 8–64 chars of [a-zA-Z0-9-] returnUrl: route('checkout.return'), ), $idempotencyKey); return redirect()->away($created->redirectUrl); // null only for flows without a browser hop
Login and webhook management
Vipps::login() exposes the raw OIDC module and Vipps::webhooks() the
subscription-management API, but in a Laravel app you rarely call either
directly — the Socialite driver and the
vipps:webhooks command wrap them. Vipps::tokens() and
Vipps::config() complete the facade.
Everything the SDK throws implements the Nesthus\Vipps\Exceptions\VippsException
marker interface — see Errors.
Webhooks
1. Expose the receiver
One line, in a stateless routes file — server-to-server calls need no session and must not hit CSRF:
// routes/api.php Route::vippsWebhooks('/vipps/webhooks');
Note that a fresh Laravel 11/12 app has no routes/api.php until you run
php artisan install:api. If you'd rather not install the API scaffolding,
any group without session/CSRF middleware works the same:
// bootstrap/app.php — or any service provider's boot() Route::middleware('api')->group(function () { Route::vippsWebhooks('/vipps/webhooks'); });
Call the macro once per app: it always registers the fixed route name
vipps.webhooks (which is how vipps:webhooks register finds your callback
URL), so a second call means two routes with the same name — php artisan route:cache then fails with a duplicate-name error.
The macro registers the URI for every HTTP method behind two pieces of middleware:
throttle:vipps-webhooks— a rate limiter the package registers as 120 requests/min per IP, so unauthenticated probes can't buy unlimited body-read + HMAC work. Override it by registering your own limiter under the namevipps-webhooks(in your app service provider'sregister(), which runs before the package'sboot()) — the package only registers its default when the name is still free.- Signature verification — forged or tampered deliveries are answered
401 (with only a stable, leak-free reason slug logged) before any of your
code runs. Any method other than POST answers 404, and while
VIPPS_WEBHOOK_SECRETis empty every request answers 404 — the sameNotFoundHttpExceptiona missing route produces, so scanners probing a freshly certified hostname get no confirmation the endpoint exists (the route registers every method precisely so a POST-only 405/Allowresponse can't leak it either).
The middleware is also available standalone under the alias
vipps.webhook-signature if you'd rather define the route yourself.
Behind a proxy, CDN or ingress? The signature covers the external
request target and Host header exactly as Vipps sent them, so whatever sits
in front of your app must preserve the original host, path prefix and query
string all the way to Laravel. A rewrite anywhere on that path — a CDN
swapping the host, an ingress stripping a path prefix — makes every valid
delivery fail signature verification with a 401.
2. Register it with Vipps
php artisan vipps:webhooks register
With no options it registers the app's own vipps.webhooks route for the
ten recurring.* event types; --url= and repeatable --events= override
both. vipps:webhooks list and vipps:webhooks delete --id=… manage existing
subscriptions; delete asks for confirmation and exits non-zero when it is
declined (in scripts, pass --force to skip the prompt).
Warning
The signing secret is shown exactly once, in the command's output —
Vipps never re-reveals it (listing returns id/url/events only), and the
command deliberately never logs or stores it. Copy it into your .env as
VIPPS_WEBHOOK_SECRET immediately; if you scroll past it, the only recovery
is delete + re-register.
3. Listen
Every signature-verified delivery fires VippsWebhookReceived — including
event types this package has never heard of, because Vipps adds types without
notice and an unknown type must degrade to "generic event fired", never to a
dropped delivery. When the type is recognised, a typed companion event fires
as well, so you can subscribe at whichever granularity fits:
eventType |
Event class (Nesthus\Vipps\Laravel\Events\…) |
|---|---|
| every delivery | VippsWebhookReceived |
recurring.agreement-activated.v1 |
AgreementActivated |
recurring.agreement-rejected.v1 |
AgreementRejected |
recurring.agreement-stopped.v1 |
AgreementStopped |
recurring.agreement-expired.v1 |
AgreementExpired |
recurring.charge-reserved.v1 |
ChargeReserved |
recurring.charge-captured.v1 |
ChargeCaptured |
recurring.charge-canceled.v1 |
ChargeCanceled |
recurring.charge-failed.v1 |
ChargeFailed |
recurring.charge-creation-failed.v1 |
ChargeCreationFailed |
recurring.charge-refunded.v1 |
ChargeRefunded |
The controller answers 204 immediately and does nothing else — persistence, dedupe and business logic are your listeners' job, and two rules keep them honest:
- Queue your listeners (
ShouldQueue). Vipps expects an answer within 10 seconds and treats a timeout as a failure to retry, so anything slow in a synchronous listener turns one delivery into many. - Persist and dedupe before acting. Vipps retries with exponential
backoff for days, and every retry lands on your endpoint again and fires
the same events again — store the payload's
eventIdand skip deliveries you have already processed.
// app/Listeners/RecordChargeCollected.php — auto-discovered by Laravel namespace App\Listeners; use Illuminate\Contracts\Queue\ShouldQueue; use Nesthus\Vipps\Laravel\Events\ChargeCaptured; final class RecordChargeCollected implements ShouldQueue { public function handle(ChargeCaptured $event): void { $eventId = $event->payload['eventId'] ?? null; // Dedupe on the event id BEFORE acting — retries of the same // delivery fire this listener again. if ($eventId === null || $this->alreadyProcessed($eventId)) { return; } // Persist the delivery, then act on $event->payload['agreementId'], // $event->payload['chargeId'], … } }
Vipps Login (Socialite)
The vipps Socialite driver is registered automatically and reads its
credentials from config/vipps.php — no config/services.php entry needed.
Set VIPPS_LOGIN_REDIRECT_URI (it must exactly match a redirect URI
registered in the portal), and note that the sales unit must have Login
activated in the merchant portal before the authorize endpoint will accept
it.
// routes/web.php — the driver keeps OAuth state and the PKCE verifier in the // session, so these routes need the web middleware group. use App\Models\User; use Illuminate\Support\Facades\Auth; use Laravel\Socialite\Facades\Socialite; Route::get('/auth/vipps/redirect', fn () => Socialite::driver('vipps')->redirect()); Route::get('/auth/vipps/callback', function () { $vippsUser = Socialite::driver('vipps')->user(); $user = User::updateOrCreate( ['vipps_sub' => $vippsUser->getId()], // OIDC `sub` — the stable identity; email can change [ 'name' => $vippsUser->getName(), 'email' => $vippsUser->getEmail(), ], ); Auth::login($user, remember: true); return redirect()->intended('/'); });
The requested scopes come from VIPPS_LOGIN_SCOPES (space-separated OIDC
scopes, default openid name email phoneNumber). Only sub is guaranteed;
which other claims arrive follows what the user actually granted — the phone
number, when present, is on $vippsUser->phone_number.
Two Vipps-specific deviations from Socialite's defaults are handled for you:
client authentication is HTTP Basic (client_secret_basic, which Vipps sales
units default to — a secret in the form body fails the token exchange with an
opaque 401), and PKCE is always on.
Two boundaries to know about: SocialiteManager builds each driver once and
caches it together with the request captured at build time, so under Octane
(or any long-lived worker) call Socialite::forgetDrivers() per request —
e.g. from an Octane RequestReceived listener — or the driver keeps serving
the boot-time request. And because the driver deliberately enforces PKCE plus
session-backed state, stateless() and session-less API flows are
unsupported — these routes must stay in the web middleware group.
Token caching across workers
Merchant access tokens are fetched and refreshed automatically; you never handle them. Unlike the bare SDK (whose default cache is per-process), the bridge always stores tokens in a Laravel cache store, so php-fpm workers, queue workers and Octane all share one token instead of each minting its own.
By default that is the app's default cache store; point
VIPPS_TOKEN_CACHE_STORE at another store name when the default isn't shared
by every process that talks to Vipps (for example, a per-host file store
behind multiple servers — use redis).
If Vipps ever answers 401 on a token that should have been valid (revoked
keys, clock trouble), Vipps::tokens()->forget() drops the cached token so
the next call fetches fresh.
Testing your app
Webhook-driven code: don't simulate deliveries — construct the events directly (their whole state is the payload array) and fake the dispatcher to assert wiring:
use Illuminate\Support\Facades\Event; use Nesthus\Vipps\Laravel\Events\ChargeCaptured; // Unit-test a listener by handing it the event: (new RecordChargeCollected())->handle(new ChargeCaptured([ 'eventType' => 'recurring.charge-captured.v1', 'eventId' => 'evt-42', 'agreementId' => 'agr-123', ])); // Or fake the dispatcher and assert your code fires/handles what it should: Event::fake([ChargeCaptured::class]); // … exercise your code … Event::assertDispatched(ChargeCaptured::class);
Outbound calls: Vipps and its API modules are final, so they cannot be
mocked — and shouldn't be. Swap the container binding for a real Vipps
wired to a fake PSR-18 client instead, so the SDK's actual request/response
mapping still runs under test. The SDK ships a ~60-line queue-and-record fake
(tests/Support/FakeHttpClient.php)
you can copy into your suite:
use GuzzleHttp\Psr7\HttpFactory; use Nesthus\Vipps\Vipps; use Nesthus\Vipps\VippsConfig; $fakeHttp = new FakeHttpClient(); $factory = new HttpFactory(); $this->app->instance(Vipps::class, new Vipps( new VippsConfig('client-id', 'client-secret', 'subscription-key', '123456'), $fakeHttp, $factory, $factory, )); $fakeHttp->queueJson(201, ['agreementId' => 'agr-1', 'vippsConfirmationUrl' => 'https://…']); // … exercise your code, then assert on $fakeHttp->lastRequest() — // a full PSR-7 request: method, URI, Idempotency-Key header, body.
For end-to-end testing against real infrastructure, VIPPS_ENVIRONMENT=test
points at Vipps' apitest sandbox, with its own merchant keys and test
users.
Development
No local PHP needed — everything runs in throwaway containers:
docker run --rm -u $(id -u):$(id -g) -v $PWD:/app -w /app composer:2 install docker run --rm -u $(id -u):$(id -g) -v $PWD:/app -w /app php:8.4-cli php vendor/bin/pest docker run --rm -u $(id -u):$(id -g) -v $PWD:/app -w /app php:8.4-cli php vendor/bin/pint --test src tests docker run --rm -u $(id -u):$(id -g) -v $PWD:/app -w /app php:8.4-cli php vendor/bin/phpstan analyse --no-progress
Tests are Testbench-based: the suite boots the real service provider with a fake configured sales unit and exercises actual container wiring, route registration and middleware — no HTTP leaves the machine.
Versioning & license
This is a 0.x release: the public API may still move between minor versions — pin accordingly and read CHANGELOG.md before upgrading. Once the surface has survived real-world use, 1.0 freezes it under semantic versioning.
MIT — see LICENSE.
Built on nesthus/vipps-php, the framework-agnostic SDK this package wraps — its README documents the API modules themselves.