Bir PHP formundan pasaport fotoğrafı geliyor ve kodunuz adı, numarayı, tarihleri ve makine tarafından okunabilir bölgeyi (MRZ) veri olarak istiyor. Bu yazı o okuyucuyu iki kez kuruyor: bir kez yalnızca ext-curl kullanan düz PHP 8.1 ile, bir kez de Http istemcisi üzerinde bir Laravel servisi olarak. İki sürüm de tek bir istek gönderir, sonuca göre dallanır, iki kez ödemeden yeniden dener ve her reddi üzerine işlem yapabileceğiniz bir hata koduna dönüştürür.

API, doc.cheap'in arkasındaki Pasaport ve Kimlik OCR API'si ve burası doc.cheap'in kendi blogu; ürün tercihlerini buna göre tartın. Kalıplar (görüntü başına bir idempotency anahtarı, sabit bir hata koduna göre dallanma, çağrı başına bir faturalama bayrağı saklama) PHP'den çağırdığınız her ücretli API'ye aynen taşınır.

Hesap olmadan ilk çağrı

Dokümantasyonda herkese açık bir sandbox anahtarı yer alıyor: sk_sandbox_public. Gerçek tanıma çalıştırır, kayıt gerektirmez ve kredi düşmez. IP adresi başına saatte 10 isteğe ve istemci başına toplam 10 ücretsiz tanımaya izin verir; ardından 403 registration_required yanıtını verir.

İstek, JSON gövdeli bir POST https://api.doc.cheap/v1/scans çağrısıdır. Tek bir alan zorunludur: image, yani dosyanın baytlarının base64 hali, JPEG veya 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;

Çağrı senkrondur: alanlar bu yanıtta geri gelir; sorgulanacak bir job id ya da barındırılacak bir webhook yoktur. Sentetik bir örnekle test edin, asla kendi pasaportunuzla değil.

Gövde katı biçimde denetlenir. Yanlış yazılmış bir seçenek gibi bilinmeyen bir anahtar, yolu belirten bir mesajla 422 validation_failed olarak reddedilir; böylece bir yazım hatası yok sayılmak yerine açıkça hata verir. Boyut sınırını aşan bir gövde 413 payload_too_large alır; fotoğrafın boyutlandırılmasını ve tüm seçenekleri pasaport tanıma rehberi anlatıyor.

Ne geri döner

Her yanıtta aynı sekiz grup bulunur ve her anahtar her zaman vardır: meta, document, holder, fields, mrz, images, quality ve authenticity. json_decode($body, true) sonrasında bilinmeyen bir değer null olur, asla eksik bir anahtar olmaz. Kısaltılmış ve değerleri maskelenmiş bir örnek:

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

Kodunuzun bundan sonra ne yapacağına dört parça karar verir:

  • meta.status beş dizeden biridir: recognized, no_document_found, unreadable, unsupported_document, rejected. Beşi de HTTP 200 olarak gelir. Yalnızca ilki veri taşır.
  • document ve holder seçilmiş değerlerdir; tarihler YYYY-MM-DD biçimindedir. Bir grup için hiçbir şey okunamadığında grubun tamamı null olur.
  • mrz.status passed, failed veya absent değerini alır. passed, bölgenin bulunduğu, tüm kontrol hanelerinin doğrulandığı ve içindeki hiçbir şeyin basılı sayfayla çelişmediği anlamına gelir.
  • meta.billed bu çağrının kredi düşüp düşmediğini söyler.

İki istemcinin şeklini belirleyen nokta şu: okunamayan bir fotoğraf hata değil, 200'dür. İki kez dallanın: retler için hata koduna, sonuçlar için meta.status değerine göre. no_document_found durumunda yeniden denerseniz asla okunmayacak bir fotoğrafı yeniden gönderirsiniz; başarı sayarsanız hiç alanı olmayan bir belge saklarsınız.

MRZ'yi PHP'de kendiniz doğrulamak

mrz.lines ve mrz.text, bölgenin okunduğu haliyle tam kopyasıdır ve yanıtın bağımsız olarak doğrulayabileceğiniz tek parçasıdır. Bir pasaportta (TD3 biçimi) ikinci satır, yeniden hesaplaması kolay üç kontrol hanesi taşır: belge numarası, doğum tarihi ve son geçerlilik tarihi. Her hane tekrarlanan 7, 3, 1 ağırlıklarını kullanır; rakamlar kendi değeriyle, A'dan Z'ye harfler 10'dan 35'e ve dolgu karakteri < 0 olarak sayılır.

<?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,                          // dolgu karakteri <
        };
        $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'] değeri passport olduğunda bunu $scan['mrz']['lines'][1] üzerinde çalıştırın. mrz.status değerine güvenmek için buna ihtiyacınız yok; bir testte ya da bölgeyi saklayıp neyin okunduğunu sonradan kanıtlamak istediğinizde işe yarar.

Kim öder: billed bayrağı

Sandbox anahtarları bakiyeye asla dokunmaz. Canlı (live) bir anahtarda tarama yalnızca belge türü belirlendiğinde ve ondan şunlardan en az biri çıktığında ücretlendirilir: kontrol haneleri doğrulanan bir MRZ, basılı sayfadan beş veya daha fazla alan ya da çözümlenmiş bir barkod. Hiçbir şey bulunamaması, okunamayan bir fotoğraf, desteklenmeyen bir tür veya servis tarafındaki bir hata hiçbir ücrete yol açmaz. sk_sandbox_public üzerinde de billed aynı taramanın ücretlendirilip ücretlendirilmeyeceğini bildirir; bu da onu test etmeye değer kılar.

Bayrağı kendi sipariş ya da kullanıcı satırınızın yanında saklayın; kayıtlarınız ayrı bir mutabakat adımı olmadan faturayla örtüşür. Bir hesap her ay 100 ücretsiz tanıma alır ve bunlar ücretli krediden önce kullanılır.

İki kez ücret alamayan yeniden denemeler

Riskli yeniden deneme, timeout sonrasındakidir: ilk isteğin sunucuya ulaşıp ulaşmadığını bilemezsiniz. Bir Idempotency-Key başlığı (1 ile 255 karakter arası) gönderin ve aynı görüntü için her denemede onu tekrar kullanın. Canlı bir anahtarda sunucu, anahtarı tüm gövdenin parmak iziyle birlikte eşleştirir:

  • Aynı anahtar, aynı gövde, sonuç saklanmış: ilk sonuç geri gelir ve yeniden ücret alınmaz.
  • Aynı anahtar, farklı gövde: 409 idempotency_conflict. Gövdeyi döngünün dışında bir kez oluşturun.
  • İlk istek hâlâ çalışıyor: 409 idempotency_in_progress. Bekleyin ve aynı anahtarla yeniden deneyin.
  • Aynı anahtar, saklanmış sonuç yok (örneğin retain_hours: 0 sonrasında): 409 idempotency_replay_unavailable. Yeni bir anahtarla gönderin.

Her hata gövdesinin şekli aynıdır (code, message, docs_url, request_id, event_id) ve dallanılacak değer code'dur, çünkü tek bir HTTP durumu birbirine zıt işlem gerektiren kodlar taşır. Hataları ele alma rehberi hepsini gruplara ayırıyor; bir tarama istemcisinin karşılaştıkları şunlar:

Kodlar Ne yapılmalı
rate_limited, document_repeated, internal_error, engine_unavailable, service_unavailable, maintenance Bekleyin (Retry-After değerine uyun), sonra yeniden deneyin
idempotency_in_progress Bekleyin, aynı anahtarla yeniden deneyin
validation_failed, invalid_request, payload_too_large, unsupported_media_type İsteği düzeltin; yeniden deneme yine başarısız olur
unauthorized, registration_required, insufficient_credits Anahtarı veya hesabı düzeltin; beklemek hiçbir şeyi değiştirmez

Retry-After tam saniye cinsindendir. Sandbox'ın saatlik sınırı neredeyse bir saatlik bekleme isteyebilir; bu yüzden aşağıdaki iki istemci de bir PHP worker'ını bloke etmek yerine bekleme bir dakikayı aştığında vazgeçer.

Düz PHP istemcisi

<?php
// scan.php – PHP 8.1+, yalnızca ext-curl ve 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; // saniye; daha uzun bir Retry-After beklenmez, bildirilir

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

/** Tek bir HTTP denemesi: [durum, çözülmüş gövde, Retry-After saniyesi]. */
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)); // görüntü başına bir anahtar, her denemede yeniden kullanılır

    for ($attempt = 1; ; $attempt++) {
        $last = $attempt >= $attempts;
        try {
            [$status, $payload, $retryAfter] = post_scan($apiKey, $idempotencyKey, $json);
        } catch (RuntimeException | JsonException $e) {
            // Bir timeout, kopan bir bağlantı ya da HTML ile yanıt veren bir proxy.
            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 ile çalıştırın. Okunabilir bir örnekte şu biçimde tek bir satır yazdırır (değerler maskelendi):

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

Açıklamaya değer birkaç tercih:

  • curl bir 4xx veya 5xx'te başarısız olmaz. Hata gövdesi kodu taşıdığı için istemci JSON gövdesini her durumda okur.
  • Gövde ve anahtar döngüden önce bir kez oluşturulur. Sunucunun gözünde her denemeyi aynı istek yapan budur.
  • return_portrait: false, belge sahibinin fotoğraf kırpıntısını yanıtın dışında bırakır; böylece loglarınızda ve depolamanızda bir yüz eksik olur.
  • reference, meta.reference olarak geri gelir (en fazla 128 karakter): bir taramayı kendi siparişinize ya da kullanıcınıza bağlamanın kolay yolu.
  • İstek gövdesini asla loglamayın. O bir kimlik belgesidir. code, request_id ve docs_url değerlerini loglayın; desteğin ihtiyaç duyduğu her şey bunlardır.

Laravel sürümü

Laravel'in Http istemcisi (Laravel 10 veya üstü) döngünün çoğunu zaten yapar: retry() bekleme süresi için bir closure ve bir denemenin tekrarlanmaya değip değmediğine karar veren bir closure alır, dizi verilen post() ise JSON gönderir. Anahtarı config/services.php dosyasına koyun:

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

Ardından küçük bir servis:

<?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; // bir timeout ya da kopan bağlantı: anahtar yeniden denemeyi güvenli kılar
        }

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

throw: false ile, yeniden denemeye değmeyen bir ret yanıt olarak geri gelir ve servis onu kodu adıyla belirten bir exception'a dönüştürür. Kendi kodunuzda bu exception'a, üzerinde error dizisi bulunan kendi sınıfını verin; böylece çağıranlar mesaja göre değil code değerine göre dallanabilir.

Bir yüklemeyi alan ve yalnızca ihtiyaç duyduğunu saklayan bir controller:

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

Kuyruğa alınan bir job'da anahtarı job'un parçası yapın. Çağrıyı bir job'a taşırsanız idempotency anahtarını yükleme geldiğinde üretin ve job'un constructor'ına geçirin. Böylece başarısız olup kuyruk worker'ı tarafından yeniden denenen bir job aynı anahtarı gönderir ve zaten çalışmış bir tarama yeniden ücretlendirilmek yerine yeniden oynatılır. handle() içinde yeni bir Str::uuid() üretmek bunu boşa çıkarır.

Yeniden deneme yolunu Http::fake() ile test edin. Önce bir 429, ardından bir 200 döndüren bir dizi, hiçbir ağ çağrısı yapmadan ikinci denemenin gerçekleştiğini ve aynı anahtarı taşıdığını kanıtlar:

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

Daha az veri saklamak

Çağrıdan sonra yüklenen görüntüyü saklamayın; sonuçtan ihtiyacınız olanı okuyun ve baytları atın. Servis tarafında sonuç, GET /v1/scans/{id} ile yeniden okuyabilmeniz için istek başına seçtiğiniz bir süre boyunca saklanır: options.retain_hours 0 ile 8760 arasında değer alır ve 0 hiçbir şey yazmaz. Bunun bedeli yukarıdaki yeniden oynatma kuralıdır: 0 ile, aynı anahtarla yapılan bir yeniden deneme idempotency_replay_unavailable alır. İkisini birden istiyorsanız sıfırın üzerinde kısa bir süre seçin.

Canlıya geçiş

DOC_CHEAP_API_KEY değerini kendi sk_live_ anahtarınıza ayarlayın; başka hiçbir şey değişmez: aynı endpoint, aynı yanıt, aynı istemci. Kayıtlı bir anahtar dakikada 60 isteğe izin verir; bu yüzden Retry-After değerine uyan bir toplu işin kendi hız sınırlayıcısına ihtiyacı olmaz. Hâlâ bir sağlayıcı seçiyorsanız pasaport OCR API karşılaştırması, ücretli alternatifleri yayımladıkları fiyatlarla birlikte listeliyor.

Size bir soru: bugün MRZ'yi PHP'de kendiniz mi ayrıştırıyorsunuz, yoksa bu işi bir servise mi bırakıyorsunuz? Aşağıdaki yorumlarda bize anlatın.