ahr-ahr / qris-dynamic
PHP library for parsing and generating dynamic QRIS payloads.
Requires
- php: ^8.1
- endroid/qr-code: ^6.0
- khanamiryan/qrcode-detector-decoder: ^2.0
Requires (Dev)
- laravel/pint: ^1.13
- phpunit/phpunit: ^11.0
README
English | Bahasa Indonesia
QRIS Dynamic
PHP library for parsing, validating, decoding, and generating dynamic QRIS payloads.
Why This Library Exists
Most QRIS-related repositories only do one thing:
- Inject amount using regex
- Replace strings blindly
- Ignore TLV structure
- Ignore CRC validation
- Fail on nested QRIS tags
This library was built differently.
qris-dynamic understands the actual QRIS payload structure using a recursive TLV parser, CRC16 validation, payload abstraction, and dynamic QRIS generation.
What is QRIS Internally?
QRIS payload is basically:
TAG + LENGTH + VALUE
Example:
540510000
Can be read as:
| Part | Meaning |
|---|---|
54 |
Transaction Amount |
05 |
Length |
10000 |
Value |
So mathematically:
TLV = T + L + V
Where:
T = Tag L = Length V = Value
QRIS Anatomy
Example QRIS payload:
00020101021126630016COM.EXAMPLE.PSP011212345678905204481253033605802ID5915SAMPLE MERCHANT6013MERCHANT CITY6304ABCD
Visual Breakdown
graph TD QRIS[QRIS Payload] QRIS --> T00[00 - Payload Format] QRIS --> T01[01 - QR Type] QRIS --> T26[26 - Merchant Info] QRIS --> T52[52 - MCC] QRIS --> T53[53 - Currency] QRIS --> T54[54 - Amount] QRIS --> T58[58 - Country] QRIS --> T59[59 - Merchant Name] QRIS --> T60[60 - City] QRIS --> T63[63 - CRC]Loading
Important QRIS Tags
| Tag | Meaning | Example |
|---|---|---|
00 |
Payload Format Indicator | 01 |
01 |
QR Type | 11 = Static |
26 |
Merchant Account Info | Payment Provider |
52 |
Merchant Category Code | 4812 |
53 |
Currency | 360 = IDR |
54 |
Transaction Amount | 10000 |
58 |
Country Code | ID |
59 |
Merchant Name | SAMPLE MERCHANT |
60 |
Merchant City | MERCHANT CITY |
63 |
CRC16 Checksum | ABCD |
Static vs Dynamic QRIS
Static QRIS
Static QRIS usually does not include tag 54 (transaction amount).
The customer enters the amount manually during payment.
Dynamic QRIS
Dynamic QRIS contains transaction amount.
Example:
540510000
Can be interpreted as:
| Part | Meaning |
|---|---|
54 |
Amount Tag |
05 |
Length |
10000 |
Amount |
This library injects the amount automatically and recalculates CRC16 safely.
CRC16 Visualization
CRC is the integrity protection layer of QRIS.
If payload changes:
Merchant Name Amount City Any Tag
then CRC becomes invalid.
Simplified CRC Flow
flowchart LR A[Raw TLV Payload] --> B[CRC16 Calculation] B --> C[Hex Checksum] C --> D[Append to Tag 63]Loading
Nested Merchant Information
Some QRIS tags contain nested TLV values.
Example:
26 ├── 00 → COM.EXAMPLE.PSP ├── 01 → Merchant ID ├── 02 → Merchant Criteria └── 03 → UMI
This library recursively parses nested structures automatically.
TLV Structure Visualization
graph TD A[54] --> B[05] B --> C[10000] A:::tag B:::length C:::value classDef tag fill:#ffb703,color:#000; classDef length fill:#8ecae6,color:#000; classDef value fill:#90be6d,color:#000;Loading
How Parsing Works
QRIS parsing is essentially a sequential string slicing process.
The parser reads:
TAG → LENGTH → VALUE
repeatedly until the payload ends.
Example
Payload:
540510000
Parser reads it like this:
flowchart LR A["54"] --> B["05"] B --> C["10000"] A:::tag B:::length C:::value classDef tag fill:#ffb703,color:#000; classDef length fill:#8ecae6,color:#000; classDef value fill:#90be6d,color:#000;Loading
Mathematical Interpretation
If:
T = Tag L = Length V = Value
then:
TLV = T + L + V
and:
Length(V) = L
Example:
54 05 10000
means:
Length("10000") = 5
which is valid.
Sequential Parsing Algorithm
The parser works using offsets.
Pseudo flow:
1. Read 2 characters → TAG 2. Read next 2 chars → LENGTH 3. Read next N chars → VALUE 4. Move offset 5. Repeat
Offset Visualization
flowchart LR A["Read Tag<br>Offset 0"] --> B["Read Length<br>Offset 2"] --> C["Read Value<br>Offset 4"] --> D["Move Offset"]Loading
Parser Complexity
The TLV parser operates in linear time complexity.
Time Complexity: O(n) Space Complexity: O(n) worst case
because the payload is scanned sequentially using offsets.
Nested TLV Parsing
Some QRIS tags contain another TLV structure internally.
Example:
26 ├── 00 → Provider ├── 01 → Merchant ID └── 03 → Category
This means the parser must recursively parse child payloads.
Recursive Parsing Visualization
graph TD A[Root Payload] A --> B[Tag 26] B --> C[Subtag 00] B --> D[Subtag 01] B --> E[Subtag 03]Loading
Internal Parsing Flow
flowchart TD A[QR Image] --> B[Decode Image] --> C[Extract Payload] --> D[Parse TLV] --> E[Build Payload Object] --> F[Modify Tags] --> G[Rebuild TLV] --> H[Generate CRC16] --> I[Dynamic QRIS]Loading
Why Recursive Parsing Matters
Many QRIS implementations fail because merchant account information itself contains nested TLV payloads.
Naive parsers usually:
- split incorrectly
- fail on nested tags
- break CRC generation
- generate invalid QRIS payloads
This library recursively parses nested structures safely before rebuilding the payload and recalculating CRC16.
Features
- Decode QRIS image
- Parse TLV payloads
- Recursive nested TLV parser
- Generate dynamic QRIS
- CRC16 generation & validation
- Fluent API
- Payload abstraction
- QRIS validation
- Exception hierarchy
- PHPUnit tested
Installation
composer require ahr-ahr/qris-dynamic
Basic Usage
use AhrAhr\QRIS\QRIS; $qris = QRIS::fromImage( 'images/sample-qris.png' ); $payload = $qris ->amount(10000) ->generate(); echo $payload;
Generate Dynamic QRIS
<?php require 'vendor/autoload.php'; use AhrAhr\QRIS\QRIS; $qris = QRIS::fromImage( 'images/sample-qris.png' ); $payload = $qris ->amount(25000) ->generate(); echo $payload;
Decode QRIS Image
<?php use AhrAhr\QRIS\Decoder\QRImageDecoder; $result = QRImageDecoder::decode( 'images/sample-qris.png' ); echo $result->payload;
Parse TLV Payload
<?php use AhrAhr\QRIS\Parser\TLVParser; $nodes = TLVParser::parse($payload); foreach ($nodes as $node) { echo $node->tag . PHP_EOL; echo $node->value . PHP_EOL; }
Example Output
[00] Payload Format Indicator [01] Point of Initiation Method [26] Merchant Account Information [00] Globally Unique Identifier [01] Merchant ID [52] Merchant Category Code [53] Transaction Currency [54] Transaction Amount [59] Merchant Name [63] CRC
Validation Logic
QRIS validation in this library consists of two layers:
- Structural validation
- CRC16 checksum validation
Structural Validation
The parser verifies:
- Required tags exist
- TLV lengths are valid
- Nested payloads are parsable
Example:
54 05 10000
is valid because:
Length("10000") = 5
CRC16 Validation
CRC16 ensures payload integrity.
If even one character changes:
Merchant Name Amount Country Any Value
then checksum becomes invalid.
Validation Pipeline
flowchart LR A[Raw Payload] --> B[Parse TLV] --> C[Validate Required Tags] --> D[Validate CRC16] --> E[Valid QRIS]Loading
Validate QRIS
Quick Validation
use AhrAhr\QRIS\Parser\PayloadValidator; $isValid = PayloadValidator::validate( $payload ); var_dump($isValid);
Validation With Exceptions
use AhrAhr\QRIS\Exceptions\QRISException; use AhrAhr\QRIS\Parser\PayloadValidator; try { PayloadValidator::validateOrFail( $payload ); } catch (QRISException $e) { echo $e->getMessage(); }
Fluent API
$qris = QRIS::fromImage( 'images/sample-qris.png' ); $generated = $qris ->amount(10000) ->generate();
Exception Hierarchy
classDiagram QRISException <|-- InvalidPayloadException QRISException <|-- InvalidCRCException QRISException <|-- UnsupportedTagExceptionLoading
Example Parsed QRIS
[00] Payload Format Indicator [01] Point of Initiation Method [26] Merchant Account Information [00] Globally Unique Identifier [01] Merchant ID [03] Merchant Category [52] Merchant Category Code [53] Transaction Currency [54] Transaction Amount [58] Country Code [59] Merchant Name [60] Merchant City [63] CRC
Project Structure
qris-dynamic/ │ ├── src/ │ ├── QRIS.php │ │ │ ├── Contracts/ │ │ ├── ParserInterface.php │ │ ├── GeneratorInterface.php │ │ └── QRISInterface.php │ │ │ ├── Decoder/ │ │ ├── QRImageDecoder.php │ │ └── QRDecoderResult.php │ │ │ ├── Parser/ │ │ ├── TLVParser.php │ │ ├── TLVNode.php │ │ ├── Payload.php │ │ └── PayloadValidator.php │ │ │ ├── Generator/ │ │ ├── DynamicQRISGenerator.php │ │ ├── StaticQRISGenerator.php │ │ └── CRC16.php │ │ │ ├── ValueObjects/ │ │ ├── Amount.php │ │ ├── MerchantInfo.php │ │ ├── Tag.php │ │ └── CRC.php │ │ │ ├── Exceptions/ │ │ ├── InvalidCRCException.php │ │ ├── InvalidPayloadException.php │ │ ├── UnsupportedTagException.php │ │ └── QRISException.php │ │ │ └── Utils/ │ ├── StringHelper.php │ └── TagHelper.php │ ├── examples/ │ ├── decode-image.php │ ├── parse.php │ ├── fluent-api.php │ ├── generate-image.php │ └── validate.php │ ├── images/ │ └── sample-qris.png │ ├── tests/ │ ├── composer.json ├── phpunit.xml ├── README.md └── LICENSE
Testing
Run all tests:
composer test
Current Test Status
25 tests 35 assertions All passing
Roadmap
- QRIS image exporting helper
- Better merchant abstraction
- Tag specification validation
- Multi-provider QRIS utilities
- Packagist release
- GitHub Actions CI
License
This project is licensed under the MIT License.
Disclaimer
This library is an independent open-source project and is not affiliated with Bank Indonesia or any payment provider.
Use responsibly and always validate generated QRIS payloads before production use.