Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Sepa Xml Laravel Package

digitick/sepa-xml

PHP library for generating SEPA XML payment files. Build pain.001 credit transfers and pain.008 direct debits with a fluent API, supporting batches, groups, and multiple payment methods for European bank payments.

View on GitHub
Deep Wiki
Context7

Getting Started

Install

composer require digitick/sepa-xml

The GitHub repo lives at php-sepa-xml/php-sepa-xml, but Composer users have historically installed it as digitick/sepa-xml. Recent releases may have republished under the new vendor name — check Packagist once and pin whatever resolves for you before wiring it into CI. Pin an exact-ish constraint (^3.2) rather than *: output changed materially between 3.0/3.1/3.2 (see Gotchas).

There is no Laravel service provider, no config, no facade. It is a plain PHP library: give it objects, get an XML string back. Requires ext-dom. As of 3.2.0 ramsey/uuid moved to require-dev, so the runtime dependency footprint is smaller — if you were relying on it arriving transitively, require it yourself.

Look here first

  1. TransferFileFacadeFactory — the only entry point you normally need.
  2. PaymentInformation — the "batch" concept; nearly every bug is a grouping bug here.
  3. TransferInformation\CustomerCreditTransferInformation / CustomerDirectDebitTransferInformation — where amount, IBAN, mandate, description and (3.2+) creditor postal address live.
  4. XSD validation (new in 3.2.0) — the built-in schema check, if you can supply the EPC XSDs.
  5. Util\StringHelper::sanitizeString() — the charset rules that will bite you.
  6. docs/ — the 3.2.0 docs restructure is the first real documentation this package has had; skim it before reading source.

First use case: a single credit-transfer batch

use Digitick\Sepa\TransferFile\Factory\TransferFileFacadeFactory;

$file = TransferFileFacadeFactory::createCustomerCreditTransfer('MSG-2024-0001', 'Acme BV');

$file->addPaymentInfo('PMT-0001', [
    'id'                => 'PMT-0001',
    'debtorName'        => 'Acme BV',
    'debtorAccountIBAN' => 'NL39RABO0300065264',
    'debtorAgentBIC'    => 'RABONL2U',
]);

$file->addTransfer('PMT-0001', [
    'amount'      => 149.95,
    'iban'        => 'DE88500700100175526303',
    'name'        => 'J. Doe',
    'description' => 'Invoice 2024-0042',
    'endToEndId'  => 'INV-2024-0042',
]);

$file->getTransferFile()->validate(); // throws ValidationException
Storage::disk('local')->put('sepa/2024-04-01.xml', $file->asXml());

That is the whole happy path. Direct debits are the same shape with createDirectDebit() plus a seqType on the payment info and mandateId/signatureDate on each transfer. On 3.2+ you can additionally pass a creditor postal address for pain.008 — worth doing if your bank enforces it, because it is validated at file level and rejecting later is expensive.

Implementation Patterns

Wrap it in one service, not in controllers. Put everything the library needs to know about your company in config/sepa.php (creditor name, IBAN, BIC, creditor scheme ID, postal address) and inject a SepaExporter service. The library's API is array-driven and untyped; centralising the array keys is what keeps it maintainable.

final class SepaExporter
{
    public function creditTransfer(Collection $payouts, string $messageId): string
    {
        $facade = TransferFileFacadeFactory::createCustomerCreditTransfer(
            $messageId, config('sepa.initiating_party')
        );
        foreach ($payouts->groupBy('currency') as $currency => $group) {
            $facade->addPaymentInfo($pmtId = "PMT-{$messageId}-{$currency}", [...]);
            foreach ($group as $payout) {
                $facade->addTransfer($pmtId, [
                    'amount'      => round($payout->amount_cents / 100, 2),
                    'iban'        => $payout->iban,
                    'name'        => StringHelper::sanitizeString($payout->account_holder),
                    'description' => StringHelper::sanitizeString("Invoice {$payout->reference}"),
                    'endToEndId'  => $payout->reference,
                ]);
            }
        }
        return $facade->asXml();
    }
}

Group correctly — this is the core domain modelling decision. One PaymentInformation = one creditor/debtor account + one due date + one sequence type + one currency. So:

  • Direct debit: groupBy([creditorIban, dueDate, sequenceType]).
  • Credit transfer: groupBy([debtorIban, requestedExecutionDate]).
  • Never mix FRST and RCUR in one PaymentInformation; banks reject it.

Sequence-type state machine. Store a mandates table with last_used_at, sequence_type, mandate_id, signature_date. Derive per collection: no prior use → FRST; subsequent → RCUR; one-off → OOFF; last → FNAL. Update only after the bank acknowledges the file, not when you generate it — a rejected file must not advance the mandate.

Build DTOs from Eloquent, not from toArray(). Map each Invoice/Payout to a small value object that already has sanitized strings, rounded amounts, and a resolved endToEndId. It makes the XML generation pure and unit-testable without a database.

Queue it and persist it. Generation is cheap but tied to banking cut-offs. A GenerateSepaFile job that stores the XML on a disk along with a sepa_files row (message_id, hash, batch_count, total_amount, generated_by) gives you an audit trail and makes re-download trivial. Use a ULID or a timestamped sequence for message_id; max 35 chars, must be unique per file, and unique enough that a retried job does not reuse it.

Validate in two stages, now that you can. Call validate() always (cheap, catches IBAN/mandate/date mistakes), then run the new optional XSD validation against the EPC schema for the flavour your bank imports. Do the XSD pass in the job and in CI on the golden fixtures. It is opt-in, so it will not surprise you on upgrade, but it is the single best guard against "bank silently imported zero payments".

Serve it as a download, don't email it. Return Content-Type: application/xml with a filename like SEPA_20240401_143000.xml. Banks and accounting tools are picky about the extension.

Test by asserting XML, not strings. assertXmlStringEqualsXmlString() with a golden fixture captured from a bank's test portal is by far the highest-value test you can write here. Keep at least one fixture per schema variant your bank requires — and expect to regenerate all of them on this upgrade, because 3.2.0 restored <PmtTpInf>. Ideally assert against the XSD instead of a byte-for-byte golden file so cosmetic ordering changes don't fail the suite.

Gotchas and Tips

Character set is the number-one support ticket. SEPA's allowed character set is roughly a-z A-Z 0-9 / - ? : ( ) . , ' + space. Everything else — umlauts, &, #, emoji, smart quotes — gets stripped by StringHelper::sanitizeString(), which means Müller becomes Mller and your bank statement shows a name the customer won't recognise. Do the transliteration yourself (ü → ue) before sanitizing if readability matters. Also enforce the 140-char limit (70 for some fields) yourself — don't assume the library truncates for you.

<PmtTpInf> is back in 3.2.0 — regenerate your fixtures. It was dropped in 3.0 (a regression) and restored in 3.2.0 with the required conditions. If you wrote golden fixtures or bank-side mapping against 3.1 output, 3.2 output will differ. If you had a post-processing hack to re-inject <PmtTpInf>, delete it now or you will emit it twice and break the schema. This is the strongest reason to treat 3.0→3.2 as a behaviour change and not a routine patch bump.

Amounts are the second. Older releases treated setAmount() as minor units (cents); recent ones take a decimal euro amount. Verify with a one-line test against your installed version before you ship, and never pass raw floats from money arithmetic — store cents in the database and convert with round($cents / 100, 2).

BIC is conditionally optional but often still required. Since the 2016 rule change, intra-EEA payments don't need a BIC, but several bank importers still fail without one, and some library code paths expect it. Keep a BIC in config and populate it; if you omit it and the file is rejected, this is the first thing to check.

endToEndId collisions get files rejected. Some banks reject duplicate end-to-end IDs across the file, and a naive $invoice->id reused across a refund and the original will trip it. Prefix with a type (INV-, REF-) and keep it ≤35 chars.

Domain validation is still necessary, not sufficient — XSD validation is now possible but is not a substitute. validate() catches IBAN checksums, missing dates, and structural problems; the new optional XSD validation catches schema-level errors. Neither knows about TARGET2 holidays, your bank's cut-off time, or whether a mandate is genuinely signed. Treat both as linters: xmllint --schema pain.008.001.02.xsd in CI is still the fallback if you can't wire the in-library option, and either way the XSDs must be version-matched to what your bank imports.

Wrong schema flavour is common. The library emits pain.001.001.03 and pain.008.001.02 by default; some banks demand a specific version and will silently import nothing rather than erroring. Confirm the exact namespace your bank expects before building your golden fixtures, and check whether your installed version supports passing the schema format to the factory. The new optional XSD validation is a good way to prove which flavour you're actually emitting.

DateTime objects, not strings. dueDate and signatureDate are expected as DateTime instances. Passing a string will either throw or produce an empty element depending on version, and the resulting file may look valid but be rejected.

Creditor postal address (new in 3.2.0) is a file-level concern. For pain.008, the creditor address is supplied at the file/payment-info level, not per mandate, so thread it through your SepaExporter from config rather than from each customer record. If you previously had none and your bank accepted the file, adding it is safe; if your bank required it and you were patching XML, drop the patch.

ramsey/uuid is now require-dev. Only relevant if you were relying on it arriving transitively (e.g. for message_id generation). If so, composer require ramsey/uuid explicitly before upgrading, or generate IDs with Str::ulid() anyway and avoid the dependency entirely.

Some properties moved from private to protected in 3.2.0. Harmless for consumers, but if you subclassed TransferFile/PaymentInformation and used reflection or accessor hacks to reach internals, re-check them — the visibility widening gives you a supported way in, and the reflection fallback may now be brittle across future releases.

Extending: build your own DomBuilder. If you need a newer schema version than the library emits, implement DomBuilderInterface for your schema and inject it rather than forking. Forking a single-maintainer project is a long-term maintenance tax — though note the maintainer team grew in 3.2.0 (two new contributors), which improves that calculus slightly.

Ecosystem is thin — budget for reading source. Zero Composer dependents means few blog posts and few Stack Overflow answers; there is still no Laravel wrapper. The upside since 3.2.0 is the docs restructure — start at docs/ rather than src/Digitick/Sepa. Pin the version in composer.json and add a smoke test that generates a file and validates it against the XSD, so an upgrade that changes amount handling, namespaces or element presence (see <PmtTpInf>) fails loudly in CI rather than in a bank's import queue.

Licence. LGPL-3.0 is fine for a closed-source SaaS that uses the library. If you fork and redistribute it, your changes to the library itself must be published under LGPL — keep your modifications isolated in a separate package if that matters to you.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource
noith/settings