genkgo/camt
A PHP library for reading CAMT bank statement files, supporting CAMT.052, CAMT.053, and CAMT.054 formats across multiple versions. Install via Composer, then loop through statements and entries with a simple reader API.
Install
composer require genkgo/camt
No service provider, no config file, no facade. It's a plain library — you wire it up yourself. That's actually good news in Laravel: it drops cleanly into a queued job, an artisan command, or a small service class without polluting the container.
First read
use Genkgo\Camt\Config;
use Genkgo\Camt\Reader;
$reader = new Reader(Config::getDefault());
$document = $reader->readFile(storage_path('app/bank/statement.xml'));
foreach ($document->getRecords() as $statement) { // Statement (053) / Report (052)
dump($statement->getAccount()->getIban()?->getIban());
dump($statement->getBalance()?->getAmount()->getAmount());
foreach ($statement->getEntries() as $entry) {
dump(
$entry->getBookingDate(), // DateTimeImmutable
$entry->getAmount()->getAmount(), // float
$entry->getAmount()->getCurrency() // 'EUR'
);
}
}
Where to look first
Config::getDefault() — this is the single place that defines which CAMT versions are supported. Reading its source tells you exactly which decoders exist, and it's the file to copy when you need to register something custom.src/DTO/ — the whole API surface. Statement, Report, Entry, TransactionDetail, RelatedParty, RemittanceInformation, Balance, Money. Everything you'll touch lives there; the package is essentially a typed tree over the XML.src/Decoder/XML/ — only if a mapping is wrong (see Gotchas).First real use case: an import command that reads a bank's CAMT.053 end-of-day statement, persists one transactions row per TransactionDetail, and reconciles the closing balance. That's 90% of what people use this package for.
Wrap it behind a small service, not a facade. You will want to swap in a fake in tests and add retry/logging. Keep the Reader construction in one place:
final class CamtImporter
{
public function __construct(private Reader $reader) {}
public function import(string $xmlPath): ImportResult
{
$document = $this->reader->readFile($xmlPath);
// ...
}
}
// AppServiceProvider
$this->app->singleton(Reader::class, fn () => new Reader(Config::getDefault()));
Read from the Storage disk, not the filesystem. Bank files usually arrive by SFTP/mail into storage/app/private/bank. readString() lets you skip temp files entirely:
$document = $reader->readString(Storage::disk('bank')->get($path));
Keep the raw XML around (store the file, or a column with the hash + original name). When a mapping question comes up six months later you want the original bytes.
Queue the import. Parsing is CPU-bound but not slow for typical files; the reason to queue is the database writes and the fact that a bad file should fail one job, not the HTTP request. ShouldQueue + $tries = 1 (retrying a malformed file is pointless) + a failed() that alerts.
Two-level iteration is mandatory. An Entry is a booking; it holds one or more TransactionDetail objects (batch bookings, one debit against many credits). If you only persist entries you lose the counterparty detail; if you only persist details you can't reconcile the entry total.
foreach ($statement->getEntries() as $entry) {
foreach ($entry->getTransactionDetails() as $detail) {
Transaction::create([
'account_iban' => $statement->getAccount()->getIban()?->getIban(),
'booked_at' => $entry->getBookingDate(),
'valued_at' => $entry->getValueDate(),
'amount' => $this->signedAmount($entry, $detail),
'currency' => $detail->getAmount()->getCurrency(),
'reference' => $detail->getReference(), // AcctSvcrRef, often null
'counterparty' => $this->counterpartyName($detail),
'counterparty_iban' => $this->counterpartyIban($detail),
'remittance' => $detail->getRemittanceInformation()?->getMessage(),
]);
}
}
Idempotency is your problem, not the package's. Bank files get re-sent, and re-importing doubles the ledger. Build a deterministic fingerprint and put a unique index on it:
$fingerprint = hash('xxh128', implode('|', [
$statement->getAccount()->getIban()?->getIban(),
$detail->getReference(), // best when present
$entry->getBookingDate()->format('Y-m-d'),
$entry->getAmount()->getAmount(),
$entry->getAmount()->getCurrency(),
$detail->getRemittanceInformation()?->getMessage(),
$indexWithinEntry, // disambiguates identical batch lines
]));
Treat getReference() as preferred, not guaranteed — many banks leave AcctSvcrRef empty. Fall back to the composite hash.
Reconcile using balances, not sums. Don't trust your own arithmetic; assert it against the file:
$closing = collect($statement->getBalances())
->first(fn ($b) => $b->getType() === 'CLBD'); // OPBD = opening, CLBD = closing, CLAV = available
$sum = $statement->getEntries()->reduce(/* sum signed amounts */);
// assert $opening + $sum === $closing, else flag the statement for manual review
This catches sign bugs, duplicate imports and skipped entries in one shot. Log the mismatch with the statement ID rather than throwing.
Multi-version bank support costs nothing. camt.053.001.02 and camt.053.001.08 produce the same DTOs. Don't branch on version in your application code — the only place versions matter is the supported-formats table, and if the bank sends an unsupported one you'll get an exception at read time, which is the right failure point.
Handling both 052 and 053: both return records from getRecords(), but the record types differ (Report for 052, Statement for 053). If you ingest both, dispatch on the namespace or instanceof rather than assuming.
Testing against fixtures. Commit a handful of anonymised real files under tests/Fixtures/camt/ — one clean, one with missing remittance info, one multi-detail batch booking, one different version. Then your importer tests are $this->artisan('bank:import', [...])->assertSuccessful() plus assertions on rows. The package's own test suite has usable XML samples if you need a starting point.
Encoding and BOM. Real bank files are frequently ISO-8859-1 or carry a UTF-8 BOM, and both break the XML parser with messages that don't mention encoding. Normalise before reading:
$xml = ltrim(Storage::disk('bank')->get($path), "\xEF\xBB\xBF");
$document = $reader->readString($xml);
If the parse still fails with non-ASCII garbage in the remittance field, mb_convert_encoding($xml, 'UTF-8', 'ISO-8859-1') is your next step.
Almost every accessor is nullable. getRemittanceInformation(), getRelatedParties(), getAccount()->getIban(), getBic(), getReference(), getBalance() — a debit card payment typically has no counterparty name at all. Write the mapping defensively from day one; retrofitting ?-> after a production null-pointer is nobody's idea of fun. Never map an optional field to a non-nullable DB column without a default.
Credit/debit sign is the classic bug. CAMT amounts are always positive; direction lives in the CdtDbtInd indicator. Whether the DTO surfaces it as a separate accessor or already applies a sign varies by version — verify against a file where you know the direction (an outgoing rent payment), and centralise the conversion in one signedAmount() helper with a unit test. Don't sprinkle *-1 through your importer.
Dates are DateTimeImmutable in the bank's local zone. They're calendar dates, not instants. Store them as date columns, not datetime, or you'll get off-by-one days the moment someone changes app.timezone.
Large files and memory. readFile()/readString() build a full DOM. A month of statements for a busy account can be tens of MB and hundreds of MB of PHP objects. For anything recurring, split per-statement at ingestion (banks usually deliver per-account files anyway) or raise memory_limit on the queue worker explicitly — Laravel's default worker limit is often lower than you think.
Unknown XML nodes fail the parse. The decoders are strict about structure; a bank-specific extension element not covered by the XSD mapping can throw. When that happens, don't patch the vendor directory — fork, or open the decoder in src/Decoder/XML/ and see which element it choked on. This is also why "the bank upgraded their format" tickets land on your desk and not theirs.
Caching. Newer versions accept a PSR-16 cache as the second Reader argument. Useful if you re-read the same unchanged file during development; useless and mildly confusing in production (stale parse results). If you enable it, key the cache by the file hash yourself, or just skip it.
Extension points are limited, by design. Beyond Config::getDefault(), the supported-version table is fixed; adding a version means writing a decoder. Before you do, check whether the bank can simply emit a version already in the table — that conversation is far cheaper than maintaining a fork.
Error types to catch. Format/parse failures come out as ReaderException / InvalidArgumentException rather than a domain-specific hierarchy. Catch \Throwable at the job boundary, mark the import row failed, and keep the file for re-processing. Don't let a bad statement take down the whole nightly run.
Finally: this package gives you data, not semantics. It won't tell you which entry is a salary, a SEPA transfer or a card refund — getTransactionDetails() gives you a CdtDbtInd-ish direction, a bank transaction code, purposes and remittance strings, and matching against your own ledger is your job. Budget for a rules layer on top; that's where the actual work lives, and it's where you'll want the raw XML retained.
How can I help you explore Laravel packages today?