Bạn có một ảnh hộ chiếu được gửi lên qua một form PHP, và code của bạn cần họ tên, số hộ chiếu, các ngày tháng và vùng đọc bằng máy (MRZ) dưới dạng dữ liệu. Bài này xây bộ đọc đó hai lần: một lần bằng PHP 8.1 thuần chỉ với ext-curl, và một lần dưới dạng service Laravel dùng Http client. Cả hai phiên bản đều gửi một request, phân nhánh theo kết quả, thử lại mà không trả tiền hai lần và biến mọi lần bị từ chối thành một mã lỗi bạn có thể xử lý.
API là API OCR hộ chiếu và thẻ căn cước đứng sau doc.cheap, và đây là blog của chính doc.cheap, nên hãy cân nhắc các lựa chọn sản phẩm cho phù hợp. Các pattern (một idempotency key cho mỗi ảnh, phân nhánh theo mã lỗi ổn định, lưu cờ tính phí cho từng lần gọi) áp dụng được cho bất kỳ API trả phí nào bạn gọi từ PHP.
Lần gọi đầu tiên, không cần tài khoản
Tài liệu có in sẵn một khóa sandbox công khai, sk_sandbox_public. Nó chạy nhận dạng thật, không cần đăng ký và không trừ credit. Khóa cho phép 10 request mỗi giờ cho mỗi địa chỉ IP và tổng cộng 10 lượt nhận dạng miễn phí cho mỗi client, sau đó nó trả về 403 registration_required.
Request là POST https://api.doc.cheap/v1/scans với body JSON. Chỉ có một trường bắt buộc, image: các byte của file dưới dạng base64, JPEG hoặc 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;
Lần gọi là đồng bộ: các trường được trả về ngay trong response này, không có job id để polling và không có webhook phải dựng. Hãy kiểm thử bằng một mẫu tổng hợp, đừng bao giờ dùng hộ chiếu của chính bạn.
Body được kiểm tra nghiêm ngặt. Một key không xác định, chẳng hạn tên tùy chọn bị gõ sai, bị từ chối với 422 validation_failed kèm một thông báo nêu rõ đường dẫn, nên lỗi gõ sẽ báo lỗi rõ ràng thay vì bị bỏ qua. Body vượt giới hạn kích thước nhận 413 payload_too_large; hướng dẫn nhận dạng hộ chiếu nói về cách chỉnh kích thước ảnh và mọi tùy chọn.
Những gì được trả về
Mọi response đều có cùng tám nhóm, và mọi key luôn có mặt: meta, document, holder, fields, mrz, images, quality và authenticity. Sau json_decode($body, true), một giá trị không xác định là null, không bao giờ là key bị thiếu. Một ví dụ rút gọn, đã che giá trị:
{
"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>" }
}
Bốn phần quyết định code của bạn làm gì tiếp theo:
meta.statuslà một trong năm chuỗi:recognized,no_document_found,unreadable,unsupported_document,rejected. Cả năm đều đến dưới dạng HTTP200. Chỉ chuỗi đầu tiên mang dữ liệu.documentvàholderlà các giá trị đã được chọn lọc, ngày theo dạngYYYY-MM-DD. Mỗi nhóm lànulltoàn bộ khi không đọc được gì cho nhóm đó.mrz.statuslàpassed,failedhoặcabsent.passednghĩa là đã tìm thấy vùng MRZ, mọi chữ số kiểm tra đều hợp lệ và không có gì trong đó mâu thuẫn với trang in.meta.billedcho biết lần gọi này có trừ credit hay không.
Điểm định hình cả hai client: một bức ảnh không đọc được là 200, không phải lỗi. Hãy phân nhánh hai lần: theo mã lỗi cho các lần bị từ chối, và theo meta.status cho kết quả. Thử lại khi gặp no_document_found thì bạn sẽ gửi lại một bức ảnh không bao giờ đọc được; coi nó là thành công thì bạn sẽ lưu một giấy tờ không có trường nào.
Tự kiểm tra MRZ bằng PHP
mrz.lines và mrz.text là vùng MRZ đúng như khi đọc, và đó là phần duy nhất của response mà bạn có thể tự kiểm chứng độc lập. Trên hộ chiếu (định dạng TD3), dòng thứ hai mang ba chữ số kiểm tra dễ tính lại: số giấy tờ, ngày sinh và ngày hết hạn. Mỗi chữ số dùng trọng số 7, 3, 1 lặp lại; chữ số tính theo chính giá trị của nó, A đến Z là 10 đến 35 và ký tự đệm < là 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, // ký tự đệm <
};
$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],
];
}
Chạy nó trên $scan['mrz']['lines'][1] khi $scan['document']['kind'] là passport. Bạn không cần nó để tin mrz.status; nó hữu ích trong một bài test, hoặc khi bạn lưu lại vùng MRZ và muốn chứng minh về sau những gì đã được đọc.
Ai trả tiền: cờ billed
Khóa sandbox không bao giờ chạm vào số dư. Với khóa live, một lượt quét chỉ bị tính phí khi xác định được loại giấy tờ và lấy ra được ít nhất một trong các thứ sau: một MRZ có chữ số kiểm tra hợp lệ, từ năm trường trở lên của trang in, hoặc một mã vạch được giải mã. Không tìm thấy gì, ảnh không đọc được, loại không hỗ trợ hay lỗi phía dịch vụ đều không mất phí. Trên sk_sandbox_public, billed vẫn cho biết cùng lượt quét đó có bị tính phí hay không, nên đáng để kiểm thử với nó.
Lưu cờ này cạnh dòng đơn hàng hoặc người dùng của chính bạn, và hồ sơ của bạn khớp với hóa đơn mà không cần bước đối soát. Một tài khoản có 100 lượt nhận dạng miễn phí mỗi tháng, được dùng trước mọi credit trả phí.
Thử lại mà không bị tính tiền hai lần
Lần thử lại rủi ro là lần sau timeout: bạn không biết request đầu tiên đã đến máy chủ hay chưa. Hãy gửi header Idempotency-Key, từ 1 đến 255 ký tự, và dùng lại nó ở mọi lần thử cho cùng một ảnh. Với khóa live, máy chủ đối chiếu key cùng với dấu vân tay (fingerprint) của toàn bộ body:
- Cùng key, cùng body, kết quả đã được lưu: kết quả đầu tiên được trả về và không bị tính phí lần nữa.
- Cùng key, khác body:
409 idempotency_conflict. Hãy tạo body một lần, bên ngoài vòng lặp. - Request đầu tiên vẫn đang chạy:
409 idempotency_in_progress. Chờ rồi thử lại với cùng key. - Cùng key, không có kết quả được lưu (ví dụ sau
retain_hours: 0):409 idempotency_replay_unavailable. Gửi lại với một key mới.
Mọi body lỗi đều có cùng cấu trúc (code, message, docs_url, request_id, event_id), và code là giá trị để phân nhánh, vì một HTTP status mang những mã cần cách xử lý ngược nhau. Hướng dẫn xử lý lỗi xếp tất cả vào từng nhóm; những mã mà một client quét gặp phải là:
| Mã | Cần làm gì |
|---|---|
rate_limited, document_repeated, internal_error, engine_unavailable, service_unavailable, maintenance |
Chờ (tôn trọng Retry-After), rồi thử lại |
idempotency_in_progress |
Chờ, thử lại với cùng key |
validation_failed, invalid_request, payload_too_large, unsupported_media_type |
Sửa request; thử lại vẫn thất bại |
unauthorized, registration_required, insufficient_credits |
Sửa khóa hoặc tài khoản; chờ đợi không thay đổi gì |
Retry-After tính bằng số giây nguyên. Giới hạn theo giờ của sandbox có thể yêu cầu chờ gần một giờ, nên cả hai client bên dưới bỏ cuộc khi thời gian chờ vượt quá một phút thay vì chặn một PHP worker.
Client PHP thuần
<?php
// scan.php – PHP 8.1+, chỉ ext-curl và 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; // giây; Retry-After dài hơn thì báo lỗi, không ngồi chờ
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']));
}
}
/** Một lần thử HTTP: [status, body đã giải mã, số giây 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)); // một key cho mỗi ảnh, dùng lại ở mọi lần thử
for ($attempt = 1; ; $attempt++) {
$last = $attempt >= $attempts;
try {
[$status, $payload, $retryAfter] = post_scan($apiKey, $idempotencyKey, $json);
} catch (RuntimeException | JsonException $e) {
// Timeout, kết nối bị ngắt, hoặc một proxy trả về 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);
}
Chạy bằng php scan.php specimen.jpg. Với một mẫu đọc được, nó in ra một dòng có dạng như sau (giá trị đã được che):
{"ok":true,"billed":true,"kind":"passport","country":"<ISO3>","expiry_date":"<YYYY-MM-DD>","surname":"<SURNAME>","birth_date":"<YYYY-MM-DD>","mrz":"passed"}
Vài lựa chọn đáng giải thích:
- curl không thất bại khi gặp 4xx hay 5xx. Client đọc body JSON trong mọi trường hợp, vì body lỗi mang mã lỗi.
- Body và key được tạo một lần, trước vòng lặp. Đó là điều khiến mọi lần thử là cùng một request trong mắt máy chủ.
return_portrait: falsebỏ ảnh cắt chân dung của người mang giấy tờ khỏi response, nên log và bộ lưu trữ của bạn bớt đi một khuôn mặt.referenceđược trả lại dưới dạngmeta.reference(tối đa 128 ký tự): cách dễ dàng để gắn một lượt quét với đơn hàng hoặc người dùng của chính bạn.- Đừng bao giờ ghi log body của request. Đó là một giấy tờ tùy thân. Hãy ghi log
code,request_idvàdocs_url; đó là tất cả những gì bộ phận hỗ trợ cần.
Phiên bản Laravel
Http client của Laravel (Laravel 10 trở lên) đã làm sẵn phần lớn vòng lặp: retry() nhận một closure cho thời gian chờ và một closure quyết định một lần thử có đáng lặp lại hay không, còn post() với một mảng sẽ gửi JSON. Đặt khóa vào config/services.php:
'doc_cheap' => [
'key' => env('DOC_CHEAP_API_KEY', 'sk_sandbox_public'),
],
Sau đó là một service nhỏ:
<?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; // timeout hoặc kết nối bị ngắt: key giúp việc thử lại an toàn
}
return $e instanceof RequestException
&& in_array($e->response->json('error.code'), self::RETRYABLE, true)
&& (int) $e->response->header('Retry-After') <= 60;
}
}
Với throw: false, một lần bị từ chối không đáng thử lại sẽ trở về dưới dạng response, và service biến nó thành một exception nêu rõ mã lỗi. Trong code của bạn, hãy cho exception đó một class riêng mang mảng error, để nơi gọi có thể phân nhánh theo code thay vì theo thông báo.
Một controller nhận file tải lên và chỉ giữ lại những gì nó cần:
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');
}
Trong một job chạy qua queue, hãy biến key thành một phần của job. Nếu bạn chuyển lần gọi vào một job, hãy tạo idempotency key khi file tải lên đến nơi và truyền nó vào constructor của job. Khi đó, một job thất bại và được queue worker thử lại sẽ gửi cùng key, và một lượt quét đã chạy sẽ được phát lại thay vì bị tính phí lần nữa. Tạo một Str::uuid() mới bên trong handle() sẽ phá hỏng điều đó.
Kiểm thử nhánh thử lại bằng Http::fake(). Một chuỗi gồm 429 rồi 200 chứng minh rằng lần thử thứ hai có diễn ra và mang cùng key, mà không cần gọi mạng:
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']);
Giữ ít dữ liệu hơn
Đừng lưu ảnh đã tải lên sau lần gọi; hãy đọc những gì bạn cần từ kết quả rồi bỏ các byte đi. Ở phía dịch vụ, kết quả được giữ lại để bạn đọc lại bằng GET /v1/scans/{id}, trong một khoảng thời gian bạn chọn cho từng request: options.retain_hours nhận giá trị từ 0 đến 8760, và 0 không ghi gì cả. Sự đánh đổi là quy tắc phát lại ở trên: với 0, một lần thử lại với cùng key sẽ nhận idempotency_replay_unavailable. Nếu muốn cả hai, hãy chọn một khoảng ngắn lớn hơn 0.
Chuyển sang live
Đặt DOC_CHEAP_API_KEY thành khóa sk_live_ của riêng bạn và không có gì khác thay đổi: cùng endpoint, cùng response, cùng client. Một khóa đã đăng ký cho phép 60 request mỗi phút, nên một batch tôn trọng Retry-After sẽ không cần bộ giới hạn tốc độ riêng. Nếu bạn vẫn đang chọn nhà cung cấp, bảng so sánh các API OCR hộ chiếu liệt kê các lựa chọn trả phí cùng mức giá họ công bố.
Một câu hỏi dành cho bạn: hiện nay bạn tự phân tích MRZ trong PHP, hay giao việc đó cho một dịch vụ? Hãy cho chúng tôi biết trong phần bình luận bên dưới.