13 KiB
Adding a banking sync provider
This guide explains how to add a new banking/financial provider to the sync
pipeline. Follow it top to bottom — each step is self-contained and the example
(Acme) can be copy-pasted and renamed.
It is written so that a human or an AI agent can complete the integration without prior context. If you only need the data to flow in on the scheduled sync, Part 1 is all you need. Part 2 covers the rest of a full end-to-end integration (connecting the account, mapping it, the UI).
Architecture in one minute
Syncing is split into three responsibilities:
| Piece | Responsibility | Lives in |
|---|---|---|
SyncBankingConnectionJob |
Orchestration — deleted-user/expiry/rate-limit checks, error handling, retries, logging, status updates. Provider-agnostic. | app/Jobs/SyncBankingConnectionJob.php |
BankingConnectionSyncer (one per provider) |
Provider work — talk to the provider API and persist balances/transactions for each account. | app/Services/Banking/Sync/*Syncer.php |
BankingConnectionSyncerFactory |
Dispatch — maps connection.provider (a BankingProvider enum) to the right syncer. |
app/Services/Banking/Sync/BankingConnectionSyncerFactory.php |
The job calls factory->make($connection) and then $syncer->sync(...). You
never edit the job to add a provider — you add a BankingProvider enum case,
a syncer class, and one match arm in the factory.
connection.provider is the App\Enums\BankingProvider enum (the DB column is
cast to it), so it is the single source of truth for the provider identifier and
shared capabilities (usesApiKey(), defaultAccountType()).
The contract
Every syncer implements App\Contracts\BankingConnectionSyncer:
interface BankingConnectionSyncer
{
/** Sync every account in the connection. Returns metadata for the sync log. */
public function sync(BankingConnection $connection, bool $isFirstSync): array;
/** Whether the connection's consent can expire (consent-based providers). */
public function expires(): bool;
/** Whether a permanent auth failure should notify the user (API-key providers). */
public function notifiesOnAuthFailure(): bool;
}
AbstractBankingConnectionSyncer provides defaults for the common case — an
API-key integration that never expires and emails the user when its credentials
stop working:
public function expires(): bool { return false; }
public function notifiesOnAuthFailure(): bool { return true; }
So most providers only implement sync(). Override a flag only when your
provider differs:
| Provider kind | expires() |
notifiesOnAuthFailure() |
Example |
|---|---|---|---|
| API-key (user supplies a key/token) | false (default) |
true (default) |
Binance, Coinbase, Bitpanda, Indexa Capital, Wise, Interactive Brokers |
| Consent-based (OAuth, expires) | true |
false |
EnableBanking |
This matches BankingProvider::usesApiKey() (everything except EnableBanking).
EnableBanking is the only provider that overrides both flags.
Part 1 — Add the sync provider
Step 1 — Write the API client (if the provider has its own API)
Clients live in app/Services/Banking/ and wrap the HTTP calls. Look at
BinanceClient or IndexaCapitalClient as templates. A client typically takes
the credentials in its constructor and exposes the few calls you need:
// app/Services/Banking/AcmeClient.php
namespace App\Services\Banking;
use Illuminate\Support\Facades\Http;
class AcmeClient
{
private const BASE_URL = 'https://api.acme.com';
public function __construct(private string $apiToken) {}
/** @return array<string, mixed> */
public function getBalances(): array
{
return Http::baseUrl(self::BASE_URL)
->withToken($this->apiToken)
->throw()
->get('/v1/balances')
->json();
}
}
Throw on 4xx/5xx (
->throw()). The job relies onRequestExceptionto detect401/403(auth) and429(rate limit). For flaky upstreams you can throwApp\Exceptions\Banking\TransientBankingProviderExceptioninstead — the job treats it as a temporary error (logged as a warning, retried).
Step 2 — Write the sync service(s)
The service maps provider data onto our models (Account, AccountBalance,
Transaction). Reuse the existing services as templates:
- Balances only (crypto/investment):
BinanceBalanceSyncService,BitpandaBalanceSyncService. - Balances and transactions (banks):
TransactionSyncService+BalanceSyncService(EnableBanking), orWiseTransactionSyncService+WiseBalanceSyncService.
These are resolved from the container, so type-hint their dependencies in the constructor and they get injected.
Step 3 — Write the syncer
Create app/Services/Banking/Sync/AcmeSyncer.php. Extend
AbstractBankingConnectionSyncer, inject your sync service(s), and implement
sync(). Loop over $connection->accounts and do the provider work.
namespace App\Services\Banking\Sync;
use App\Models\BankingConnection;
use App\Services\Banking\AcmeBalanceSyncService;
use App\Services\Banking\AcmeClient;
class AcmeSyncer extends AbstractBankingConnectionSyncer
{
public function __construct(private AcmeBalanceSyncService $balanceSync) {}
public function sync(BankingConnection $connection, bool $isFirstSync): array
{
$client = new AcmeClient($connection->api_token);
$connection->load('accounts');
foreach ($connection->accounts as $account) {
$this->balanceSync->sync($account, $client);
}
return []; // or ['transactions_synced' => N, ...] — stored on the sync log
}
}
Notes:
- Credentials are on the connection:
$connection->api_tokenand$connection->api_secret(bothencryptedcasts, decrypted automatically). - Return value is metadata persisted on
BankingSyncLog.metadata. Return[]if there's nothing useful. SeeWiseSyncerfor a transaction-count example. $isFirstSyncistrueon the very first sync or a forced full sync. Use it to backfill history once (seeBinanceSyncer/CoinbaseSyncer).- Override
expires()/notifiesOnAuthFailure()here if your provider is not the default API-key shape (see the table above).
Step 4 — Register the provider (enum case + factory arm)
First add the identifier to app/Enums/BankingProvider.php:
enum BankingProvider: string
{
// ...existing cases...
case Acme = 'acme'; // the value stored in banking_connections.provider
}
If your provider is not the default shape, also reflect it on the enum:
usesApiKey() (auth-failed email; true for everything except EnableBanking)
and defaultAccountType() (the account type its accounts default to — e.g.
Investment for crypto, Checking for banks).
Then add one match arm in
app/Services/Banking/Sync/BankingConnectionSyncerFactory.php:
$syncer = match ($connection->provider) {
// ...existing arms...
BankingProvider::Acme => AcmeSyncer::class,
};
The match is exhaustive over the enum — there is no default. If you add
an enum case without a syncer arm, Larastan flags the non-exhaustive match and
the factory throws \UnhandledMatchError at runtime, so you cannot forget this
step. That is the only wiring the sync pipeline needs.
Step 5 — Add a factory state (for tests)
In database/factories/BankingConnectionFactory.php, add a state mirroring the
other providers so tests can build connections easily:
public function acme(): static
{
return $this->state(fn (array $attributes) => [
'provider' => BankingProvider::Acme,
'authorization_id' => null,
'session_id' => null,
'api_token' => 'test-acme-token-'.fake()->uuid(),
'aspsp_name' => 'Acme',
'aspsp_country' => 'ES',
'valid_until' => null, // null = never expires (API-key provider)
]);
}
Step 6 — Test it
Two layers of tests:
a) Factory wiring — extend the dataset in
tests/Feature/OpenBanking/BankingConnectionSyncerFactoryTest.php:
'acme' => [BankingProvider::Acme, AcmeSyncer::class],
The it('covers every provider enum case') test already fails for any enum case
without a syncer. Add your flag expectations too if you overrode a default.
b) Sync behavior — add a test in
tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php. Drive the job
through the shared runSync() helper (defined in tests/Pest.php) and fake the
provider HTTP with Http::fake():
test('acme sync stores balances', function () {
$user = User::factory()->onboarded()->create();
$connection = BankingConnection::factory()->acme()->create([
'user_id' => $user->id,
'last_synced_at' => null,
]);
$account = Account::factory()->connected()->create([
'user_id' => $user->id,
'banking_connection_id' => $connection->id,
]);
Http::fake(['api.acme.com/*' => Http::response(['balances' => [/* ... */]])]);
$job = new SyncBankingConnectionJob($connection);
runSync($job); // resolves the real factory → AcmeSyncer
$connection->refresh();
expect($connection->last_synced_at)->not->toBeNull();
expect($account->balances()->count())->toBeGreaterThan(0);
});
runSync($job, $transactionSync, $balanceSync)also accepts mockedTransactionSyncService/BalanceSyncServiceand binds them into the container — only needed for EnableBanking-style providers that use those shared services.
Run them:
php artisan test --compact tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php \
tests/Feature/OpenBanking/BankingConnectionSyncerFactoryTest.php
Part 2 — Full end-to-end integration
Part 1 makes the scheduled sync work. To let a user actually connect an Acme account, you also need the pieces below. Use an existing API-key provider (e.g. Binance) as the reference implementation and grep for its name.
-
Connection + onboarding controller —
app/Http/Controllers/OpenBanking/. Each provider has a controller (e.g.BinanceController) that validates credentials, creates theBankingConnection, and storespending_accounts_data. Add anAcmeController, a Form Request for its credentials, and its routes. -
Model helper (optional) —
app/Models/BankingConnection.phpexposesisBinance(),isCoinbase(), etc. AddisAcme()only if other code needs to branch on the provider. The sync pipeline does not need it (it uses the factory), so don't add it speculatively. -
Account mapping — the created account type comes from
BankingProvider::defaultAccountType()(used byAccountMappingControllerandConcerns/CreatesAccountsFromPending). You already set this in Step 4 when you added the enum case, so there is nothing extra to wire here. -
Frontend — one registry entry. The connect dialog, the inline connect flow, and the update-credentials dialog are all driven by a single registry:
resources/js/lib/connect-providers.tsx. Add oneCONNECT_PROVIDERSentry describing the provider —institution(name/country/logo), the connectendpoint, the credentialfields, and the on-screen copy. There is no per-component wiring: the fields render the form and define the POST body (each fieldkeyis the body key), so you don't touch the dialogs.Useful per-entry flags:
onlyCountry— offer only when connecting from that country (e.g. Indexa:'ES').sendsCountry— also POST the selectedcountry(banks/crypto that need it).feature— hide behind a Pennant flag (akeyof Features) until it's ready.updatable: false— the provider has no credential-update path on the backend.
Copy in the registry is plain English wrapped with
__()at render (likeCOUNTRIES), so it is not auto-detected byLocalizationTest— add the Spanish strings tolang/es.jsonyourself or the UI renders them untranslated.
The backend touchpoints (controller, Form Request, route, optional model helper) are still per-provider.
grep -rn "'binance'" app routesfinds them to mirror; the frontend is just the registry entry above.
Checklist & CI
Sync pipeline (Part 1):
BankingProvider::Acmeenum case (+usesApiKey()/defaultAccountType()if non-default)AcmeClient(if the provider has an API)AcmeBalanceSyncService/ transaction serviceAcmeSyncer extends AbstractBankingConnectionSyncer- flag overrides if not a default API-key provider
- one
matcharm inBankingConnectionSyncerFactory acme()factory state- factory-wiring + sync-behavior tests, green
Before finalizing, run the project checks:
vendor/bin/pint --dirty # PHP formatting
vendor/bin/phpstan analyse # static analysis
php artisan test --exclude-testsuite=Browser --compact