Skip to content

Parsing QIF files

Parser::parseFile() reads the file, parses it and returns the parser. getTransactions() then returns the transactions, in the order of the file:

export.qif
!Account
NChecking
TBank
^
!Type:Bank
D03/01/2026
T-1,250.00
CX
PExample Property Rentals
A12 Sample Street
ASpringfield
MJanuary rent
LHousing:Rent
^
D05/01/2026
T2,400.00
C*
PAcme Corp
MSalary
LIncome:Salary
^
D07/01/2026
T-84.30
PDemo Grocery
LFood
SFood:Groceries
$-64.30
EWeekly groceries
SHousehold
$-20.00
^
parsing.php
<?php
use Silarhi\Qif\Parser;
$parser = Parser::parseFile('export.qif');
foreach ($parser->getTransactions() as $transaction) {
echo json_encode([
'type' => $transaction->getType()?->name,
'date' => $transaction->getDate()?->format('Y-m-d'),
'amount' => $transaction->getAmount(),
'status' => $transaction->getStatus()->name,
'payee' => $transaction->getDescription(),
'address' => $transaction->getAddress(),
'memo' => $transaction->getMemo(),
'category' => $transaction->getCategory(),
'splits' => $transaction->getSplits(),
], JSON_PRETTY_PRINT), PHP_EOL;
}
Output
{
"type": "Bank",
"date": "2026-01-03",
"amount": -1250,
"status": "Reconciled",
"payee": "Example Property Rentals",
"address": "12 Sample Street\nSpringfield",
"memo": "January rent",
"category": "Housing:Rent",
"splits": []
}
{
"type": "Bank",
"date": "2026-01-05",
"amount": 2400,
"status": "Cleared",
"payee": "Acme Corp",
"address": null,
"memo": "Salary",
"category": "Income:Salary",
"splits": []
}
{
"type": "Bank",
"date": "2026-01-07",
"amount": -84.3,
"status": "NotCleared",
"payee": "Demo Grocery",
"address": null,
"memo": null,
"category": "Food",
"splits": [
{
"category": "Food:Groceries",
"amount": -64.3,
"memo": "Weekly groceries"
},
{
"category": "Household",
"amount": -20,
"memo": null
}
]
}

The !Account list at the top of the file holds the account, not a transaction: the parser skips it.

parseFile() throws a RuntimeException when the file does not exist or cannot be read, and a ParseException on a value it cannot read without guessing: see Error handling.

To parse content you already have in memory (an upload, an HTTP response…), use the constructor and call parse(), which returns the transactions:

use Silarhi\Qif\Parser;
$transactions = (new Parser($content))->parse();

getTransactions() returns the same array afterwards, and an empty array until parse() has run. Calling parse() again parses the content again and replaces the transactions: parseFile() has already called it. When parse() throws a ParseException, getTransactions() returns the transactions read before the error: see Error handling.

The second argument of the constructor is the line separator, "\r\n" by default. Each of its characters ends a line, so the default reads Windows (\r\n), Unix (\n) and old Mac (\r) files, and skips empty lines: you never need to change it for these files.

The whole content is held in memory, and so are the transactions.

parseFile() and the constructor take the date format and the decimal separator of the file:

use Silarhi\Qif\Enums\DateFormat;
use Silarhi\Qif\Enums\DecimalSeparator;
use Silarhi\Qif\Parser;
$parser = Parser::parseFile('/path/to/export.qif', DateFormat::MonthFirst, DecimalSeparator::Point);
$transactions = (new Parser($content, dateFormat: DateFormat::DayFirst, decimalSeparator: DecimalSeparator::Comma))
->parse();
Option Default Values
$dateFormat DateFormat::DayFirst DayFirst (25/12/2023) or MonthFirst (12/25/2023): Dates
$decimalSeparator null, detected DecimalSeparator::Point (1,234.50) or Comma (1.234,50): Amounts
Getter Type QIF line Notes
getType() ?Types !Type: Bank, Cash, CreditCard… null for an unknown type
getDate() ?DateTimeImmutable D At midnight, null when missing or empty: see Dates
getAmount() ?float T, U null when missing or empty: see Amounts
getMemo() ?string M
getStatus() Status C Reconciled (X, R), Cleared (*, c), else NotCleared
getDescription() ?string P Payee
getAddress() ?string A Every A line of the record, joined with "\n"
getCategory() ?string L
getSplits() list<array> S, E, $ [['category' => string, 'amount' => float, 'memo' => ?string], ...]: see Splits

The values are the rest of the line after its code, with the spaces around the line trimmed. An empty field (a bare P, D or T line) is skipped like a missing one: the getter returns null, and an empty C line leaves the NotCleared status. The only exception is the S line, which starts a split even when empty: see Splits.

  • Records end with a ^ line. The last record of the file is kept even without its closing ^, and so is a record followed by a new header. A record without any value (a stray ^, or only empty fields) is not a transaction: it is skipped.
  • Headers: a !Type: line sets the type of the records that follow it, until the next header. An unknown type (!Type:Memorized) gives them a null type. The type values are listed in Types.
  • Lists that are not transactions are skipped, up to the next header: accounts (!Account), categories (!Type:Cat), classes (!Type:Class), securities (!Type:Security) and prices (!Type:Prices).
  • Options: !Option: and !Clear: lines (!Option:AutoSwitch) are ignored, and leave the type of the records that follow them unchanged.
  • Amounts: when a record has both a T and a U line, the last one wins.
  • The UTF-8 BOM is removed, wherever it is.
  • Ignored lines: the check number (N), the split percentage (%), the investment fields (Y, I, Q, O), the reimbursable flag (F), the budget (B) and the invoice fields (X…). The field reference lists what is read and what is written.

Each S line starts a split of the transaction: its category. The E line after it is its memo, the $ line its amount, read like the T amount. getSplits() returns them as a list, in the order of the file, as several splits can share a category:

[
['category' => 'Food:Groceries', 'amount' => -64.3, 'memo' => 'Weekly groceries'],
['category' => 'Household', 'amount' => -20.0, 'memo' => null],
]

A split without a $ line, or with an empty one, throws a ParseException; an empty E line leaves the memo null. An empty S line still starts a split, with an empty string as category, as the $ and E lines that follow it belong to it. A $ or E line that does not follow an S line is ignored: in investment records, $ is the amount transferred.

The parser replaces the ' year separator with / and spaces with 0 (1/ 1/ 0 becomes 1/01/00), then reads the date with two-digit or four-digit years, day first by default. ISO dates (2026-01-31, 26-01-31) are read whatever the date format. For the month-first dates of US versions of Quicken, pass DateFormat::MonthFirst:

dates.php
<?php
use Silarhi\Qif\Enums\DateFormat;
use Silarhi\Qif\Exception\ParseException;
use Silarhi\Qif\Parser;
$dates = ['2026-01-31', '31/01/2026', '01/31/2026', '26-01-31', '31/01/26', "31/01'26", '1/ 1/ 0', '31/02/2026', 'Jan 31', ''];
printf("%-12s %-18s %s\n", 'D line', 'DayFirst', 'MonthFirst');
foreach ($dates as $date) {
$results = [];
foreach ([DateFormat::DayFirst, DateFormat::MonthFirst] as $format) {
try {
[$transaction] = (new Parser("!Type:Bank\nD{$date}\nT-10\n^\n", dateFormat: $format))->parse();
$results[] = $transaction->getDate()?->format('Y-m-d H:i') ?? 'null';
} catch (ParseException) {
$results[] = 'ParseException';
}
}
printf("%-12s %-18s %s\n", "D{$date}", ...$results);
}
Output
D line DayFirst MonthFirst
D2026-01-31 2026-01-31 00:00 2026-01-31 00:00
D31/01/2026 2026-01-31 00:00 ParseException
D01/31/2026 ParseException 2026-01-31 00:00
D26-01-31 2026-01-31 00:00 2026-01-31 00:00
D31/01/26 2026-01-31 00:00 ParseException
D31/01'26 2026-01-31 00:00 ParseException
D1/ 1/ 0 2000-01-01 00:00 2000-01-01 00:00
D31/02/2026 ParseException ParseException
DJan 31 ParseException ParseException
D null null
  • Dates are at midnight, in the default timezone of PHP.
  • A date that does not exist with the chosen format throws a ParseException instead of rolling over: 31/02/2026, or 01/31/2026 read day first. So does a value that is not a date (Jan 31).
  • An empty D line leaves the date null.
  • Transactions with the same date string share one DateTimeImmutable instance.

Amounts can group their thousands with ,, . or spaces (including the no-break spaces of French amounts). By default, the decimal separator is detected for each amount:

  • a plain number (-1234.50, 1.234) uses a decimal point, as in the QIF specification;
  • when an amount has both , and ., the last one separates the decimals (1,234.50, 1.234,50);
  • a separator repeated groups thousands (1,234,567);
  • a single , is a decimal comma (12,50), except after 1 to 3 digits and before exactly 3 (1,234): that amount is ambiguous, 1234 or 1.234, and throws a ParseException.

Pass DecimalSeparator::Point or DecimalSeparator::Comma to read every amount of the file the same way. An amount written in the other convention then throws a ParseException, and so does a value that is not a number (12 EUR). The messages show the amount as written in the file, spaces included:

amounts.php
<?php
use Silarhi\Qif\Enums\DecimalSeparator;
use Silarhi\Qif\Exception\ParseException;
use Silarhi\Qif\Parser;
$amounts = ['-1234.50', '1,234.50', '1.234,50', '12,50', '1,234,567', '1.234', '1 234,50', '1,234', '12 EUR'];
printf("%-12s %-16s %-16s %s\n", 'T line', 'Detected', 'Point', 'Comma');
foreach ($amounts as $amount) {
$results = [];
foreach ([null, DecimalSeparator::Point, DecimalSeparator::Comma] as $separator) {
try {
[$transaction] = (new Parser("!Type:Bank\nT{$amount}\n^\n", decimalSeparator: $separator))->parse();
$results[] = var_export($transaction->getAmount(), true);
} catch (ParseException) {
$results[] = 'ParseException';
}
}
printf("%-12s %-16s %-16s %s\n", "T{$amount}", ...$results);
}
Output
T line Detected Point Comma
T-1234.50 -1234.5 -1234.5 ParseException
T1,234.50 1234.5 1234.5 ParseException
T1.234,50 1234.5 ParseException 1234.5
T12,50 12.5 ParseException 12.5
T1,234,567 1234567.0 1234567.0 ParseException
T1.234 1.234 1.234 1234.0
T1 234,50 1234.5 ParseException 1234.5
T1,234 ParseException 1234.0 1.234
T12 EUR ParseException ParseException ParseException

An empty T line leaves the amount null.

Each parsed transaction keeps the lines it was read from in its public $_raw property, for debugging and logs. Its header line comes first, on a line of its own (!Type:Bank, or !Type:Memorized for an unknown type). Every line ends with the line break of the file (its first one: "\r\n", "\n" or "\r"), the last one included, and empty lines are left out. It is null on the transactions you build yourself. Its exact format is not part of the API.