PHPのフォームからパスポートの写真が届き、コードでは氏名、旅券番号、日付、そして機械読み取り領域(MRZ)をデータとして扱いたい。この記事では、その読み取り処理を2通りに作ります。1つは ext-curl だけを使う素のPHP 8.1版、もう1つは Http クライアントを使うLaravelのサービス版です。どちらもリクエストを1回送り、結果で分岐し、二重に支払わずにリトライし、すべての拒否を対処可能なエラーコードに変換します。

使うAPIは、doc.cheapのパスポート・身分証OCR APIです。ここはdoc.cheap自身のブログなので、製品に関する判断はその前提で受け止めてください。パターン(画像ごとに1つの冪等性キー、安定したエラーコードでの分岐、呼び出しごとの課金フラグの保存)は、PHPから呼び出すどの有料APIにもそのまま使えます。

アカウントなしで最初の呼び出し

ドキュメントには公開sandboxキー sk_sandbox_public が載っています。本物の認識を実行し、登録は不要で、クレジットも消費しません。上限はIPアドレスごとに1時間10リクエスト、クライアントごとに通算10件の無料認識で、それを超えると 403 registration_required が返ります。

リクエストは、JSONボディを付けた POST https://api.doc.cheap/v1/scans です。必須フィールドは image の1つだけで、ファイルのバイト列をbase64にしたもの(JPEGまたはPNG)を入れます。

<?php
$ch = curl_init('https://api.doc.cheap/v1/scans');
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => json_encode(['image' => base64_encode(file_get_contents('specimen.jpg'))]),
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer sk_sandbox_public',
        'Content-Type: application/json',
    ],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 60,
]);
$body = curl_exec($ch);
echo curl_getinfo($ch, CURLINFO_RESPONSE_CODE), PHP_EOL, $body, PHP_EOL;

呼び出しは同期型です。フィールドはこのレスポンスで返ってくるので、ポーリングするジョブIDも、用意すべきWebhookもありません。テストには合成の見本を使い、自分のパスポートは決して使わないでください。

ボディは厳格にチェックされます。オプション名のタイプミスのような未知のキーは、パスを示すメッセージ付きの 422 validation_failed で拒否されるので、タイプミスは無視されずにはっきり失敗します。サイズ上限を超えるボディは 413 payload_too_large です。写真のサイズ調整とすべてのオプションはパスポート認識ガイドにまとまっています。

返ってくるもの

どのレスポンスも同じ8つのグループを持ち、キーは常にすべて存在します。meta、document、holder、fields、mrz、images、quality、authenticity です。json_decode($body, true) のあと、わからない値は null になり、キーが欠けることはありません。一部を省略し、値を伏せた例を示します。

{
  "meta": { "id": "<SCAN_ID>", "status": "recognized", "billed": true, "confidence": "high", "reference": null },
  "document": { "kind": "passport", "country": "<ISO3>", "number": "<DOCUMENT_NUMBER>", "expiry_date": "<YYYY-MM-DD>", "is_expired": false },
  "holder": { "given_names": "<GIVEN_NAMES>", "surname": "<SURNAME>", "birth_date": "<YYYY-MM-DD>", "sex": "<M|F|X>", "nationality": "<ISO3>" },
  "mrz": { "status": "passed", "reason": null, "lines": ["<MRZ_LINE_1>", "<MRZ_LINE_2>"], "text": "<MRZ_TEXT>" }
}

次の4つが、コードの次の動きを決めます。

  • meta.status は5つの文字列のいずれかです:recognized、no_document_found、unreadable、unsupported_document、rejected。5つともHTTP 200 で届きます。データを持つのは最初の1つだけです。
  • document と holder は整理済みの値で、日付は YYYY-MM-DD 形式です。何も読み取れなかったグループは、グループごと null になります。
  • mrz.status は passed、failed、absent のいずれかです。passed は、領域が見つかり、すべてのチェックディジットが一致し、印字ページと矛盾する内容がないことを意味します。
  • meta.billed は、この呼び出しでクレジットが消費されたかどうかを示します。

両方のクライアントの形を決めるポイントはこれです。読み取れなかった写真は 200 であり、エラーではありません。 分岐は2回行います。拒否はエラーコードで、結果は meta.status で。no_document_found でリトライすると、決して読み取れない写真を送り直すことになります。成功として扱うと、フィールドのない書類を保存することになります。

PHPで自分でMRZを検証する

mrz.lines と mrz.text は読み取ったままの領域で、レスポンスの中で独立して検証できる唯一の部分です。パスポート(TD3形式)では、2行目に再計算しやすい3つのチェックディジットがあります。旅券番号、生年月日、有効期限です。各ディジットは重み7、3、1の繰り返しを使い、数字はその値、A から Z は10から35、埋め文字 < は0として数えます。

<?php
function mrz_check_digit(string $field): int
{
    $weights = [7, 3, 1];
    $sum = 0;
    foreach (str_split($field) as $i => $char) {
        $value = match (true) {
            ctype_digit($char) => (int) $char,
            ctype_upper($char) => ord($char) - 55, // A = 10 ... Z = 35
            default => 0,                          // 埋め文字 <
        };
        $sum += $value * $weights[$i % 3];
    }
    return $sum % 10;
}

function td3_line2_checks(string $line): array
{
    return [
        'number' => mrz_check_digit(substr($line, 0, 9)) === (int) $line[9],
        'birth_date' => mrz_check_digit(substr($line, 13, 6)) === (int) $line[19],
        'expiry_date' => mrz_check_digit(substr($line, 21, 6)) === (int) $line[27],
    ];
}

$scan['document']['kind'] が passport のとき、$scan['mrz']['lines'][1] に対して実行します。mrz.status を信頼するためにこれが必要なわけではありません。テストの中や、領域を保存しておいて何が読み取られたかを後で証明したいときに役立ちます。

誰が払うのか:billed フラグ

sandboxキーでは残高に一切触れません。本番キーでは、書類の種類が特定され、さらに次のいずれかが得られたときにだけスキャンが課金されます。チェックディジットが一致するMRZ、印字ページの5つ以上のフィールド、またはデコードできたバーコード。何も見つからない、読み取れない写真、未対応の種類、サービス側の失敗は、いずれも無料です。sk_sandbox_public でも、billed は同じスキャンが課金されたかどうかを示すので、これを使ったテストにも意味があります。

このフラグを自社の注文やユーザーの行の隣に保存しておけば、突き合わせの手順なしで記録と請求が一致します。アカウントには毎月100件の無料認識があり、有料クレジットより先に使われます。

二重課金にならないリトライ

危ないのはタイムアウト後のリトライです。最初のリクエストがサーバーに届いたかどうかわからないからです。Idempotency-Key ヘッダー(1~255文字)を送り、同じ画像の試行ではすべて同じ値を使い回してください。本番キーでは、サーバーはキーとボディ全体のフィンガープリントを組み合わせて照合します。

  • 同じキー、同じボディ、結果が保存済み:最初の結果が返され、再び課金されることはありません。
  • 同じキー、違うボディ:409 idempotency_conflict。ボディはループの外で1回だけ組み立ててください。
  • 最初のリクエストがまだ処理中:409 idempotency_in_progress。待ってから同じキーでリトライします。
  • 同じキーで保存済みの結果がない(たとえば retain_hours: 0 のあと):409 idempotency_replay_unavailable。新しいキーで送ります。

エラーボディはすべて同じ形(code、message、docs_url、request_id、event_id)で、分岐に使うべき値は code です。1つのHTTPステータスに、正反対の対処が必要なコードが含まれるからです。エラー処理のガイドではすべてのコードを分類しています。スキャンのクライアントが出会うのは次のものです。

コード 対応
rate_limited, document_repeated, internal_error, engine_unavailable, service_unavailable, maintenance 待って(Retry-After に従う)からリトライ
idempotency_in_progress 待って、同じキーでリトライ
validation_failed, invalid_request, payload_too_large, unsupported_media_type リクエストを直す。リトライしても再び失敗する
unauthorized, registration_required, insufficient_credits キーかアカウントを直す。待っても何も変わらない

Retry-After は整数の秒数です。sandboxの1時間あたりの上限では、1時間近く待つよう求められることがあります。そのため下の2つのクライアントは、PHPワーカーをブロックせず、待ち時間が1分を超えたら諦めます。

素のPHPクライアント

<?php
// scan.php – PHP 8.1以降、ext-curl と ext-json のみ

const API = 'https://api.doc.cheap/v1/scans';
const RETRYABLE = [
    'rate_limited', 'document_repeated', 'internal_error', 'engine_unavailable',
    'service_unavailable', 'maintenance', 'idempotency_in_progress',
];
const MAX_WAIT = 60; // 秒。これより長い Retry-After は待たずに報告する

final class ScanError extends RuntimeException
{
    public function __construct(public readonly int $status, public readonly array $error)
    {
        parent::__construct(sprintf('%s (%d): %s', $error['code'], $status, $error['message']));
    }
}

/** HTTPの試行1回:[ステータス, デコード済みボディ, Retry-After の秒数]。 */
function post_scan(string $apiKey, string $idempotencyKey, string $json): array
{
    $retryAfter = 0;
    $ch = curl_init(API);
    curl_setopt_array($ch, [
        CURLOPT_POST => true,
        CURLOPT_POSTFIELDS => $json,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 60,
        CURLOPT_HTTPHEADER => [
            'Authorization: Bearer ' . $apiKey,
            'Content-Type: application/json',
            'Idempotency-Key: ' . $idempotencyKey,
        ],
        CURLOPT_HEADERFUNCTION => function ($ch, string $header) use (&$retryAfter): int {
            if (stripos($header, 'Retry-After:') === 0) {
                $retryAfter = (int) trim(substr($header, 12));
            }
            return strlen($header);
        },
    ]);
    $body = curl_exec($ch);
    if ($body === false) {
        throw new RuntimeException('transport: ' . curl_error($ch));
    }
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    return [$status, json_decode($body, true, flags: JSON_THROW_ON_ERROR), $retryAfter];
}

function scan(string $path, ?string $reference = null, int $attempts = 4): array
{
    $bytes = file_get_contents($path);
    if ($bytes === false) {
        throw new RuntimeException("cannot read $path");
    }
    $apiKey = getenv('DOC_CHEAP_API_KEY') ?: 'sk_sandbox_public';
    $json = json_encode([
        'image' => base64_encode($bytes),
        'reference' => $reference,
        'options' => ['return_portrait' => false],
    ], JSON_THROW_ON_ERROR);
    $idempotencyKey = bin2hex(random_bytes(16)); // 画像ごとに1つのキー。すべての試行で使い回す

    for ($attempt = 1; ; $attempt++) {
        $last = $attempt >= $attempts;
        try {
            [$status, $payload, $retryAfter] = post_scan($apiKey, $idempotencyKey, $json);
        } catch (RuntimeException | JsonException $e) {
            // タイムアウト、切れた接続、またはHTMLを返すプロキシ。
            if ($last) {
                throw $e;
            }
            sleep(2 ** $attempt);
            continue;
        }
        if ($status === 200) {
            return $payload;
        }
        $error = $payload['error'];
        $wait = $retryAfter > 0 ? $retryAfter : 2 ** $attempt;
        if ($last || !in_array($error['code'], RETRYABLE, true) || $wait > MAX_WAIT) {
            throw new ScanError($status, $error);
        }
        sleep($wait);
    }
}

function summarise(array $scan): array
{
    $meta = $scan['meta'];
    if ($meta['status'] !== 'recognized') {
        return ['ok' => false, 'status' => $meta['status'], 'billed' => $meta['billed']];
    }
    $document = $scan['document'] ?? [];
    $holder = $scan['holder'] ?? [];
    return [
        'ok' => true,
        'billed' => $meta['billed'],
        'kind' => $document['kind'] ?? null,
        'country' => $document['country'] ?? null,
        'expiry_date' => $document['expiry_date'] ?? null,
        'surname' => $holder['surname'] ?? null,
        'birth_date' => $holder['birth_date'] ?? null,
        'mrz' => $scan['mrz']['status'],
    ];
}

try {
    echo json_encode(summarise(scan($argv[1], reference: 'demo-1'))), PHP_EOL;
} catch (ScanError $e) {
    fwrite(STDERR, $e->getMessage() . ' ' . $e->error['docs_url'] . ' ' . $e->error['request_id'] . PHP_EOL);
    exit(1);
}

php scan.php specimen.jpg で実行します。読み取れる見本なら、次の形の1行を出力します(値は伏せています)。

{"ok":true,"billed":true,"kind":"passport","country":"<ISO3>","expiry_date":"<YYYY-MM-DD>","surname":"<SURNAME>","birth_date":"<YYYY-MM-DD>","mrz":"passed"}

説明しておく価値のある選択がいくつかあります。

  • curlは4xxや5xxでは失敗しません。 エラーボディにコードが入っているので、クライアントはどちらの場合もJSONボディを読みます。
  • ボディとキーはループの前に1回だけ作ります。 これによって、サーバーから見てすべての試行が同じリクエストになります。
  • return_portrait: false は所持者の顔写真の切り抜きを応答から除くので、ログやストレージに残る顔が1つ減ります。
  • reference は meta.reference(最大128文字)として返ってきます。スキャンを自社の注文やユーザーに結び付ける簡単な方法です。
  • リクエストボディは決してログに残さないでください。 それは身分証明書です。ログに残すのは code、request_id、docs_url で、サポートに必要なのはそれだけです。

Laravel版

Laravelの Http クライアント(Laravel 10以降)は、ループの大部分をすでに担ってくれます。retry() は待ち時間を決めるクロージャと、試行を繰り返す価値があるかを判断するクロージャを受け取り、配列を渡した post() はJSONを送ります。キーは config/services.php に置きます。

'doc_cheap' => [
    'key' => env('DOC_CHEAP_API_KEY', 'sk_sandbox_public'),
],

そして小さなサービスを作ります。

<?php

namespace App\Services;

use Exception;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use RuntimeException;

final class PassportReader
{
    private const RETRYABLE = [
        'rate_limited', 'document_repeated', 'internal_error', 'engine_unavailable',
        'service_unavailable', 'maintenance', 'idempotency_in_progress',
    ];

    public function read(string $bytes, string $idempotencyKey, ?string $reference = null): array
    {
        $response = Http::baseUrl('https://api.doc.cheap/v1')
            ->withToken(config('services.doc_cheap.key'))
            ->withHeaders(['Idempotency-Key' => $idempotencyKey])
            ->acceptJson()
            ->timeout(60)
            ->retry(
                4,
                fn (int $attempt, Exception $e) => $this->waitSeconds($attempt, $e) * 1000,
                fn (Exception $e) => $this->worthRetrying($e),
                throw: false,
            )
            ->post('scans', [
                'image' => base64_encode($bytes),
                'reference' => $reference,
                'options' => ['return_portrait' => false],
            ]);

        if ($response->successful()) {
            return $response->json();
        }

        $error = $response->json('error') ?? ['code' => 'unknown', 'message' => $response->reason()];
        throw new RuntimeException(sprintf('%s (%d): %s', $error['code'], $response->status(), $error['message']));
    }

    private function waitSeconds(int $attempt, Exception $e): int
    {
        $after = $e instanceof RequestException ? (int) $e->response->header('Retry-After') : 0;

        return $after > 0 ? $after : 2 ** $attempt;
    }

    private function worthRetrying(Exception $e): bool
    {
        if ($e instanceof ConnectionException) {
            return true; // タイムアウトか切れた接続:キーがあるのでリトライは安全
        }

        return $e instanceof RequestException
            && in_array($e->response->json('error.code'), self::RETRYABLE, true)
            && (int) $e->response->header('Retry-After') <= 60;
    }
}

throw: false を指定すると、リトライする価値のない拒否はレスポンスとして返り、サービスがそれをコード名入りの例外に変換します。自分のコードでは、その例外に専用のクラスを用意して error 配列を持たせてください。そうすれば呼び出し側はメッセージではなく code で分岐できます。

アップロードを受け取り、必要なものだけを保存するコントローラーです。

public function store(Request $request, PassportReader $reader)
{
    $request->validate(['passport' => ['required', 'file', 'mimes:jpg,png', 'max:10240']]);

    $scan = $reader->read(
        $request->file('passport')->get(),
        (string) Str::uuid(),
        reference: (string) $request->user()->id,
    );

    if ($scan['meta']['status'] !== 'recognized') {
        return back()->withErrors(['passport' => 'We could not read that photo. Please try a sharper one.']);
    }

    $request->user()->passportChecks()->create([
        'scan_id' => $scan['meta']['id'],
        'billed' => $scan['meta']['billed'],
        'expiry_date' => $scan['document']['expiry_date'] ?? null,
        'mrz_status' => $scan['mrz']['status'],
    ]);

    return redirect()->route('profile');
}

キュー投入するジョブでは、キーをジョブの一部にします。 呼び出しをジョブに移すなら、アップロードが届いた時点で冪等性キーを生成し、ジョブのコンストラクタに渡してください。そうすれば、失敗してキューワーカーにリトライされたジョブも同じキーを送り、すでに実行済みのスキャンは再課金されずに再送されます。handle() の中で新しく Str::uuid() を作ると、この仕組みが無効になります。

リトライの経路は Http::fake() でテストします。 429 のあとに 200 を返すシーケンスで、ネットワークを呼ばずに、2回目の試行が行われ、同じキーを運ぶことを証明できます。

Http::fake([
    'api.doc.cheap/*' => Http::sequence()
        ->push(['error' => ['code' => 'rate_limited', 'message' => 'Slow down', 'docs_url' => null, 'request_id' => 'req_test', 'event_id' => null]], 429, ['Retry-After' => '1'])
        ->push(['meta' => ['id' => 'scan_test', 'status' => 'no_document_found', 'billed' => false]], 200),
]);

$scan = app(PassportReader::class)->read('fake-bytes', 'key-1');

Http::assertSentCount(2);
Http::assertNotSent(fn ($request) => $request->header('Idempotency-Key') !== ['key-1']);

保存するデータを減らす

呼び出しのあと、アップロードされた画像を保存しないでください。結果から必要なものだけを読み取り、バイト列は捨てます。サービス側では、GET /v1/scans/{id} で再取得できるよう結果が保存されます。期間はリクエストごとに選べます。options.retain_hours は0から8760を受け付け、0 なら何も書き込まれません。その代わり、上の再送ルールが効いてきます。0 の場合、同じキーでのリトライは idempotency_replay_unavailable を受け取ります。両方を取りたいなら、0より大きい短い期間を選んでください。

本番への移行

DOC_CHEAP_API_KEY に自分の sk_live_ キーを設定するだけで、ほかは何も変わりません。同じエンドポイント、同じレスポンス、同じクライアントです。登録済みのキーは1分あたり60リクエストまで使えるので、Retry-After に従うバッチ処理に独自のレート制限は要りません。まだサービスを選んでいる段階なら、パスポートOCR APIの比較で、有料の選択肢とそれぞれが公開している料金を確認できます。

**最後に質問です。**いまPHPでは、MRZを自分でパースしていますか。それともサービスに任せていますか。下のコメント欄で教えてください。