Hai la foto di un passaporto che arriva da un form PHP, e il tuo codice ha bisogno di nome, numero, date e zona a lettura ottica (MRZ) come dati. Questo articolo costruisce quel lettore due volte: una in PHP 8.1 puro, solo con ext-curl, e una come servizio Laravel basato sul client Http. Entrambe le versioni inviano una sola richiesta, diramano in base all'esito, ripetono senza pagare due volte e trasformano ogni rifiuto in un codice di errore su cui puoi agire.
L'API è l'API OCR per passaporti e documenti d'identità di doc.cheap, e questo è il blog di doc.cheap, quindi valuta le scelte di prodotto di conseguenza. Gli schemi (una chiave di idempotenza per immagine, la diramazione su un codice di errore stabile, il salvataggio di un flag di addebito per ogni chiamata) valgono per qualsiasi API a pagamento che chiami da PHP.
La prima chiamata, senza account
La documentazione riporta una chiave sandbox pubblica, sk_sandbox_public. Esegue un riconoscimento reale, non richiede registrazione e non consuma crediti. Consente 10 richieste all'ora per indirizzo IP e 10 riconoscimenti gratuiti in totale per client, dopodiché risponde 403 registration_required.
La richiesta è POST https://api.doc.cheap/v1/scans con un corpo JSON. Un solo campo è obbligatorio, image: i byte del file in base64, JPEG o 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;
La chiamata è sincrona: i campi tornano in questa stessa risposta, senza id di job da interrogare e senza webhook da ospitare. Fai le prove con un esemplare sintetico, mai con il tuo passaporto.
Il corpo viene verificato in modo rigido. Una chiave sconosciuta, come un'opzione scritta male, viene rifiutata con 422 validation_failed e un messaggio che indica il percorso, così un refuso fallisce in modo evidente invece di essere ignorato. Un corpo oltre il limite di dimensione restituisce 413 payload_too_large; la guida al riconoscimento del passaporto spiega come dimensionare la foto e ogni opzione.
Che cosa torna indietro
Ogni risposta ha gli stessi otto gruppi, e ogni chiave è sempre presente: meta, document, holder, fields, mrz, images, quality e authenticity. Dopo json_decode($body, true) un valore non noto è null, mai una chiave mancante. Un esempio ridotto e mascherato:
{
"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>" }
}
Quattro parti decidono che cosa fa poi il tuo codice:
meta.statusè una di cinque stringhe:recognized,no_document_found,unreadable,unsupported_document,rejected. Tutte e cinque arrivano come HTTP200. Solo la prima porta dati.documenteholdersono i valori ripuliti, con le date in formatoYYYY-MM-DD. Ogni gruppo ènullper intero quando per esso non è stato letto nulla.mrz.statusvalepassed,failedoabsent.passedsignifica che la zona è stata trovata, ogni cifra di controllo è risultata valida e nulla al suo interno contraddice la pagina stampata.meta.billeddice se questa chiamata ha consumato un credito.
Il punto che dà forma a entrambi i client: una foto che non è stato possibile leggere è un 200, non un errore. Dirama due volte: sul codice di errore per i rifiuti e su meta.status per gli esiti. Se ripeti su no_document_found, reinvii una foto che non verrà mai letta; se lo tratti come un successo, salvi un documento senza campi.
Verificare la MRZ da soli in PHP
mrz.lines e mrz.text sono la zona esattamente come è stata letta, e sono l'unica parte della risposta che puoi verificare in modo indipendente. Su un passaporto (il formato TD3) la seconda riga contiene tre cifre di controllo facili da ricalcolare: numero del documento, data di nascita e data di scadenza. Ogni cifra usa i pesi 7, 3, 1 a ripetizione; le cifre valgono se stesse, le lettere da A a Z valgono da 10 a 35 e il carattere di riempimento < vale 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],
];
}
Eseguila su $scan['mrz']['lines'][1] quando $scan['document']['kind'] è passport. Non ti serve per fidarti di mrz.status; è utile in un test, oppure quando conservi la zona e vuoi dimostrare in seguito che cosa è stato letto.
Chi paga: il flag billed
Le chiavi sandbox non toccano mai un saldo. Con una chiave live, una scansione viene addebitata solo quando il tipo di documento è stato determinato e ne è uscito almeno uno di questi elementi: una MRZ con cifre di controllo valide, cinque o più campi della pagina stampata, oppure un codice a barre decodificato. Nessun documento trovato, una foto illeggibile, un tipo non supportato o un guasto lato servizio non costano nulla. Con sk_sandbox_public, billed indica comunque se la stessa scansione sarebbe stata addebitata, il che lo rende utile da verificare nei test.
Salva il flag accanto alla riga del tuo ordine o del tuo utente, e i tuoi registri corrisponderanno alla fattura senza un passaggio di riconciliazione. Un account riceve 100 riconoscimenti gratuiti ogni mese, consumati prima di qualsiasi credito a pagamento.
Retry che non possono addebitare due volte
Il retry rischioso è quello dopo un timeout: non sai se la prima richiesta ha raggiunto il server. Invia un header Idempotency-Key, da 1 a 255 caratteri, e riusalo in ogni tentativo per la stessa immagine. Con una chiave live, il server confronta la chiave insieme a un'impronta dell'intero corpo:
- Stessa chiave, stesso corpo, risultato conservato: torna il primo risultato e non viene addebitato nulla di nuovo.
- Stessa chiave, corpo diverso:
409 idempotency_conflict. Costruisci il corpo una volta sola, fuori dal ciclo. - La prima richiesta è ancora in corso:
409 idempotency_in_progress. Attendi e riprova con la stessa chiave. - Stessa chiave, nessun risultato conservato (per esempio dopo
retain_hours: 0):409 idempotency_replay_unavailable. Invia la richiesta con una chiave nuova.
Ogni corpo di errore ha la stessa forma (code, message, docs_url, request_id, event_id), e code è il valore su cui diramare, perché uno stesso status HTTP porta codici che richiedono una gestione opposta. La guida alla gestione degli errori li divide tutti in gruppi; quelli che un client di scansione incontra sono questi:
| Codici | Che cosa fare |
|---|---|
rate_limited, document_repeated, internal_error, engine_unavailable, service_unavailable, maintenance |
Attendi (rispetta Retry-After), poi riprova |
idempotency_in_progress |
Attendi, riprova con la stessa chiave |
validation_failed, invalid_request, payload_too_large, unsupported_media_type |
Correggi la richiesta; un retry fallisce di nuovo |
unauthorized, registration_required, insufficient_credits |
Correggi la chiave o l'account; attendere non cambia nulla |
Retry-After è espresso in secondi interi. Il limite orario della sandbox può chiedere di attendere quasi un'ora, quindi entrambi i client qui sotto rinunciano quando l'attesa supera un minuto invece di bloccare un worker PHP.
Il client in PHP puro
<?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);
}
Eseguilo con php scan.php specimen.jpg. Su un esemplare leggibile stampa una riga di questa forma (valori mascherati):
{"ok":true,"billed":true,"kind":"passport","country":"<ISO3>","expiry_date":"<YYYY-MM-DD>","surname":"<SURNAME>","birth_date":"<YYYY-MM-DD>","mrz":"passed"}
Alcune scelte che vale la pena spiegare:
- curl non fallisce su un 4xx o un 5xx. Il client legge comunque il corpo JSON, perché il corpo di errore porta il codice.
- Il corpo e la chiave vengono costruiti una volta sola, prima del ciclo. È questo che rende ogni tentativo la stessa richiesta agli occhi del server.
return_portrait: falseesclude dalla risposta il ritaglio della foto del titolare, così nei tuoi log e nel tuo storage c'è un volto in meno.referencetorna comemeta.reference(fino a 128 caratteri): il modo semplice per collegare una scansione al tuo ordine o al tuo utente.- Non registrare mai nei log il corpo della richiesta. È un documento d'identità. Registra
code,request_idedocs_url; è tutto ciò che serve al supporto.
La versione Laravel
Il client Http di Laravel (Laravel 10 o successivo) fa già gran parte del ciclo: retry() accetta una closure per l'attesa e una closure che decide se vale la pena ripetere un tentativo, e post() con un array invia JSON. Metti la chiave in config/services.php:
'doc_cheap' => [
'key' => env('DOC_CHEAP_API_KEY', 'sk_sandbox_public'),
],
Poi un piccolo servizio:
<?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;
}
}
Con throw: false, un rifiuto per cui non vale la pena ripetere torna come risposta, e il servizio lo trasforma in un'eccezione che riporta il codice. Nel tuo codice, dai a quell'eccezione una classe propria con l'array error al suo interno, così i chiamanti possono diramare su code invece che sul messaggio.
Un controller che riceve un upload e conserva solo ciò che gli serve:
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 un job in coda, rendi la chiave parte del job. Se sposti la chiamata in un job, genera la chiave di idempotenza quando arriva l'upload e passala al costruttore del job. Un job che fallisce e viene ripetuto dal queue worker invia così la stessa chiave, e una scansione già eseguita viene riproposta invece di essere addebitata di nuovo. Un Str::uuid() nuovo dentro handle() vanificherebbe tutto questo.
Prova il percorso di retry con Http::fake(). Una sequenza di un 429 seguito da un 200 dimostra che il secondo tentativo avviene e porta la stessa chiave, senza alcuna chiamata di rete:
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']);
Conservare meno dati
Non salvare l'immagine caricata dopo la chiamata; leggi dal risultato ciò che ti serve e scarta i byte. Lato servizio, il risultato viene conservato perché tu possa rileggerlo con GET /v1/scans/{id}, per una finestra che scegli a ogni richiesta: options.retain_hours accetta da 0 a 8760, e 0 non scrive proprio nulla. Il compromesso è la regola di riproposizione vista sopra: con 0, un retry con la stessa chiave riceve idempotency_replay_unavailable. Se vuoi entrambe le cose, scegli una finestra breve superiore a zero.
Andare in produzione
Imposta DOC_CHEAP_API_KEY con la tua chiave sk_live_ e non cambia nient'altro: stesso endpoint, stessa risposta, stesso client. Una chiave registrata consente 60 richieste al minuto, quindi un batch che rispetta Retry-After non avrà bisogno di un proprio rate limiter. Se stai ancora scegliendo un fornitore, il confronto tra API OCR per passaporti elenca le alternative a pagamento con i prezzi che pubblicano.
Una domanda per te: oggi analizzi la MRZ da solo in PHP o ti affidi a un servizio? Raccontacelo nei commenti qui sotto.