تصلك صورة جواز سفر عبر نموذج PHP، ويحتاج الكود لديك إلى الاسم والرقم والتواريخ والمنطقة المقروءة آليًا (MRZ) في صورة بيانات. يبني هذا المقال ذلك القارئ مرتين: مرة بلغة PHP 8.1 الخالصة مع ext-curl وحدها، ومرة كخدمة Laravel مبنية على عميل Http. ترسل النسختان طلبًا واحدًا، وتتفرعان بحسب النتيجة، وتعيدان المحاولة دون الدفع مرتين، وتحوّلان كل رفض إلى رمز خطأ يمكنك التصرف بناءً عليه.
الواجهة المستخدمة هي واجهة API للتعرّف على جوازات السفر وبطاقات الهوية التي تقف خلف doc.cheap، وهذه مدونة doc.cheap نفسها، فقدّر خيارات المنتج على هذا الأساس. أما الأنماط (مفتاح idempotency واحد لكل صورة، والتفرع بحسب رمز خطأ ثابت، وتخزين حقل احتساب لكل استدعاء) فتصلح لأي واجهة API مدفوعة تستدعيها من PHP.
الاستدعاء الأول، دون حساب
تعرض الوثائق مفتاح sandbox عامًا هو sk_sandbox_public. ينفّذ تعرّفًا حقيقيًا، ولا يحتاج إلى تسجيل، ولا يخصم أي رصيد. يسمح بـ10 طلبات في الساعة لكل عنوان IP وبـ10 عمليات تعرّف مجانية إجمالًا لكل عميل، وبعدها يجيب بـ403 registration_required.
الطلب هو POST https://api.doc.cheap/v1/scans بجسم JSON. حقل واحد إلزامي هو image: بايتات الملف بترميز 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;
الاستدعاء متزامن: تعود الحقول في هذه الاستجابة نفسها، دون معرّف مهمة يحتاج إلى استطلاع دوري (polling) ودون webhook تستضيفه. اختبر بنموذج اصطناعي، ولا تستخدم جواز سفرك أبدًا.
يُفحص الجسم بصرامة. أي مفتاح غير معروف، كخيار مكتوب بخطأ إملائي، يُرفض بـ422 validation_failed مع رسالة تسمّي المسار، فيفشل الخطأ الإملائي بوضوح بدلًا من أن يُتجاهل. والجسم الذي يتجاوز الحد الأقصى للحجم يعود بـ413 payload_too_large؛ ويشرح دليل التعرّف على جوازات السفر ضبط حجم الصورة وكل الخيارات.
ما الذي يعود
لكل إجابة المجموعات الثماني نفسها، وكل مفتاح موجود دائمًا: 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>" }
}
أربعة أجزاء تحدد ما يفعله الكود لديك بعد ذلك:
meta.statusواحدة من خمس سلاسل:recognizedوno_document_foundوunreadableوunsupported_documentوrejected. تصل الخمس كلها بـHTTP200. والأولى وحدها تحمل بيانات.documentوholderهما القيم المنقّحة، والتواريخ فيهما بصيغةYYYY-MM-DD. وتكون كل مجموعةnullبكاملها حين لا يُقرأ لها شيء.mrz.statusقيمتهاpassedأوfailedأوabsent. وpassedتعني أن المنطقة وُجدت، وأن كل أرقام التحقق صحيحة، وأن لا شيء فيها يناقض الصفحة المطبوعة.meta.billedيبيّن هل خصم هذا الاستدعاء رصيدًا.
النقطة التي تشكّل العميلين معًا: الصورة التي تعذّرت قراءتها هي 200، وليست خطأً. تفرّع مرتين: بحسب رمز الخطأ في حالات الرفض، وبحسب meta.status في النتائج. إن أعدت المحاولة مع no_document_found فأنت تعيد إرسال صورة لن تُقرأ أبدًا؛ وإن عددتها نجاحًا فأنت تخزّن وثيقة بلا حقول.
التحقق من MRZ بنفسك في PHP
mrz.lines وmrz.text هما المنطقة كما قُرئت تمامًا، وهما الجزء الوحيد من الاستجابة الذي يمكنك التحقق منه بشكل مستقل. في جواز السفر (صيغة TD3) يحمل السطر الثاني ثلاثة أرقام تحقق يسهل حسابها من جديد: رقم الوثيقة، وتاريخ الميلاد، وتاريخ انتهاء الصلاحية. يستخدم كل رقم الأوزان 7 و3 و1 بالتكرار؛ وتُحسب الأرقام بقيمتها، والحروف من A إلى Z من 10 إلى 35، ومحرف الحشو < صفرًا.
<?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],
];
}
شغّلها على $scan['mrz']['lines'][1] حين تكون قيمة $scan['document']['kind'] هي passport. لست بحاجة إليها لتثق بـmrz.status؛ لكنها مفيدة في اختبار، أو حين تحتفظ بالمنطقة وتريد أن تثبت لاحقًا ما الذي قُرئ.
من يدفع: حقل billed
مفاتيح الـsandbox لا تمسّ أي رصيد أبدًا. أما مع مفتاح حقيقي (live) فلا يُحتسب المسح إلا إذا حُدِّد نوع الوثيقة وخرج منه واحد على الأقل مما يلي: منطقة MRZ تجتاز أرقام تحققها، أو خمسة حقول أو أكثر من الصفحة المطبوعة، أو رمز شريطي فُكّ ترميزه. عدم العثور على شيء، أو صورة غير مقروءة، أو نوع غير مدعوم، أو عطل من جهة الخدمة – كل ذلك لا يكلّف شيئًا. ومع sk_sandbox_public يظل billed يبيّن هل كان المسح نفسه سيُحتسب، ولهذا يستحق أن تختبر عليه.
خزّن الحقل بجانب صف طلب الشراء أو المستخدم لديك، فتتطابق سجلاتك مع الفاتورة دون خطوة مطابقة. ويحصل الحساب على 100 عملية تعرّف مجانية كل شهر، تُستهلك قبل أي رصيد مدفوع.
إعادة محاولة لا تخصم مرتين
إعادة المحاولة الخطرة هي التي تأتي بعد انتهاء المهلة: فأنت لا تعرف هل وصل الطلب الأول إلى الخادم. أرسل الترويسة Idempotency-Key، من 1 إلى 255 محرفًا، وأعد استخدامها في كل محاولة للصورة نفسها. مع مفتاح حقيقي، يطابق الخادم المفتاح مع بصمة الجسم كاملًا معًا:
- المفتاح نفسه والجسم نفسه والنتيجة مخزّنة: تعود النتيجة الأولى ولا يُخصم شيء مرة أخرى.
- المفتاح نفسه وجسم مختلف:
409 idempotency_conflict. ابنِ الجسم مرة واحدة، خارج الحلقة. - الطلب الأول ما زال قيد التنفيذ:
409 idempotency_in_progress. انتظر وأعد المحاولة بالمفتاح نفسه. - المفتاح نفسه ولا نتيجة مخزّنة (مثلًا بعد
retain_hours: 0):409 idempotency_replay_unavailable. أرسله بمفتاح جديد.
لكل جسم خطأ الشكل نفسه (code وmessage وdocs_url وrequest_id وevent_id)، وcode هي القيمة التي تتفرع بحسبها، لأن حالة 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 الانتظار معظم ساعة، لذا يتوقف العميلان أدناه حين يتجاوز الانتظار دقيقة واحدة بدلًا من حجز عامل PHP.
عميل PHP الخالص
<?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);
}
شغّله بالأمر php scan.php specimen.jpg. مع نموذج مقروء يطبع سطرًا واحدًا بهذا الشكل (القيم مُقنَّعة):
{"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 في الحالتين، لأن جسم الخطأ يحمل الرمز.
- يُبنى الجسم والمفتاح مرة واحدة، قبل الحلقة. وهذا ما يجعل كل محاولة الطلب نفسه في نظر الخادم.
return_portrait: falseيستبعد من الإجابة قصاصة صورة حامل الجواز، فيقلّ وجه واحد في سجلاتك وتخزينك.referenceيعود في صورةmeta.reference(حتى 128 محرفًا): الطريقة السهلة لربط المسح بطلب الشراء أو المستخدم لديك.- لا تسجّل جسم الطلب أبدًا. إنه وثيقة هوية. سجّل
codeوrequest_idوdocs_url؛ فهذا كل ما يحتاج إليه الدعم.
نسخة Laravel
عميل Http في Laravel (الإصدار 10 أو أحدث) يتولى معظم الحلقة أصلًا: تأخذ retry() دالة closure لمدة الانتظار ودالة closure تقرر هل تستحق المحاولة التكرار، وترسل 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; // 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;
}
}
مع throw: false يعود الرفض الذي لا تستحق إعادة محاولته في صورة استجابة، فتحوّله الخدمة إلى استثناء يسمّي الرمز. وفي الكود الخاص بك، امنح ذلك الاستثناء صنفًا مستقلًا يحمل المصفوفة error، حتى يتمكن المستدعون من التفرع بحسب code لا بحسب الرسالة.
متحكم (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');
}
في مهمة ضمن طابور (queued job)، اجعل المفتاح جزءًا من المهمة. إذا نقلت الاستدعاء إلى مهمة، فولّد مفتاح idempotency لحظة وصول الملف المرفوع ومرّره إلى مُنشئ المهمة. عندها ترسل المهمة التي تفشل ويعيدها عامل الطابور المفتاح نفسه، فيُعاد تقديم المسح الذي نُفّذ من قبل بدلًا من احتسابه مرة أخرى. أما Str::uuid() جديد داخل handle() فسيُبطل ذلك.
اختبر مسار إعادة المحاولة باستخدام Http::fake(). تسلسل من 429 ثم 200 يثبت أن المحاولة الثانية تحدث وتحمل المفتاح نفسه، دون أي استدعاء عبر الشبكة:
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. وإذا أردت الأمرين معًا، فاختر مدة قصيرة أكبر من الصفر.
الانتقال إلى الإنتاج
اضبط DOC_CHEAP_API_KEY على مفتاحك sk_live_ ولا شيء آخر يتغير: نقطة النهاية نفسها، والاستجابة نفسها، والعميل نفسه. يسمح المفتاح المسجّل بـ60 طلبًا في الدقيقة، لذا فإن الدفعة التي تحترم Retry-After لن تحتاج إلى محدِّد معدل خاص بها. وإذا كنت ما زلت تختار مزوّد خدمة، فإن مقارنة واجهات API للتعرّف على جوازات السفر تسرد البدائل المدفوعة مع أسعارها المنشورة.
سؤال لك: هل تحلّل منطقة MRZ بنفسك في PHP اليوم، أم تثق بخدمة تتولى ذلك؟ أخبرنا في التعليقات أدناه.