The agent coordinates the workflow. Your PHP code owns the verdict.

A certificate verification system passed a tampered document as valid.
The QR code decoded cleanly. The trusted domain check passed. The original PDF downloaded without error. The agent extracted the fields, ran the comparison, and marked the result: valid. Everything worked. The certificate number matched. The issuer matched. The course title matched.
The recipient name did not. Someone had edited the PDF and swapped in a different person’s name. The agent noticed the fields. It compared them. It still returned valid, because the model weighted the five matching fields more heavily than the one that differed, and the instruction “return valid when the uploaded PDF matches the original PDF” left room for interpretation.
That is the gap between OCR and verification. OCR reads. Verification decides. The model is good at the first part. You do not want it making calls on the second.
The Laravel AI SDK, released as a first-party package on February 5, 2026, makes it easy to build agentic workflows in PHP without standing up a Python service. What it does not make easy is knowing where the agent’s authority should end. That boundary is what this article is about.
What You Are Actually Building
The term “agentic OCR” implies the agent is doing something special with document recognition. It isn’t. The OCR is still happening the same way it always has: render the page to an image, scan for text, normalize the output, return structured fields. What the agent adds is coordination: deciding which tools to call, in what order, and what to do with the results.
A real document verification pipeline has distinct jobs:
- Text extraction: visible content from the uploaded document, via OCR or the embedded text layer
- Machine-readable signal decoding: QR codes, barcodes
- Signal validation against a trusted source list
- Retrieval of the original document from the trusted source
- Field extraction from the original
- Comparison of uploaded fields against original fields
- The verdict
An LLM agent is well-suited for the middle steps: reading context, calling tools, handling ambiguity in extraction, normalizing inconsistent date formats, explaining what it found. It is not well-suited for the final step. The verdict needs to be deterministic. Consistent. Auditable. If a certificate is rejected, someone will ask why. “The model thought so” is not a defensible answer.
The architecture that works separates these concerns: the agent orchestrates, the decision engine enforces.

Installing the Laravel AI SDK
composer require laravel/aiThat is the full install. The package is production-stable as of Laravel 13 (released March 2026) and supported in Laravel 12.x as a first-party add-on. It ships with a unified API for 14 AI providers: OpenAI, Anthropic, Google Gemini, Groq, Mistral, DeepSeek, xAI, Ollama, Azure OpenAI, Cohere, OpenRouter, Jina, VoyageAI and ElevenLabs. Swapping providers is a single .env change.
Configure your provider in config/ai.php:
// config/ai.php
return [
'default' => env('AI_PROVIDER', 'anthropic'),
'providers' => [
'anthropic' => [
'key' => env('ANTHROPIC_API_KEY'),
],
'openai' => [
'key' => env('OPENAI_API_KEY'),
],
],
];The agent() helper function is available globally after installation. For production pipelines you'll want dedicated Agent classes. The helper is useful for prototyping.
The Architecture
The pipeline has two layers. The agent layer and the rules layer.
What the agent layer does:
- QR decoder tool (extracts the URL from the certificate’s embedded QR code)
- Domain validator tool (checks whether that URL is on the trusted issuer list)
- PDF downloader tool (retrieves the original certificate from the trusted source)
- Field extractor tool, run twice: once for the uploaded PDF, once for the original
- The field comparator tool (returns a structured diff of both documents)
What the rules layer does:
- Evaluate the comparison result against hard thresholds
- Check confidence scores from the extractor
- Apply the decision logic: valid, invalid, suspicious, manual_review
- Store the audit record
- Return the final status to the caller
The agent never calls a method that sets the status. The rules layer reads the agent’s structured output and applies deterministic logic. If the rule says “certificate_number_match must be true for a valid result”, that rule runs in PHP code, not in a model instruction.
This boundary is the architectural decision the article is about. Getting it wrong means your verification system can be argued with. Getting it right means the outcome is reproducible and explainable regardless of which model processed the request.
Defining the Tools
In the Laravel AI SDK, a tool is a PHP class. The model decides when to call it. The SDK executes it and passes the result back to the model automatically.
Here is the domain validator tool:
<?php
namespace App\AI\Tools;
use Illuminate\Support\Facades\Http;
use Laravel\AI\Tool;
class ValidateQrDomain extends Tool
{
public string $name = 'validate_qr_domain';
public string $description = 'Check whether a URL decoded from a QR code belongs to a trusted certificate issuer domain.';
/**
* Validate whether the QR URL domain is trusted and SSRF-safe.
*/
public function handle(string $url): array
{
$host = parse_url($url, PHP_URL_HOST);
if (! $host) {
return ['trusted' => false, 'reason' => 'Could not parse host from URL.'];
}
// Block private IP ranges and localhost before any HTTP request
if ($this->isPrivateOrLoopback($host)) {
return ['trusted' => false, 'reason' => 'URL points to a private or loopback address.'];
}
if (strtolower(parse_url($url, PHP_URL_SCHEME)) !== 'https') {
return ['trusted' => false, 'reason' => 'Only HTTPS URLs are accepted.'];
}
$trustedDomains = config('certificate.trusted_domains', []);
$trusted = in_array($host, $trustedDomains, true);
return [
'trusted' => $trusted,
'host' => $host,
'reason' => $trusted ? 'Domain is on the trusted list.' : 'Domain is not on the trusted list.',
];
}
private function isPrivateOrLoopback(string $host): bool
{
if ($host === 'localhost') {
return true;
}
$ip = gethostbyname($host);
return filter_var(
$ip,
FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
) === false;
}
}The SSRF check is the one thing the agent must not be allowed to bypass. A certificate PDF can contain any QR code. Treating the decoded URL as trusted input and forwarding it directly to Http::get() opens the server to requests against internal services, metadata endpoints and localhost services. The tool enforces the validation before any HTTP call happens.
The PDF downloader tool follows the same pattern:
<?php
namespace App\AI\Tools;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Laravel\AI\Tool;
class DownloadOriginalPdf extends Tool
{
public string $name = 'download_original_pdf';
public string $description = 'Download the original certificate PDF from a validated trusted URL.';
/**
* Download the original PDF from the trusted QR source.
*/
public function handle(string $url): array
{
$response = Http::timeout(20)
->accept('application/pdf')
->withOptions(['max_redirects' => 3])
->get($url);
if (! $response->successful()) {
return ['downloaded' => false, 'reason' => 'HTTP request failed with status ' . $response->status()];
}
$contentType = strtolower($response->header('Content-Type') ?? '');
if (! str_contains($contentType, 'pdf')) {
return ['downloaded' => false, 'reason' => 'Response content type is not application/pdf.'];
}
// Reject files over 20MB
if (strlen($response->body()) > 20 * 1024 * 1024) {
return ['downloaded' => false, 'reason' => 'Original PDF exceeds the 20MB size limit.'];
}
$hash = hash('sha256', $response->body());
$path = 'certificates/originals/' . $hash . '.pdf';
Storage::put($path, $response->body());
return [
'downloaded' => true,
'path' => $path,
'sha256' => $hash,
];
}
}The hash serves two purposes. First, identical source documents produce the same path, so you don’t download the same original twice. Second, if the source PDF changes later (the issuer revoked or updated it), you have a record of what the original looked like at validation time.
The Orchestrator Agent
The agent’s instructions define its authority boundary. This is where most agentic OCR implementations go wrong: the instructions ask the agent to “validate the certificate” instead of “coordinate the validation tools.”
<?php
namespace App\AI\Agents;
use App\AI\Tools\DecodeQrFromPdf;
use App\AI\Tools\DownloadOriginalPdf;
use App\AI\Tools\ExtractCertificateFields;
use App\AI\Tools\ValidateQrDomain;
use Laravel\AI\Agent;
use Laravel\AI\Contracts\HasStructuredOutput;
class CertificateValidationAgent extends Agent implements HasStructuredOutput
{
protected array $tools = [
DecodeQrFromPdf::class,
ValidateQrDomain::class,
DownloadOriginalPdf::class,
ExtractCertificateFields::class,
];
public function instructions(): string
{
return <<<'INSTRUCTIONS'
You coordinate a certificate PDF validation workflow.
Your job is to gather information, not to make a final validity decision.
Steps you must complete in order:
1. Decode the QR code from the uploaded PDF path provided.
2. Validate whether the decoded URL belongs to a trusted domain.
3. If the domain is trusted, download the original PDF from that URL.
4. Extract fields from the uploaded PDF.
5. Extract fields from the original PDF (if downloaded successfully).
6. Return all gathered information in the structured output format.
Rules:
- Do not return a final "valid" or "invalid" verdict. Return only the raw data.
- If any step fails, record the failure reason in the relevant field and continue.
- Do not invent field values. Use null for fields you cannot extract.
- Do not follow redirects to untrusted domains.
INSTRUCTIONS;
}
public function schema(): array
{
return [
'type' => 'object',
'properties' => [
'qr_url' => ['type' => ['string', 'null']],
'qr_domain_trusted' => ['type' => 'boolean'],
'original_downloaded' => ['type' => 'boolean'],
'original_pdf_path' => ['type' => ['string', 'null']],
'original_sha256' => ['type' => ['string', 'null']],
'uploaded_fields' => ['type' => 'object'],
'original_fields' => ['type' => 'object'],
'extraction_failures' => ['type' => 'array', 'items' => ['type' => 'string']],
],
'required' => ['qr_domain_trusted', 'original_downloaded', 'uploaded_fields'],
];
}
}The instruction “Do not return a final valid or invalid verdict. Return only the raw data.” is the most important line in the entire file. Without it, you are not building an agentic OCR pipeline. You are delegating the final compliance decision to a probability distribution.
Calling the agent from a queued job:
<?php
namespace App\Jobs;
use App\AI\Agents\CertificateValidationAgent;
use App\Models\CertificateValidation;
use App\Services\CertificateDecisionEngine;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class ValidateCertificatePdf implements ShouldQueue
{
use Queueable;
public int $timeout = 120;
public int $tries = 2;
public function __construct(
public CertificateValidation $validation
) {}
public function handle(
CertificateValidationAgent $agent,
CertificateDecisionEngine $engine
): void {
$context = 'Validate the certificate at path: ' . $this->validation->uploaded_pdf_path;
// Agent coordinates the tools and returns structured data
$agentResult = $agent->prompt($context);
// Decision engine applies deterministic rules to that data
$decision = $engine->decide($agentResult);
$this->validation->update([
'status' => $decision['status'],
'qr_url' => $agentResult['qr_url'],
'uploaded_fields' => $agentResult['uploaded_fields'],
'original_fields' => $agentResult['original_fields'],
'risk_flags' => $decision['risk_flags'],
'reason' => $decision['reason'],
]);
}
}The job runs on the queue because OCR and PDF processing are not fast. A certificate with a scanned image layer, a QR code to decode and an original PDF to download can take ten to thirty seconds end to end. You do not want the HTTP request waiting on that.
The Decision Engine
This is the part that has to live in PHP. Not because PHP is smarter than the model. It isn’t. But this code has to be testable, predictable and auditable.
<?php
namespace App\Services;
class CertificateDecisionEngine
{
public function decide(array $agentResult): array
{
$uploadedFields = $agentResult['uploaded_fields'] ?? [];
$originalFields = $agentResult['original_fields'] ?? [];
// Structural failures come first
if (! $agentResult['qr_domain_trusted']) {
return $this->result('suspicious', ['qr_domain_untrusted'],
'The QR URL does not belong to a trusted certificate issuer domain.');
}
if (! $agentResult['original_downloaded']) {
return $this->result('suspicious', ['original_pdf_unavailable'],
'The original certificate PDF could not be downloaded from the QR source.');
}
// Confidence gates
$lowConfidenceFields = $this->lowConfidenceFields($uploadedFields);
if (! empty($lowConfidenceFields)) {
return $this->result('manual_review', $lowConfidenceFields,
'OCR confidence is too low on one or more fields to make an automated decision.');
}
// Hard field matches
if (! $this->fieldsMatch('certificate_number', $uploadedFields, $originalFields)) {
return $this->result('invalid', ['certificate_number_mismatch'],
'The certificate number does not match the original document.');
}
if (! $this->fieldsMatch('recipient_name', $uploadedFields, $originalFields, fuzzy: true)) {
return $this->result('invalid', ['recipient_name_mismatch'],
'The recipient name does not match the original document.');
}
// Remaining field checks
$mismatches = $this->allMismatches($uploadedFields, $originalFields);
if (! empty($mismatches)) {
return $this->result('invalid', $mismatches,
'One or more certificate fields do not match the original document.');
}
return $this->result('valid', [], 'Certificate matches the original from the trusted QR source.');
}
private function fieldsMatch(string $field, array $uploaded, array $original, bool $fuzzy = false): bool
{
$a = strtolower(trim($uploaded[$field] ?? ''));
$b = strtolower(trim($original[$field] ?? ''));
if ($fuzzy) {
// Normalize whitespace, common name variations
$a = preg_replace('/\s+/', ' ', $a);
$b = preg_replace('/\s+/', ' ', $b);
}
return $a === $b && $a !== '';
}
private function lowConfidenceFields(array $fields): array
{
$threshold = 0.85;
$flags = [];
foreach ($fields['confidence'] ?? [] as $field => $score) {
if ((float) $score < $threshold) {
$flags[] = $field . '_low_confidence';
}
}
return $flags;
}
private function allMismatches(array $uploaded, array $original): array
{
$requiredFields = ['issuer', 'course_title', 'issue_date'];
$mismatches = [];
foreach ($requiredFields as $field) {
if (! $this->fieldsMatch($field, $uploaded, $original)) {
$mismatches[] = $field . '_mismatch';
}
}
return $mismatches;
}
private function result(string $status, array $riskFlags, string $reason): array
{
return compact('status', 'riskFlags', 'reason');
}
}You can unit test every branch of this class with no AI dependency. The agent result is just an array; you can mock it in a hundred different test cases, each one covering a specific failure scenario, without a single API call.
That testability is the point. The model’s behavior is probabilistic and depends on which provider, which version and what day it is. The decision engine’s behavior is deterministic and depends only on the data. Separating them means you can write a regression test for “certificate_number_mismatch should always return invalid” and it will pass on every test run forever.
The Boundary Between Agent and Rules
Most mistakes in agentic document processing come from giving the agent too much authority. Here is a practical breakdown of what should live where:
Agent layer:
- Interpreting ambiguous document layouts
- Extracting text from scanned images with inconsistent formatting
- Normalizing dates that appear in different formats across documents
- Generating human-readable explanations of what was found
- Deciding which extraction strategy to try when the first one fails
Rules layer:
- The definition of “valid” (you wrote it, you own it)
- Confidence thresholds (0.85 is a policy decision, not a model parameter)
- Trusted domain lists (these need to be reviewed by a human, not inferred)
- Hard field matching logic (certificate numbers either match or they don’t)
- The escalation trigger for manual review
The rule of thumb: if a result needs to be the same every time for the same input, it belongs in the rules layer. If a result benefits from interpretation, contextual understanding or handling edge cases gracefully, it belongs in the agent layer.
A second heuristic: if a wrong result creates legal or compliance exposure, that result must not come from the agent.
When to Use the Orchestrator Pattern vs. a Simple Pipeline
The Laravel AI SDK ships five multi-agent patterns based on Anthropic’s agent design research. For document verification, two are most relevant.
Prompt chaining is the right choice when the steps are fixed and you know the execution path upfront. Field extraction → QR decoding → domain validation → comparison → verdict. Each step’s output is the next step’s input. Use Pipeline to wire them together:
// Prompt chaining via Pipeline
Pipeline::make()
->send($uploadedPdfPath)
->pipe(new ExtractFieldsStage)
->pipe(new DecodeQrCodeStage)
->pipe(new ValidateDomainStage)
->pipe(new DownloadOriginalStage)
->pipe(new CompareFieldsStage)
->thenReturn();This works well when the steps are predictable and each stage is reliable. The downside is that a failure in any stage aborts the whole pipeline unless you handle exceptions in each stage.
Orchestrator-workers is the right choice when you need the agent to handle unexpected situations, retry failed steps with a different strategy, or decide what to do when a tool returns partial data. The orchestrator reads the workflow instructions, calls tools dynamically and adapts to what it finds. The certificate validation case is a good fit for this pattern because documents are inconsistent: some have embedded text layers, some are scanned images, some have QR codes in unexpected positions.
For parallel extraction (uploaded and original PDFs simultaneously), use Concurrency::run():
use Illuminate\Support\Facades\Concurrency;
[$uploadedFields, $originalFields] = Concurrency::run([
fn () => $this->extractor->extract($uploadedPdfPath),
fn () => $this->extractor->extract($originalPdfPath),
]);Once both PDFs are downloaded, there’s no reason to extract them sequentially. The Concurrency::run() call runs both extractions in parallel and waits for both to complete before continuing.
Security Controls the SDK Does Not Handle
The SDK makes tool calling easy. It does not make the tools safe. Every tool in a document verification pipeline that accepts external input is a potential attack surface.
The QR code is user-controlled input. Treat it that way:
- Block private IP ranges and loopback addresses before any HTTP request (see the
ValidateQrDomaintool above) - Enforce HTTPS (certificate verification over plain HTTP defeats the purpose)
- Limit HTTP redirects to a small number (two to three maximum) and validate the final destination domain
- Set a timeout on every external request (
Http::timeout(20)) - Reject responses over a reasonable size limit before reading the body
- Verify the Content-Type header before treating the response as a PDF
- Hash every downloaded original PDF (both for deduplication and as an audit record)
The file upload side has its own requirements:
- Validate file type server-side (never trust the MIME type the client sends)
- Set a maximum file size at the upload endpoint, not only in validation
- Store uploaded files outside the public web root
- Never pass a user-uploaded path directly to a shell command
None of these are Laravel AI SDK problems. They are standard security controls that have to be applied regardless of what AI framework you are using. The SDK makes it easy to forget them because the agent layer feels powerful and complete. It isn’t. The agent processes your data. Your application is responsible for what data reaches it.
What the Agent Actually Gives You
The certificate validation pipeline above would work without an AI agent. You could write deterministic OCR extraction using a PHP library, compare fields with string matching, and run the full pipeline with no LLM involved. There are use cases where that’s the right call.
The agent adds value in two specific places.
First, field extraction from documents you don’t control. Certificate templates vary. Fonts, layouts, label positions and date formats differ across issuers. A rule-based extractor breaks when it encounters a format it wasn’t written for. The agent, given a vision model or a text model with PDF content, can extract fields from layouts it has never seen before. The output is still uncertain, which is why the confidence scores in the structured output feed into the manual review gate, but it handles variability better than a regex-based pipeline.
Second, explanation. When the decision engine returns invalid with recipient_name_mismatch, the agent can generate a plain-language explanation for the reviewer: "The uploaded certificate shows 'Ali Ahmad' but the original PDF from the QR source shows 'Abu Bakar' in the recipient name field. The certificate number, issuer and course title all match." That explanation is useful for human reviewers, audit logs and applicant-facing error messages. Writing it deterministically would require a rule for every possible combination of mismatched fields.
Everything else in the pipeline, the domain check, the download, the comparison, the final verdict, your code owns.
The Upload Endpoint
For completeness, the upload controller:
<?php
namespace App\Http\Controllers;
use App\Jobs\ValidateCertificatePdf;
use App\Models\CertificateValidation;
use Illuminate\Http\Request;
class CertificateController extends Controller
{
public function store(Request $request)
{
$request->validate([
'certificate' => ['required', 'file', 'mimes:pdf', 'max:10240'],
]);
$path = $request->file('certificate')->store('certificates/uploaded', 'private');
$validation = CertificateValidation::create([
'uploaded_pdf_path' => $path,
'status' => 'pending',
]);
ValidateCertificatePdf::dispatch($validation);
return response()->json([
'id' => $validation->id,
'status' => 'pending',
], 202);
}
}The file goes to a private disk (not publicly accessible). The job goes on the queue. The response returns immediately with a pending status and the validation ID. The client polls or listens for a webhook when the decision is ready.
What Makes This Hard
The technical parts of this pipeline are not the hard part. The Laravel AI SDK handles the agent coordination. Laravel queues handle the async processing. The SSRF checks and file validations are standard patterns.
The hard part is the boundary definition.
Every organisation that builds document verification has to decide: what counts as a name match? Is “Ahmad bin Ali” the same as “Ahmad Ali”? What confidence threshold should trigger manual review instead of automatic rejection? Which domains are trusted and who approves additions to that list? What happens when the QR URL is valid but the original PDF has been removed?
These are policy decisions. They belong in your configuration, your code review process and your product requirements. The agent cannot make them for you, and you should not ask it to. What the agent gives you is the ability to handle messy, inconsistent documents without writing a rule for every layout variant you might encounter.
OCR reads. The agent coordinates. Your code decides.


