bernskioldmedia/laravel-fortnox
A Laravel package for integrating with the Fortnox accounting API, offering a fluent client and resource models to work with invoices, customers, and other bookkeeping data. It simplifies authentication and API calls so you can sync financial data without boilerplate.
This package registers a Laravel Socialite driver for Fortnox, so you authenticate against Fortnox's OAuth 2 server using the same Socialite::driver(...) API you already use for GitHub or Google. The value is not the login itself — Fortnox is rarely an identity provider for end users — but the token pair you get back, which you then use to call the Fortnox accounting API on a company's behalf.
Minimum viable setup:
https://yourapp.test/auth/fortnox/callback.config/services.php:'fortnox' => [
'client_id' => env('FORTNOX_CLIENT_ID'),
'client_secret' => env('FORTNOX_CLIENT_SECRET'),
'redirect' => env('FORTNOX_REDIRECT_URI'),
],
Route::get('/auth/fortnox', fn () => Socialite::driver('fortnox')->redirect());
Route::get('/auth/fortnox/callback', function (Request $request) {
$user = Socialite::driver('fortnox')->user();
// Persist the token pair — this is the real payload.
FortnoxConnection::updateOrCreate(
['tenant_id' => $user->getId()],
[
'access_token' => $user->token,
'refresh_token' => $user->refreshToken,
'expires_at' => now()->addSeconds($user->expiresIn ?? 3600),
],
);
return redirect('/dashboard');
});
Where to look first: the registered driver name (fortnox), the config key it expects in config/services.php, and FortnoxSocialiteProvider::resolve() — the typed accessor added in 0.2.0. Reach for resolve() whenever you need the concrete provider instance with IDE completion rather than the AbstractProvider returned by Socialite::driver().
First real use case: a "Connect Fortnox" button in account settings that stores a per-tenant token, then a queued job that refreshes it before each sync. That is the shape 90% of integrations take.
Treat the callback as an authorisation event, not a login. The Socialite user object's getId() gives you the Fortnox tenant/company identifier — store it. Map it to your own Team/Tenant model and never assume one user equals one Fortnox company.
Token refresh is the hot path. Fortnox access tokens are short-lived (on the order of an hour), so every real implementation needs a refresh routine. Prefer refreshToken() over hand-rolling a Guzzle call, since the provider already knows the token endpoint, client credentials, and grant type:
$connection = FortnoxConnection::where('expires_at', '<', now()->addMinutes(5))->first();
$response = FortnoxSocialiteProvider::resolve()->refreshToken($connection->refresh_token);
$connection->update([
'access_token' => $response->token,
'refresh_token' => $response->refreshToken ?? $connection->refresh_token,
'expires_at' => now()->addSeconds($response->expiresIn ?? 3600),
]);
Wrap the refresh in a small service and call it from a withFortnoxToken() helper or an HTTP client middleware, so call sites never think about expiry. Refresh before expiry (a 5-minute buffer), not on a 401 — retrying after a 401 means the original request already failed and you now need retry logic.
Use resolve() for typed access. Because Laravel 13 rebinds Socialite extend() closures to the manager, the driver is not the service provider — $this->clientId style access inside a closure is gone. Consume the package through resolve() and keep your own provider subclassing out of it.
Queue the API work. Fetching customers, vouchers, or invoices should happen in jobs, not in the request that handles the callback. The callback should do nothing but persist tokens and redirect.
Testing. Fortnox is a real OAuth server, so tests must not hit it. Fake the HTTP layer (Http::fake() against the token and API hosts) and drive the callback route directly with a signed state or a bypassed Socialite::fake('fortnox') where your Socialite version supports it. Assert on the persisted refresh_token, not just on the redirect — that is the part that silently breaks in production.
Multi-tenant pattern. In a SaaS, keep one FortnoxConnection row per tenant and resolve the correct one from the authenticated user's current team. Cache the access token per tenant with a TTL shorter than expires_at to avoid a DB read on every API call, but always read the refresh token from the database — see the gotcha below.
Laravel 11 is gone. 0.2.0 requires Laravel 12 or 13. If you are upgrading a Laravel 11 app, do the framework upgrade first — otherwise composer will resolve you onto 0.1.x and you will not get the Laravel 13 driver fix.
The 0.2.0 bug is worth understanding, not just installing. On Laravel 13, extend() callbacks are rebound to the SocialiteManager, so any driver closure referencing $this (expecting the service provider) resolves against the wrong object. The symptom is "No Socialite driver was specified." on every call — login, refresh, and all API traffic — which reads like a config error but is not. If you are patching around this on 0.1.x by re-registering the driver in your own AppServiceProvider, delete that workaround after upgrading; leaving both registered can mask the fix and give you a stale driver instance.
Refresh tokens are likely single-use. Fortnox rotates refresh tokens. Always persist the new refresh token returned by the refresh call, and do it in the same transaction/update as the access token. If you only update access_token, the integration works until the next refresh and then dies with an invalid-grant error that looks like a credentials problem. Also guard against concurrent refreshes on the same connection (queue ShouldBeUnique, or a lockForUpdate) — two parallel jobs refreshing the same single-use token will strand the connection.
Redirect URI must match byte-for-byte. Fortnox compares the callback against the value registered in the portal. http vs https, a trailing slash, or localhost vs 127.0.0.1 will all fail, usually with an opaque error on the redirect step rather than the callback.
Sandbox and production are separate registrations. Different client IDs, secrets, and often different base hosts. Do not share a .env value between environments; you will eventually sync a sandbox token into production data.
Scope creep is a one-way door. Scopes are fixed at authorisation time. If you did not request the scope you need, refreshing will not grant it — you need a fresh authorisation round-trip and a re-consent from the customer. Decide your scope set up front and store it alongside the connection so you can detect and prompt for re-authorisation when you add scopes later.
Honour rate limits. Fortnox applies per-client request throttling (in the low single-digit requests per second). With Http::retry() on 429 plus a queue rate limiter, this is fine; without it, a bulk sync will burn through the limit and start failing other tenants' jobs. Set ->retry(3, fn ($attempt) => $attempt * 2000) and use RateLimiter on the Fortnox queue.
Config cache surprises. If config/services.php reads env() and you run config:cache in deploy, a missing .env key becomes null silently and you get a redirect with client_id=. Add a boot-time guard or a config() sanity check in your deploy script.
Extension point: if you need extra Fortnox endpoints or custom grant parameters, subclass the provider and register it against the fortnox driver name in a service provider — but do this after confirming 0.2.0 fixes your issue, since the $this rebinding change is exactly the kind of thing a homegrown workaround will conflict with.
How can I help you explore Laravel packages today?