Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Fortnox Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  1. Install, and confirm your app is on Laravel 12 or 13 (11 is no longer supported as of 0.2.0).
  2. Register the Fortnox application in the Fortnox developer portal and set the callback URL to something stable, e.g. https://yourapp.test/auth/fortnox/callback.
  3. Add credentials to config/services.php:
'fortnox' => [
    'client_id'     => env('FORTNOX_CLIENT_ID'),
    'client_secret' => env('FORTNOX_CLIENT_SECRET'),
    'redirect'      => env('FORTNOX_REDIRECT_URI'),
],
  1. Wire two routes:
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.

Implementation Patterns

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.

Gotchas and Tips

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.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core
nodisrupt/laravel-agent
sierratecnologia/media-manager