How I structure Laravel support services so I can swap vendors without a refactor
Last year I had one of those slightly sweaty moments: a third-party package we relied on was suddenly the wrong fit. Not broken, not dead. Just wrong. Pricing changed, behaviour changed, and we uncovered a constraint we could not live with.
The unpleasant part was not swapping the package. It was realising how many parts of the app had started to depend on it directly.
If you have ever done a “quick integration” and then found it scattered through controllers, jobs, models, validation rules, tests, and three different helper classes, you know the feeling.
This is how I avoid that in Laravel: I treat these integrations as support services. I start with my own contract, I keep vendor-specific logic in a provider implementation, and I bind the contract into the container in a dedicated service provider.
The payoff is boring in the best way: swapping vendors is a single binding change, not a refactor.
What I mean by support services
In my apps, a support service is anything that:
- talks to the outside world (or a non-trivial vendor package), and
- is likely to change independently of the rest of the application.
Think: image processing, two-factor authentication, PDF generation, payment gateways, email delivery, search, feature flags.
The key rule is simple: application code depends on my interface, not their API.
In my Laravel apps, I keep these under app/Support/... with a structure along these lines:
app/
Support/
TwoFactorAuthentication/
Contracts/
Providers/
Facades/ (optional)
Rules/ (optional)
Concerns/ (optional)
You do not need all of those directories. The important ones are Contracts/ and Providers/.
Start with a contract you own
This interface is the boundary. Everything else in the app is allowed to type-hint it.
Keep it small and shaped around your domain, not the vendor package. If you copy the vendor API verbatim, you have not bought yourself much.
Here is a deliberately minimal TwoFactorAuthentication contract:
<?php
declare(strict_types=1);
namespace App\Support\TwoFactorAuthentication\Contracts;
interface TwoFactorAuthentication
{
public function generateSecretKey(): string;
public function getQrCodeUrl(string $company, string $email, string $secret): string;
public function verify(string $secret, string $code): bool;
}
That is enough for:
- enrolling a user (secret + QR code URL), and
- verifying an OTP during login.
Anything else can be added later, deliberately, as your application needs it.
Implement a provider that speaks “vendor”
The provider implementation is where vendor code is allowed to exist; here, provider means a provider of the two-factor implementation behind your contract, not a Laravel service provider.
In this example, it lives under app/Support/TwoFactorAuthentication/Providers/Google2FA/ and wraps pragmarx/google2fa.
The important design choice here is that the provider implements your contract, not the other way around.
<?php
declare(strict_types=1);
namespace App\Support\TwoFactorAuthentication\Providers\Google2FA;
use App\Support\TwoFactorAuthentication\Contracts\TwoFactorAuthentication;
use PragmaRX\Google2FA\Google2FA;
use Throwable;
final readonly class GoogleTwoFactorAuthentication implements TwoFactorAuthentication
{
public function __construct(
private Google2FA $google2FA,
) {}
public function generateSecretKey(): string
{
return $this->google2FA->generateSecretKey();
}
public function getQrCodeUrl(string $company, string $email, string $secret): string
{
return $this->google2FA->getQRCodeUrl($company, $email, $secret);
}
public function verify(string $secret, string $code): bool
{
try {
return (bool) $this->google2FA->verifyKey($secret, $code);
} catch (Throwable) {
return false;
}
}
}
Two things to notice:
- The rest of the app sees
booland strings. It does not learn about vendor exceptions or types. - Vendor dependencies are injected. That keeps it testable and makes the service provider binding straightforward.
A quick aside: preventing OTP replay
If you care about “a code that was valid 10 seconds ago is still valid now”, you are not wrong. That is a real failure mode for TOTP systems.
Google2FA exposes verifyKeyNewer, which returns false (invalid or already used) or a timestamp value you can persist and feed back in next time (you can read more about it in the projects README).
In my app I cache that timestamp per-secret to make OTP verification idempotent and resistant to simple replay. This is exactly the kind of detail you want trapped inside the provider implementation rather than sprinkled throughout your login flow.
Bind the contract into the container in a service provider
Now we teach Laravel which implementation to provide whenever something asks for the contract.
This is the only place I want to change when I swap vendors.
<?php
declare(strict_types=1);
namespace App\Providers;
use App\Support\TwoFactorAuthentication\Contracts\TwoFactorAuthentication;
use App\Support\TwoFactorAuthentication\Providers\Google2FA\GoogleTwoFactorAuthentication;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Support\ServiceProvider;
use PragmaRX\Google2FA\Google2FA;
final class TwoFactorAuthenticationServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(
TwoFactorAuthentication::class,
fn (Application $app): TwoFactorAuthentication => new GoogleTwoFactorAuthentication(
$app->make(Google2FA::class),
),
);
}
}
To summarise Laravel’s docs: the register method is for container bindings, and singleton means “resolve it once and return the same instance thereafter”.
Then, because this is Laravel 12, register your provider in bootstrap/providers.php:
<?php
use App\Providers\TwoFactorAuthenticationServiceProvider;
return [
// ...
TwoFactorAuthenticationServiceProvider::class,
];
Use it from the app without knowing (or caring) about the vendor
At this point, your application code can type-hint the contract.
Here is the shape I like: a small action class that expresses intent.
<?php
declare(strict_types=1);
namespace App\Actions\Auth;
use App\Models\User;
use App\Support\TwoFactorAuthentication\Contracts\TwoFactorAuthentication;
final readonly class EnableTwoFactorAction
{
public function __construct(
private TwoFactorAuthentication $twoFactor,
) {}
public function handle(User $user): bool
{
return $user->update([
'two_factor_secret' => $this->twoFactor->generateSecretKey(),
]);
}
}
No vendor imports. No vendor types. No “just call the package directly”.
If you want a facade for convenience, that is fine too. But I still like constructor injection for anything non-trivial because it keeps dependencies obvious and makes tests calmer.
Swapping vendors becomes a binding change
When you need to switch from Google2FA to some other implementation, the workflow is:
- create a new provider class under
app/Support/TwoFactorAuthentication/Providers/NewVendor/that implements yourTwoFactorAuthenticationcontract; - update
TwoFactorAuthenticationServiceProviderto bind the contract to the new provider.
Nothing else in the app should change.
That is the whole point.
Contextual binding for standard vs premium implementations
Swapping vendors globally is the common case. But sometimes you want two implementations of the same support service running side-by-side.
A fairly normal example is “standard” vs “premium”: standard customers get the basic provider, premium customers pay for a better one (better results, higher limits, better SLAs, whatever makes sense for that domain).
Search is a realistic example. A standard plan might use a database-backed search. A premium plan might pay for a hosted search provider with better relevance, typo tolerance, or speed.
This is where Laravel’s contextual binding is genuinely useful: you can bind the same contract to different implementations depending on which class is being resolved.
Hypothetically, imagine we have two controllers serving two routes. Both depend on the same search contract:
final class StandardSearchController
{
public function __construct(
private SearchEngine $search,
) {}
}
final class PremiumSearchController
{
public function __construct(
private SearchEngine $search,
) {}
}
Now we can teach the container which implementation to inject for each controller:
<?php
declare(strict_types=1);
namespace App\Providers;
use App\Http\Controllers\PremiumSearchController;
use App\Http\Controllers\StandardSearchController;
use App\Support\Search\Contracts\SearchEngine;
use App\Support\Search\Providers\Database\DatabaseSearchEngine;
use App\Support\Search\Providers\Hosted\HostedSearchEngine;
use Illuminate\Support\ServiceProvider;
final class SearchServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->when(StandardSearchController::class)
->needs(SearchEngine::class)
->give(DatabaseSearchEngine::class);
$this->app->when(PremiumSearchController::class)
->needs(SearchEngine::class)
->give(HostedSearchEngine::class);
}
}
The important bit is not the controller names. It is the shape: two different entry points, one shared contract.
And if both controllers end up doing the same orchestration work, you can push that into an action and pass the resolved dependency in. Because both implementations follow the same contract, the action can stay completely oblivious to which provider it is working with.
Trade-offs (what you pay for this cleanliness)
You will write a little more code up front:
- a contract,
- a provider implementation,
- a service provider binding.
But the cost is predictable and small. The alternative cost is the one you pay later, under deadline pressure, when a vendor package change ripples through unrelated parts of your app.
Conclusion
If you take one thing from this pattern, make it this: application code depends on contracts you own; vendor code lives behind provider implementations; the container decides which one you get.
Once you have that seam, swapping vendors is a service provider change. Tests get simpler. Upgrades get less dramatic. And you stop having to negotiate with a package API in parts of the codebase that should never have known it existed.
If you’re building a Laravel app and you want these boundaries in place from day one, this is exactly the kind of thing I help teams do at Moon Pixels. Get in touch!