Install with Composer (the package is abandoned but still installable):
composer require asgrim/ofxparser
The library has one job: turn an OFX file (the format most banks export from their "download transactions" button) into PHP objects. Start by looking at src/Parser.php — it is the single entry point — and then src/Entities/ where Ofx, BankAccount, CreditCardAccount, Statement, and Transaction live. The Parser exposes two methods: parse($filePath) and parseString($contents).
A first, realistic use case: a customer uploads a .ofx export and you want the transactions in a database.
use OfxParser\Parser;
$parser = new Parser();
$ofx = $parser->parse('/tmp/statement.ofx');
foreach ($ofx->bankAccounts as $account) {
foreach ($account->statement->transactions as $txn) {
printf(
"%s %-30s %10.2f %s\n",
$txn->date->format('Y-m-d'),
$txn->name,
$txn->amount,
$txn->uniqueId
);
}
}
That is essentially the whole API surface. $txn->date is a \DateTime, $txn->amount is a float, and $txn->name/$txn->memo are the strings the bank felt like providing.
In Laravel, wrap this in a small service and never touch the parser directly from a controller:
// app/Services/OfxImporter.php
final class OfxImporter
{
public function import(string $path): array
{
$ofx = (new \OfxParser\Parser())->parse($path);
return collect($ofx->bankAccounts)
->flatMap(fn ($account) => $account->statement->transactions)
->map(fn ($txn) => [
'external_id' => $txn->uniqueId,
'posted_on' => $txn->date->format('Y-m-d'),
'payee' => $txn->name,
'memo' => $txn->memo,
'amount' => $txn->amount,
])
->all();
}
}
Branch on account type, not on a generic account. The parser gives you two separate collections — $ofx->bankAccounts and $ofx->creditCardAccounts — and you must iterate both. A common bug is only reading bankAccounts and silently dropping credit card data.
$accounts = array_merge($ofx->bankAccounts, $ofx->creditCardAccounts);
Use uniqueId as the idempotency key. OFX's FITID (exposed as $txn->uniqueId) is the bank's stable transaction identifier. Store it with a unique index so re-importing the same statement is a no-op. In Laravel:
Transaction::upsert($rows, ['external_id'], ['payee', 'memo', 'amount', 'posted_on']);
Beware that some institutions emit empty or duplicate FITIDs; fall back to a composite hash (sha1(rtrim($txn->name).$txn->date->format('c').$txn->amount)) when the value is null/blank.
Normalize the sign convention before persisting. Bank statements treat debits as negative (-42.10). Credit card statements in OFX often report a purchase as positive and a payment as negative — the opposite mental model. Decide once, in your importer, whether your DB stores "signed cash flow" or "positive for spend", and convert on the way in:
$amount = $account instanceof CreditCardAccount ? -$txn->amount : $txn->amount;
Work from the statement window. $account->statement->startDate and ->endDate are useful for choosing a reconciliation period, and $account->balance gives you the closing balance for a sanity check against your ledger.
Queue large imports. The parser builds the entire object graph in memory, so a multi-year export from a busy account can be tens of thousands of transaction objects. Dispatch a ImportOfxStatement job, store the raw file on a private disk, and parse inside the job. Keep the original file around — it is your audit trail for "where did this number come from?"
Accept both uploads and stored files. Because parseString exists, you can skip Storage::disk()->path() gymnastics and feed the contents directly:
$ofx = (new Parser())->parseString($request->file('ofx')->get());
Abstract behind an interface if you care about the future. The package is archived (last release 2018) and does not accept fixes. If OFX imports are load-bearing for you, put a StatementParser contract in front of it so you can swap in a maintained fork without touching call sites.
&, stray control characters — can still throw. Catch \OfxParser\ParseException and log the raw file; you will need it.(int) round($txn->amount * 100) or Money::of($txn->amount, $currency) from brick/money before storing.DateTime objects. There is no timezone handling; the date is whatever the bank sent (usually the posting date, sometimes a timezone-shifted one). Store as a date column, not a timestamp, or you will fight midnight-offset bugs.name and memo are inconsistent. Some banks put the merchant in name and nothing in memo; others invert it; some put the cardholder's name in both. Treat them as free text and normalize (trim, collapse whitespace, strip terminal-location noise) rather than assuming a structure.Ofx entity. If a field you expect is missing, dd($ofx->bankAccounts[0]->statement) — the entity shape tells you exactly what that particular bank export actually contained, which is faster than reading the spec.How can I help you explore Laravel packages today?