Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
ffcaa91
build: require PHP 8.2, upgrade to Pest 3 and expand CI matrix
rakibdevs Jul 1, 2026
76f25a3
build: require PHP 8.2, upgrade to Pest 3 and expand CI matrix
rakibdevs Jul 1, 2026
ad97d4c
refactor: modernize core to PHP 8.2 and normalize input centrally
rakibdevs Jul 1, 2026
8c35988
refactor: modernize core to PHP 8.2 and normalize input centrally
rakibdevs Jul 1, 2026
1cd7a04
fix: strict dates, century pivot, and consistent mappers
rakibdevs Jul 1, 2026
8552ead
fix: strict dates, century pivot, and consistent mappers
rakibdevs Jul 1, 2026
b9a9a83
feat: add ICAO 9303 check-digit validation and richer output
rakibdevs Jul 1, 2026
f1f550b
feat: add ICAO 9303 check-digit validation and richer output
rakibdevs Jul 1, 2026
a67681d
test: add full Pest suite covering parsers, validation and mappers
rakibdevs Jul 1, 2026
d3b67c9
test: add full Pest suite covering parsers, validation and mappers
rakibdevs Jul 1, 2026
e65f78c
docs: document v2.0 output keys, validation and requirements
rakibdevs Jul 1, 2026
ce2745b
docs: document v2.0 output keys, validation and requirements
rakibdevs Jul 1, 2026
9a01cd1
feat: add MrzResult DTO, non-throwing and strict entry points
rakibdevs Jul 1, 2026
a5f665c
feat: add MrzResult DTO, non-throwing and strict entry points
rakibdevs Jul 1, 2026
82af4ff
feat: add name transliteration and per-length visa optional data
rakibdevs Jul 1, 2026
6e958d6
feat: add name transliteration and per-length visa optional data
rakibdevs Jul 1, 2026
76c9d85
feat: support the French national ID (legacy 2x36 format)
rakibdevs Jul 1, 2026
a379e56
feat: support the French national ID (legacy 2x36 format)
rakibdevs Jul 1, 2026
bc46ba8
feat: add Laravel service provider and ValidMrz rule
rakibdevs Jul 1, 2026
9d00e54
feat: add Laravel service provider and ValidMrz rule
rakibdevs Jul 1, 2026
9daa9cb
test: cover DTO, entry points, transliteration, French ID and Laravel…
rakibdevs Jul 1, 2026
18ea908
test: cover DTO, entry points, transliteration, French ID and Laravel…
rakibdevs Jul 1, 2026
62e048b
docs: document advanced usage, Laravel, French ID and add badges
rakibdevs Jul 1, 2026
75fd8ed
docs: document advanced usage, Laravel, French ID and add badges
rakibdevs Jul 1, 2026
8492f9a
Merge branch 'hotfix/upgrade' of github-personal:rakibdevs/mrz-parser…
rakibdevs Jul 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/php-cs-fixer.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ jobs:

steps:
- name: Checkout code
uses: actions/checkout@v3
uses: actions/checkout@v4
with:
ref: ${{ github.head_ref }}

Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/run-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@ jobs:
fail-fast: true
matrix:
os: [ubuntu-latest, windows-latest]
php: [8.0]
php: [8.2, 8.3, 8.4, 8.5]
stability: [prefer-lowest, prefer-stable]

name: P${{ matrix.php }} - ${{ matrix.stability }} - ${{ matrix.os }}

steps:
- name: Checkout code
uses: actions/checkout@v3
uses: actions/checkout@v4

- name: Setup PHP
uses: shivammathur/setup-php@v2
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/update-changelog.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ jobs:

steps:
- name: Checkout code
uses: actions/checkout@v3
uses: actions/checkout@v4
with:
ref: main

Expand Down
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,39 @@

All notable changes to `mrz-parser` will be documented in this file.

## v2.0.0 - 2026-07-01

### Breaking changes

- Minimum PHP version is now **8.2** (was 8.0).
- `parse()` now returns additional keys (`issuer_code`, `nationality_code`, `sex`, `optional_data`, `valid`, `validation`, `raw`). Existing keys are unchanged.
- `Enums\DocumentType` is now a real backed enum instead of an abstract class with constants.

### Added

- **ICAO 9303 check-digit validation.** Each result exposes an overall `valid` flag plus a per-field `validation` map (document number, date of birth, date of expiry, personal number, and composite).
- Richer output: raw ISO alpha-3 codes (`issuer_code`, `nationality_code`), raw `sex` character, `optional_data`, and the normalized `raw` MRZ string.
- **Typed `MrzResult` object** via `MrzParser::read()` — typed properties, `dateOfBirthAsDate()`/`dateOfExpiryAsDate()`, `isExpired()`, `fullName()`, and `toArray()`.
- **Non-throwing entry points** `MrzParser::tryParse()` and `MrzParser::tryRead()` returning `null` on invalid input.
- **Strict check-digit mode** (`parse($text, strict: true)`) throwing `InvalidChecksumException` when a check digit fails.
- **Name transliteration** (`Support\Transliterator`): `fromLatin()` and `matches()` for converting/comparing accented names against the MRZ.
- **French national ID** (2×36 legacy format) support.
- **Laravel integration**: auto-discovered service provider and a `ValidMrz` validation rule.
- A full Pest test suite (parsers, validator, check digits, mappers, DTO, transliteration, Laravel rule) with ~99% coverage.

### Fixed

- CRLF / whitespace input is now normalized, so Windows- and OCR-sourced MRZ strings parse correctly instead of failing validation.
- Strict date validation rejects impossible calendar dates (e.g. Feb 30) instead of silently rolling them over.
- A date of birth that would resolve to the future is shifted back a century.
- Removed a PHP 8.4 implicit-nullable deprecation and guarded against `substr(null, ...)` on missing rows.
- Consistent trailing-filler trimming for given names across all document types.

### Changed

- Modernized the codebase: `declare(strict_types=1)`, typed properties, `match`-based adapter selection, and a cached country lookup table.
- CI now tests PHP 8.2, 8.3, 8.4 and 8.5; upgraded to Pest 3.

## v1.2.0 - 2022-06-30

**Full Changelog**: https://github.com/rakibdevs/mrz-parser/compare/v1.1.0...v1.2.0
Expand Down
100 changes: 99 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,20 @@
# MRZ (Machine Readable Zones) Parser for PHP

[![Latest Version on Packagist](https://img.shields.io/packagist/v/rakibdevs/mrz-parser.svg?style=flat-square)](https://packagist.org/packages/rakibdevs/mrz-parser)
[![Tests](https://img.shields.io/github/actions/workflow/status/rakibdevs/mrz-parser/run-tests.yml?branch=main&label=tests&style=flat-square)](https://github.com/rakibdevs/mrz-parser/actions/workflows/run-tests.yml)
[![PHP Version](https://img.shields.io/packagist/dependency-v/rakibdevs/mrz-parser/php?style=flat-square)](https://packagist.org/packages/rakibdevs/mrz-parser)
[![Total Downloads](https://img.shields.io/packagist/dt/rakibdevs/mrz-parser.svg?style=flat-square)](https://packagist.org/packages/rakibdevs/mrz-parser)
[![License](https://img.shields.io/packagist/l/rakibdevs/mrz-parser.svg?style=flat-square)](LICENSE.md)


A PHP package for MRZ (Machine Readable Zones) code parser for Passport, Visa & Travel Document (TD1 & TD2).

It reads the machine-readable zone, extracts the document fields, and — new in v2 — verifies the ICAO 9303 check digits so you can tell whether the data is internally consistent.

## Requirements

- PHP 8.2 or higher

## Installation

You can install the package via composer:
Expand All @@ -31,16 +41,97 @@ var_dump($data);
"type": "Passport",
"card_no": "L898902C3",
"issuer": "Utopian",
"issuer_code": "UTO",
"date_of_expiry": "2012-04-15",
"first_name": "ANNA MARIA",
"last_name": "ERIKSSON",
"date_of_birth": "1974-08-12",
"gender": "Female",
"sex": "F",
"personal_number": "ZE184226B",
"nationality": "Utopian"
"optional_data": "ZE184226B",
"nationality": "Utopian",
"nationality_code": "UTO",
"valid": true,
"validation": {
"card_no": true,
"date_of_birth": true,
"date_of_expiry": true,
"personal_number": true,
"composite": true
},
"raw": "P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<\nL898902C36UTO7408122F1204159ZE184226B<<<<<10"
}
```

### Check-digit validation

Every result carries a `valid` flag (overall) and a `validation` map with the outcome of each ICAO 9303 check digit (`card_no`, `date_of_birth`, `date_of_expiry`, `personal_number` for passports, and the `composite`). Visas carry no composite check digit, so their `validation` map omits it. Use these to reject OCR mistakes or tampered documents.

### Input handling

Input is normalized before parsing: `\r\n` / `\r` line endings are converted to `\n`, surrounding whitespace is trimmed, and blank lines are dropped — so MRZ strings copied from Windows or OCR output parse the same as clean input. Dates are validated strictly (impossible dates such as Feb 30 return `null`), and a date of birth that would resolve to the future is shifted back a century.

## Advanced usage

### Typed result object

`read()` returns an `MrzResult` value object with typed properties and helpers, while `toArray()` gives you the same array as `parse()`:

```php
$mrz = MrzParser::read($text);

$mrz->cardNo; // 'L898902C3'
$mrz->fullName(); // 'ANNA MARIA ERIKSSON'
$mrz->dateOfBirthAsDate(); // DateTimeImmutable
$mrz->isExpired(); // bool (or null when the format has no expiry)
$mrz->valid; // bool
$mrz->toArray(); // the canonical parse() array
```

### Non-throwing parsing

`parse()`/`read()` throw on unrecognised input. Use the `try*` variants to get `null` instead:

```php
MrzParser::tryParse($text); // ?array
MrzParser::tryRead($text); // ?MrzResult
```

### Strict check-digit mode

Pass `strict: true` to reject documents whose check digits don't verify (throws `InvalidChecksumException`; the `try*` variants return `null`):

```php
MrzParser::parse($text, strict: true);
MrzParser::read($text, strict: true);
```

### Name transliteration

The MRZ name field only holds `A–Z`. Convert a visual-zone (accented) name to its MRZ form, or compare the two:

```php
use Rakibdevs\MrzParser\Support\Transliterator;

Transliterator::fromLatin('Müller'); // 'MUELLER'
Transliterator::fromLatin('Jørgensen'); // 'JOERGENSEN'
Transliterator::matches('MUELLER', 'Müller'); // true
```

### Laravel

The package ships an auto-discovered service provider and a validation rule (requires `illuminate/support`):

```php
use Rakibdevs\MrzParser\Laravel\Rules\ValidMrz;

$request->validate([
'mrz' => [new ValidMrz()], // must be a parseable MRZ
'mrz' => [new ValidMrz(strict: true)], // ...with valid check digits
]);
```

## Supported Document
#### [Passport (TD3)](https://www.icao.int/publications/Documents/9303_p4_cons_en.pdf)
```
Expand Down Expand Up @@ -70,6 +161,13 @@ I<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<
D231458907UTO7408122F1204159<<<<<<<6
```

#### French National ID (legacy 2×36 Carte Nationale d'Identité)
This pre-2021 format predates ICAO TD1/TD2 and has its own layout (no expiry date is encoded).
```
IDFRADOUEL<<<<<<<<<<<<<<<<<<<<932013
0506932020438CHRISTIANE<<NI2906209F3
```


## Changelog

Expand Down
23 changes: 18 additions & 5 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,15 @@
}
],
"require": {
"php": "^8.0"
"php": "^8.2"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.0",
"pestphp/pest": "^1.20",
"spatie/ray": "^1.28"
"friendsofphp/php-cs-fixer": "^3.64",
"illuminate/support": "^10.0|^11.0|^12.0",
"pestphp/pest": "^3.0"
},
"suggest": {
"illuminate/support": "Required to use the Laravel service provider and the ValidMrz validation rule."
},
"autoload": {
"psr-4": {
Expand All @@ -38,7 +41,17 @@
"format": "vendor/bin/php-cs-fixer fix --allow-risky=yes"
},
"config": {
"sort-packages": true
"sort-packages": true,
"allow-plugins": {
"pestphp/pest-plugin": true
}
},
"extra": {
"laravel": {
"providers": [
"Rakibdevs\\MrzParser\\Laravel\\MrzParserServiceProvider"
]
}
},
"minimum-stability": "dev",
"prefer-stable": true
Expand Down
9 changes: 3 additions & 6 deletions phpunit.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -3,30 +3,27 @@
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
backupGlobals="false"
backupStaticAttributes="false"
bootstrap="vendor/autoload.php"
colors="true"
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="true"
processIsolation="false"
stopOnFailure="false"
executionOrder="random"
failOnWarning="true"
failOnRisky="true"
failOnEmptyTestSuite="true"
beStrictAboutOutputDuringTests="true"
verbose="true"
>
<testsuites>
<testsuite name="Rakibdevs Test Suite">
<directory>tests</directory>
</testsuite>
</testsuites>
<coverage>
<source>
<include>
<directory suffix=".php">./src</directory>
</include>
</source>
<coverage>
<report>
<html outputDirectory="build/coverage"/>
<text outputFile="build/coverage.txt"/>
Expand Down
4 changes: 3 additions & 1 deletion src/Contracts/ParserInterface.php
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
<?php

declare(strict_types=1);

namespace Rakibdevs\MrzParser\Contracts;

interface ParserInterface
{
public function parse(string $text);
public function parse(string $text): ?array;
}
13 changes: 8 additions & 5 deletions src/Enums/DocumentType.php
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
<?php

declare(strict_types=1);

namespace Rakibdevs\MrzParser\Enums;

abstract class DocumentType
enum DocumentType: string
{
public const PASSPORT = "passport";
public const VISA = "visa";
public const TRAVEL_DOCUMENT_1 = "travel_document_1";
public const TRAVEL_DOCUMENT_2 = "travel_document_2";
case PASSPORT = 'passport';
case VISA = 'visa';
case TRAVEL_DOCUMENT_1 = 'travel_document_1';
case TRAVEL_DOCUMENT_2 = 'travel_document_2';
case FRENCH_ID = 'french_id';
}
11 changes: 11 additions & 0 deletions src/Exceptions/InvalidChecksumException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

declare(strict_types=1);

namespace Rakibdevs\MrzParser\Exceptions;

use Exception;

class InvalidChecksumException extends Exception
{
}
2 changes: 2 additions & 0 deletions src/Exceptions/InvalidFormatException.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<?php

declare(strict_types=1);

namespace Rakibdevs\MrzParser\Exceptions;

use Exception;
Expand Down
2 changes: 2 additions & 0 deletions src/Exceptions/NotSupportedException.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<?php

declare(strict_types=1);

namespace Rakibdevs\MrzParser\Exceptions;

use Exception;
Expand Down
17 changes: 17 additions & 0 deletions src/Laravel/MrzParserServiceProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace Rakibdevs\MrzParser\Laravel;

use Illuminate\Support\ServiceProvider;
use Rakibdevs\MrzParser\MrzParser;

class MrzParserServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(MrzParser::class, static fn () => new MrzParser());
$this->app->alias(MrzParser::class, 'mrz-parser');
}
}
29 changes: 29 additions & 0 deletions src/Laravel/Rules/ValidMrz.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

declare(strict_types=1);

namespace Rakibdevs\MrzParser\Laravel\Rules;

use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Rakibdevs\MrzParser\MrzParser;

/**
* Laravel validation rule: passes when the value is a parseable MRZ.
*
* $request->validate(['mrz' => [new ValidMrz()]]);
* $request->validate(['mrz' => [new ValidMrz(strict: true)]]); // require check digits
*/
class ValidMrz implements ValidationRule
{
public function __construct(private readonly bool $strict = false)
{
}

public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_string($value) || MrzParser::tryParse($value, $this->strict) === null) {
$fail('The :attribute is not a valid machine-readable zone.');
}
}
}
Loading
Loading