rikudou/euqrpayment
Generate EU QR payment codes (EPC/SEPA) for invoices and payment slips. This Laravel/PHP package builds compliant QR payloads with IBAN, BIC, amount and remittance data, plus validation helpers and simple Blade rendering.
Install
composer require rikudou/euqrpayment
That's the whole install. There is no service provider, no facade, no config to publish, no migrations. It is a plain PHP string builder: you construct a QrPayment object, set the EPC fields, and call getQrString() to get the newline-delimited payload that goes into a QR code.
Where to look first
src/QrPayment.php — read the setter names and any validation exceptions it throws. That is effectively the entire API surface.First use case
The canonical use case is "customer pays a SEPA invoice by scanning a QR code with their banking app" (Czech, German, Austrian, Belgian, Finnish banking apps broadly support it; coverage is uneven elsewhere in the EU).
use Rikudou\EuQrPayment\QrPayment;
$payment = new QrPayment('CZ'); // country code, used for IBAN/BIC expectations
$payload = $payment
->setName('Acme s.r.o.')
->setIban('CZ6508000000192000145399')
->setBic('GIBACZPX')
->setAmount(1250.00)
->setCurrency('EUR')
->setRemittance('Invoice 2024-0042')
->setInformation('Thank you for your order')
->getQrString();
At this point you have the raw EPC string, e.g.:
BCD
002
1
SCT
GIBACZPX
Acme s.r.o.
CZ6508000000192000145399
EUR1250.00
Invoice 2024-0042
Thank you for your order
To turn it into an actual image, you need a second library (endroid/qr-code, bacon/bacon-qr-code, chillerlan/php-qrcode) — this package deliberately does not render pixels.
composer require endroid/qr-code
use Endroid\QrCode\Builder\Builder;
use Endroid\QrCode\ErrorCorrectionLevel;
use Endroid\QrCode\Writer\PngWriter;
$result = Builder::create()
->writer(new PngWriter())
->data($payload)
->errorCorrectionLevel(ErrorCorrectionLevel::Medium)
->size(400)
->margin(16)
->build();
file_put_contents(storage_path('app/invoices/qr-42.png'), $result->getString());
Render it in Blade as data:image/png;base64,... or drop it into a PDF invoice (dompdf/snappy).
Because the library is a bare value object, put the mapping from your domain (an Invoice/Order model) into it behind one class. This is the single most valuable thing you can do, since it isolates you from both the library's age and its API.
namespace App\Services;
use App\Models\Invoice;
use Rikudou\EuQrPayment\QrPayment;
class InvoiceQrPayload
{
public function __construct(private Invoice $invoice) {}
public function string(): string
{
$p = new QrPayment(config('payments.qr.country'));
$p->setName(config('payments.qr.beneficiary_name'))
->setIban(config('payments.qr.iban'))
->setBic(config('payments.qr.bic'))
->setAmount((float) $this->invoice->total)
->setCurrency('EUR')
->setRemittance('Invoice ' . $this->invoice->number)
->setInformation(mb_substr($this->invoice->reference, 0, 70));
return $p->getQrString();
}
}
Then a Blade component / @php helper and a controller route GET /invoices/{invoice}/qr.png that returns the rendered PNG with a long cache header.
Ship your own config/payments.php (the package provides none):
return [
'qr' => [
'iban' => env('QR_IBAN'),
'bic' => env('QR_BIC'),
'beneficiary_name' => env('QR_BENEFICIARY_NAME', config('app.name')),
'country' => env('QR_COUNTRY', 'CZ'),
],
];
The library's constructor takes a country code — it's used for validating/formatting expectations around IBAN and BIC. Keep it in config, not hardcoded, so a second legal entity in another country doesn't require a code change.
If you ever let users paste an IBAN/BIC (e.g. a marketplace where the seller defines payout details), validate before constructing the object, because the library's validation is minimal and its exception messages are terse:
$request->validate([
'iban' => ['required', 'string', 'regex:/^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/'],
'bic' => ['required', 'string', 'regex:/^[A-Z]{6}[A-Z0-9]{2}([A-Z0-9]{3})?$/'],
'amount' => ['required', 'numeric', 'min:0.01', 'max:999999999.99'],
]);
Use brick/money or moneyphp/money for the amount and cast to string with number_format($minor / 100, 2, '.', '') — never pass a float formatted by (string) because locale settings can leak into it.
Generating the string is essentially free (string concatenation), so don't over-engineer. The PNG rendering is the only mildly expensive part. Cache the PNG on disk keyed by invoice number + a content hash, or memoize at the HTTP layer:
return response(file_get_contents($path), 200, [
'Content-Type' => 'image/png',
'Cache-Control' => 'public, max-age=31536000, immutable',
]);
Assert against the exact byte string. This catches field-order and newline regressions, which are the only things that realistically break in production:
public function test_invoice_payload_matches_epc_v2(): void
{
$invoice = Invoice::factory()->create(['number' => '2024-42', 'total' => 10.50]);
$this->assertSame(
"BCD\n002\n1\nSCT\nGIBACZPX\nAcme s.r.o.\nCZ6508000000192000145399\nEUR10.50\n\n\nInvoice 2024-42\n",
(new InvoiceQrPayload($invoice))->string()
);
}
Also add one "smoke" test that scans a rendered PNG with a QR decoder in CI if you can — khanamiryan/qrcode-detector-decoder works. It will not tell you the QR is valid EPC, but it will catch a corrupted render pipeline.
Last release was September 2021, 18 stars, 0 dependents. It is unlikely to receive fixes for a newer EPC spec revision or a PHP 8.x edge case. Pin it exactly in composer.json, and keep the wrapper class from above so a future swap (or a fork) is a one-file change. Don't spread new QrPayment(...) across ten controllers.
SEPA credit transfers via EPC QR are euro-only. If you setCurrency('CZK') you'll get a syntactically valid string that every banking app will reject on scan. Enforce EUR at your config/DTO layer and log if it's ever not.
The standard requires a plain decimal with a . separator and no thousands separator, or an empty field if no amount. Passing 1290.5 or "1 290,50" produces a bad code. Format explicitly:
->setAmount((float) number_format($cents / 100, 2, '.', ''))
Also: many banking apps treat amount as optional and let the payer edit it — if you need a fixed amount, test with the actual apps your customers use, not just one.
The spec's two remittance fields are mutually exclusive — you set either an unstructured text (≤140 chars) or an ISO 11649 structured reference (RF..., ≤35 chars). Setting both usually means the library writes only one, and the field you thought you set silently disappears. Decide which one your accounting needs (structured references reconcile better; free-text is friendlier to consumers) and only ever call one setter.
The payload is newline-delimited. Any \n or \r inside a name, remittance, or information field shifts every subsequent field and makes the code unreadable. Sanitize:
$text = preg_replace('/[\r\n]+/', ' ', $text);
$text = mb_substr(trim($text), 0, 70); // 70 for name/information, 140 for remittance
The spec also constrains the character set. Czech/German/Polish diacritics mostly survive UTF-8, but emoji, em-dashes, smart quotes, and non-Latin scripts will confuse scanners. A conservative Str::ascii() on name fields is often safer than trusting every bank app.
Name ≤ 70, information ≤ 70, unstructured remittance ≤ 140, structured reference ≤ 35. The package may not enforce all of these, and a too-long field can silently truncate or produce garbage. Truncate at your boundary with mb_substr, not substr (byte truncation can split a multibyte character and make the whole QR invalid UTF-8).
In EPC v2 the BIC is mandatory and lives in its own field, before the beneficiary name. Some older v1 codes omitted it. If you generate for a bank that only reads v1, you may need to test against that specific app — but do not omit the BIC on the assumption that "IBAN is enough"; the field order will break.
The EPC guidance recommends error-correction level M and a printed size of at least ~3 cm with a quiet zone (the white margin around the code, ~4 modules). A 100 px PNG in a corner of an A4 PDF will not scan from a phone. Use ≥ 300×300 px at render time and keep margin(16) or higher in endroid.
dd($payload) and inspect each line — count the newlines and confirm the field order (BCD, 002, 1, SCT, BIC, name, IBAN, amount, purpose, remittance, information, language).There aren't any — no events, no interfaces, no formatters to override. The effective extension point is your wrapper class. If you need behavior the library doesn't offer (e.g. validating IBAN checksum, supporting a second spec version, or emitting the language field), implement it in the wrapper by post-processing the string or by writing your own payload builder entirely. The format is ~12 lines; a hand-rolled builder is a legitimate alternative if you find yourself fighting this one.
How can I help you explore Laravel packages today?