Über ein PHP-Formular kommt das Foto eines Reisepasses herein, und Ihr Code braucht Name, Nummer, Daten und die maschinenlesbare Zone (MRZ) als Daten. Dieser Beitrag baut diesen Leser zweimal: einmal in reinem PHP 8.1 mit ext-curl und sonst nichts, und einmal als Laravel-Service auf dem Http-Client. Beide Versionen senden eine Anfrage, verzweigen nach dem Ergebnis, wiederholen, ohne doppelt zu zahlen, und machen aus jeder Ablehnung einen Fehlercode, auf den Sie reagieren können.

Die API ist die OCR-API für Reisepässe und Ausweise hinter doc.cheap, und dies ist der eigene Blog von doc.cheap; gewichten Sie die Produktentscheidungen entsprechend. Die Muster (ein Idempotenzschlüssel pro Bild, Verzweigen nach einem stabilen Fehlercode, ein Abrechnungs-Flag pro Aufruf speichern) lassen sich auf jede kostenpflichtige API übertragen, die Sie aus PHP aufrufen.

Der erste Aufruf, ohne Konto

Die Dokumentation nennt einen öffentlichen Sandbox-Schlüssel, sk_sandbox_public. Er führt eine echte Erkennung aus, braucht keine Registrierung und verbraucht kein Guthaben. Er erlaubt 10 Anfragen pro Stunde und IP-Adresse und insgesamt 10 kostenlose Erkennungen pro Client; danach antwortet er mit 403 registration_required.

Die Anfrage ist POST https://api.doc.cheap/v1/scans mit einem JSON-Body. Ein Feld ist Pflicht, image: die Bytes der Datei als Base64, JPEG oder 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;

Der Aufruf ist synchron: Die Felder kommen in genau dieser Antwort zurück, ohne Job-ID zum Abfragen und ohne Webhook, den Sie betreiben müssten. Testen Sie mit einem synthetischen Muster, nie mit Ihrem eigenen Reisepass.

Der Body wird streng geprüft. Ein unbekannter Schlüssel, etwa eine falsch geschriebene Option, wird mit 422 validation_failed und einer Meldung abgelehnt, die den Pfad nennt; ein Tippfehler scheitert also laut, statt ignoriert zu werden. Ein Body über der Größengrenze ergibt 413 payload_too_large; der Leitfaden zur Reisepass-Erkennung behandelt die passende Fotogröße und jede Option.

Was zurückkommt

Jede Antwort hat dieselben acht Gruppen, und jeder Schlüssel ist immer vorhanden: meta, document, holder, fields, mrz, images, quality und authenticity. Nach json_decode($body, true) ist ein unbekannter Wert null, nie ein fehlender Schlüssel. Ein gekürztes, maskiertes Beispiel:

{
  "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>" }
}

Vier Teile entscheiden, was Ihr Code als Nächstes tut:

  • meta.status ist einer von fünf Strings: recognized, no_document_found, unreadable, unsupported_document, rejected. Alle fünf kommen als HTTP 200. Nur der erste trägt Daten.
  • document und holder sind die aufbereiteten Werte, mit Datumsangaben als YYYY-MM-DD. Jede Gruppe ist als Ganzes null, wenn für sie nichts gelesen wurde.
  • mrz.status ist passed, failed oder absent. passed heißt, die Zone wurde gefunden, jede Prüfziffer wurde validiert, und nichts darin widerspricht der gedruckten Seite.
  • meta.billed sagt, ob dieser Aufruf ein Guthaben verbraucht hat.

Der Punkt, der beide Clients prägt: Ein Foto, das nicht gelesen werden konnte, ist ein 200, kein Fehler. Verzweigen Sie zweimal: nach dem Fehlercode bei Ablehnungen und nach meta.status bei Ergebnissen. Wiederholen Sie bei no_document_found, senden Sie ein Foto erneut, das nie lesbar sein wird; werten Sie es als Erfolg, speichern Sie ein Dokument ohne Felder.

Die MRZ selbst in PHP prüfen

mrz.lines und mrz.text sind die Zone genau so, wie sie gelesen wurde, und sie sind der einzige Teil der Antwort, den Sie unabhängig nachprüfen können. Bei einem Reisepass (dem Format TD3) trägt die zweite Zeile drei Prüfziffern, die sich leicht nachrechnen lassen: Dokumentnummer, Geburtsdatum und Ablaufdatum. Jede Ziffer verwendet die sich wiederholenden Gewichte 7, 3, 1; Ziffern zählen als sie selbst, A bis Z als 10 bis 35 und das Füllzeichen < als 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,                          // the filler <
        };
        $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],
    ];
}

Wenden Sie es auf $scan['mrz']['lines'][1] an, wenn $scan['document']['kind'] gleich passport ist. Um mrz.status zu vertrauen, brauchen Sie es nicht; nützlich ist es in einem Test oder wenn Sie die Zone aufbewahren und später belegen wollen, was gelesen wurde.

Wer zahlt: das billed-Flag

Sandbox-Schlüssel berühren nie ein Guthaben. Mit einem Live-Schlüssel wird ein Scan nur berechnet, wenn der Dokumenttyp bestimmt wurde und mindestens eines davon herauskam: eine MRZ mit gültigen Prüfziffern, fünf oder mehr Felder von der gedruckten Seite oder ein dekodierter Barcode. Nichts gefunden, ein unlesbares Foto, ein nicht unterstützter Typ oder ein Fehler auf Seiten des Dienstes kosten nichts. Mit sk_sandbox_public meldet billed trotzdem, ob derselbe Scan berechnet worden wäre; deshalb lohnt es sich, dagegen zu testen.

Speichern Sie das Flag neben Ihrer eigenen Bestell- oder Nutzerzeile, dann stimmen Ihre Aufzeichnungen ohne Abgleichsschritt mit der Rechnung überein. Ein Konto erhält 100 kostenlose Erkennungen pro Monat, die vor jedem bezahlten Guthaben verbraucht werden.

Wiederholungen, die nicht doppelt abrechnen können

Die riskante Wiederholung ist die nach einem Timeout: Sie wissen nicht, ob die erste Anfrage den Server erreicht hat. Senden Sie einen Idempotency-Key-Header mit 1 bis 255 Zeichen und verwenden Sie ihn bei jedem Versuch für dasselbe Bild erneut. Mit einem Live-Schlüssel vergleicht der Server den Schlüssel zusammen mit einem Fingerabdruck des gesamten Bodys:

  • Gleicher Schlüssel, gleicher Body, Ergebnis gespeichert: Das erste Ergebnis kommt zurück, und nichts wird erneut berechnet.
  • Gleicher Schlüssel, anderer Body: 409 idempotency_conflict. Bauen Sie den Body einmal, außerhalb der Schleife.
  • Die erste Anfrage läuft noch: 409 idempotency_in_progress. Warten Sie und wiederholen Sie mit demselben Schlüssel.
  • Gleicher Schlüssel, kein gespeichertes Ergebnis (zum Beispiel nach retain_hours: 0): 409 idempotency_replay_unavailable. Senden Sie mit einem neuen Schlüssel.

Jeder Fehler-Body hat dieselbe Form (code, message, docs_url, request_id, event_id), und code ist der Wert, nach dem verzweigt wird, denn ein HTTP-Status trägt Codes, die gegensätzlich behandelt werden müssen. Der Leitfaden zur Fehlerbehandlung ordnet sie alle in Gruppen; die, denen ein Scan-Client begegnet, sind diese:

Codes Was zu tun ist
rate_limited, document_repeated, internal_error, engine_unavailable, service_unavailable, maintenance Warten (Retry-After beachten), dann wiederholen
idempotency_in_progress Warten, mit demselben Schlüssel wiederholen
validation_failed, invalid_request, payload_too_large, unsupported_media_type Die Anfrage korrigieren; eine Wiederholung scheitert erneut
unauthorized, registration_required, insufficient_credits Schlüssel oder Konto korrigieren; Warten ändert nichts

Retry-After sind ganze Sekunden. Das Stundenlimit der Sandbox kann fast eine Stunde Wartezeit verlangen, deshalb geben beide Clients unten auf, wenn die Wartezeit über einer Minute liegt, statt einen PHP-Worker zu blockieren.

Der reine PHP-Client

<?php
// scan.php – PHP 8.1+, ext-curl and ext-json only

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; // seconds; a longer Retry-After is reported, not slept through

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']));
    }
}

/** One HTTP attempt: [status, decoded body, Retry-After seconds]. */
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)); // one key per image, reused on every attempt

    for ($attempt = 1; ; $attempt++) {
        $last = $attempt >= $attempts;
        try {
            [$status, $payload, $retryAfter] = post_scan($apiKey, $idempotencyKey, $json);
        } catch (RuntimeException | JsonException $e) {
            // A timeout, a dropped connection, or a proxy answering with 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);
}

Starten Sie es mit php scan.php specimen.jpg. Bei einem lesbaren Muster gibt es eine Zeile dieser Form aus (Werte maskiert):

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

Einige Entscheidungen, die eine Erklärung verdienen:

  • curl scheitert nicht bei einem 4xx oder 5xx. Der Client liest den JSON-Body in jedem Fall, denn der Fehler-Body trägt den Code.
  • Body und Schlüssel werden einmal gebaut, vor der Schleife. Genau das macht jeden Versuch in den Augen des Servers zur selben Anfrage.
  • return_portrait: false lässt den Ausschnitt mit dem Foto des Inhabers aus der Antwort weg, sodass ein Gesicht weniger in Ihren Logs und Ihrem Speicher liegt.
  • reference kommt als meta.reference zurück (bis zu 128 Zeichen): der einfache Weg, einen Scan mit Ihrer eigenen Bestellung oder Ihrem Nutzer zu verknüpfen.
  • Loggen Sie nie den Request-Body. Er ist ein Ausweisdokument. Loggen Sie code, request_id und docs_url; mehr braucht der Support nicht.

Die Laravel-Version

Der Http-Client von Laravel (Laravel 10 oder neuer) erledigt den Großteil der Schleife bereits: retry() nimmt eine Closure für die Wartezeit und eine Closure, die entscheidet, ob sich eine Wiederholung lohnt, und post() mit einem Array sendet JSON. Legen Sie den Schlüssel in config/services.php ab:

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

Dann ein kleiner Service:

<?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; // a timeout or a dropped connection: the key makes the retry safe
        }

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

Mit throw: false kommt eine Ablehnung, bei der sich keine Wiederholung lohnt, als Response zurück, und der Service macht daraus eine Exception, die den Code nennt. Geben Sie dieser Exception in Ihrem eigenen Code eine eigene Klasse mit dem error-Array, damit Aufrufer nach code verzweigen können statt nach der Meldung.

Ein Controller, der einen Upload annimmt und nur behält, was er braucht:

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');
}

In einem Queue-Job gehört der Schlüssel zum Job. Wenn Sie den Aufruf in einen Job verlagern, erzeugen Sie den Idempotenzschlüssel beim Eingang des Uploads und übergeben ihn dem Konstruktor des Jobs. Ein Job, der fehlschlägt und vom Queue-Worker wiederholt wird, sendet dann denselben Schlüssel, und ein bereits gelaufener Scan wird erneut ausgeliefert statt erneut berechnet. Ein frisches Str::uuid() innerhalb von handle() würde das zunichtemachen.

Testen Sie den Wiederholungspfad mit Http::fake(). Eine Folge aus einem 429 und danach einem 200 belegt, dass der zweite Versuch stattfindet und denselben Schlüssel trägt, ohne einen Netzwerkaufruf:

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']);

Weniger Daten aufbewahren

Speichern Sie das hochgeladene Bild nach dem Aufruf nicht; lesen Sie aus dem Ergebnis, was Sie brauchen, und verwerfen Sie die Bytes. Auf Seiten des Dienstes wird das Ergebnis aufbewahrt, damit Sie es mit GET /v1/scans/{id} erneut lesen können, für ein Zeitfenster, das Sie pro Anfrage wählen: options.retain_hours nimmt 0 bis 8760, und 0 schreibt gar nichts. Der Preis dafür ist die Wiedergabe-Logik von oben: Mit 0 erhält eine Wiederholung unter demselben Schlüssel idempotency_replay_unavailable. Wenn Sie beides wollen, wählen Sie ein kurzes Fenster über null.

Live gehen

Setzen Sie DOC_CHEAP_API_KEY auf Ihren eigenen sk_live_-Schlüssel, und sonst ändert sich nichts: derselbe Endpunkt, dieselbe Antwort, derselbe Client. Ein registrierter Schlüssel erlaubt 60 Anfragen pro Minute, sodass ein Batch, der Retry-After beachtet, keinen eigenen Rate-Limiter braucht. Wenn Sie noch einen Anbieter suchen, listet der Vergleich von Reisepass-OCR-APIs kostenpflichtige Alternativen mit ihren veröffentlichten Preisen.

Eine Frage an Sie: Parsen Sie die MRZ heute selbst in PHP, oder vertrauen Sie dafür einem Dienst? Erzählen Sie es uns unten in den Kommentaren.