Reading documents with GLM-OCR and Laravel

A while ago I needed to get numbers out of a pile of scanned invoices. Not a few, a couple of hundred. The classic answer is a PDF text extraction library, which works fine until someone scans a paper document and you’re left with a picture of text and nothing else.

I ended up doing it with an OCR model instead. The nice part is that these models are served behind an OpenAI-compatible API, so from PHP it’s just HTTP calls. No Python, no local model weights, no queue of shell commands.

Let me walk you through it.

The models

I’m using four models, each doing one job.

GLM-OCR reads text out of documents and images. It’s worth being clear about what it is: it’s built to transcribe, not to chat. If you ask it to summarize a contract, you’ll get a mediocre answer. Ask it to read the contract, then hand the text to a general model, and you get a good one.

Qwen3-Embedding-8B turns text into vectors, 4096 dimensions each. Qwen3.5-122B-A10B-FP8 answers the actual questions. And Qwen3-VL-Reranker-2B reorders search results, which I’ll get to at the end because you probably don’t need it on day one.

The first three sit behind a plain OpenAI-compatible endpoint, so anything that serves them and speaks that protocol will do, whether that’s your own vLLM box or a hosted provider. The reranker is the exception, and I’ll come back to why at the end.

Setting up the client

Add the endpoint to config/services.php:

'llm' => [
    'url' => env('LLM_BASE_URL'),
    'key' => env('LLM_API_KEY'),
],

Then a small class that hands out a configured request. I’m using Laravel’s HTTP client directly here. There are decent OpenAI packages for PHP, but for four endpoints it’s more indirection than it’s worth.

namespace App\Support;

use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;

class DocumentReader
{
    protected function request(): PendingRequest
    {
        return Http::baseUrl(config('services.llm.url'))
            ->withToken(config('services.llm.key'))
            ->timeout(180)
            ->throw();
    }
}

The timeout matters. OCR on a dense page isn’t fast, and the default of 30 seconds will bite you somewhere around page three.

Reading an image

Every call has the same shape. You base64 the file, wrap it in a data URI, pass it as an image_url content part, and add a text instruction next to it. Temperature goes low, because you want transcription, not creativity.

public function read(array $paths, string $instruction): string
{
    $content = collect($paths)
        ->map(fn (string $path) => [
            'type' => 'image_url',
            'image_url' => ['url' => $this->toDataUri($path)],
        ])
        ->push(['type' => 'text', 'text' => $instruction])
        ->all();

    return $this->request()
        ->post('/chat/completions', [
            'model' => 'GLM-OCR',
            'temperature' => 0.1,
            'messages' => [['role' => 'user', 'content' => $content]],
        ])
        ->json('choices.0.message.content');
}

protected function toDataUri(string $path): string
{
    $mimeType = mime_content_type($path);
    $base64 = base64_encode(file_get_contents($path));

    return "data:{$mimeType};base64,{$base64}";
}

Notice that read() takes an array of paths. The content array can hold as many image parts as you want, and the model reads them in order. That turns out to be the whole trick for multi-page documents.

Calling it looks like this:

$text = $reader->read(['receipt.jpg'], 'Extract all text from this image.');

JPEG, PNG and WebP all work, and so does SVG.

Reading a PDF

Some gateways accept a PDF directly and rasterize it server-side for you. I’d rather not depend on that, and doing it locally means I control the resolution. spatie/pdf-to-image handles it in a couple of lines:

use Illuminate\Support\Collection;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use Spatie\PdfToImage\Pdf;
use Symfony\Component\Finder\SplFileInfo;

public function readPdf(
    string $pdfPath,
    string $instruction,
    int $pagesPerRequest = 10,
): string {
    $directory = storage_path('app/ocr/'.Str::uuid());
    File::ensureDirectoryExists($directory);

    try {
        (new Pdf($pdfPath))->resolution(200)->saveAllPages($directory);

        return collect(File::files($directory))
            ->sortBy(fn (SplFileInfo $page) => $page->getFilename(), SORT_NATURAL)
            ->map(fn (SplFileInfo $page) => $page->getPathname())
            ->chunk($pagesPerRequest)
            ->map(fn (Collection $batch) => $this->read($batch->all(), $instruction))
            ->implode("\n\n");
    } finally {
        File::deleteDirectory($directory);
    }
}

Three things in there are worth pointing out.

SORT_NATURAL matters, because without it page 10 sorts before page 2 and your document comes back shuffled, which is the kind of bug that looks like the model hallucinated. resolution(200) matters too: at the default 144 dpi, small print and stamped text start dropping characters, while going above 300 mostly just makes your payloads bigger.

The finally is there because the request builder calls ->throw(). One failed page means the exception escapes, and without it you’d leave a directory of rendered images behind on every failure.

That $pagesPerRequest default of 10 is the main lever you have on request size. Ten pages at 200 dpi is already a few megabytes, and base64 adds roughly a third on top of that. If you’re getting timeouts or payload limits, turn it down before you touch anything else.

The package needs Imagick and Ghostscript on the machine, so that’s one more thing for your Dockerfile.

Asking for Markdown

By default you get a flat wall of text. If the document has structure, you want to keep it, so ask for Markdown. Being vague here doesn’t work. Spell out the syntax you want.

$markdown = $reader->readPdf('handbook.pdf', <<<'TEXT'
    Extract the text from this document and format it as Markdown.
    Use # for main headings, ## for subheadings, and - for bullet lists.
    TEXT);

“Format this nicely” gets you something that’s Markdown-adjacent and inconsistent between pages. Listing the markers gets you something you can parse.

Tables need the same treatment. Ask for pipe syntax explicitly or rows get flattened into prose. One quirk worth knowing: when the input is an HTML file, the model emits <table> markup no matter how firmly you ask for pipes. Feeding it a PDF or an image of the same content fixes it.

Pulling structured data out

This is the part I actually wanted. Describe the JSON you want in the prompt, and the model fills in what it finds:

$raw = $reader->readPdf('invoice.pdf', <<<'TEXT'
    Extract the following fields from this invoice and return them
    as a JSON object:
    {
        "invoice_number": "",
        "date": "",
        "vendor": "",
        "total_amount": "",
        "line_items": []
    }
    TEXT);

And here’s the part that catches everyone. The model wraps its JSON in Markdown code fences. It doesn’t matter that you asked for raw JSON, it doesn’t matter how you phrase it. So strip them:

$json = str($raw)
    ->trim()
    ->replaceMatches('/^```[a-z]*\s*/', '')
    ->replaceMatches('/\s*```$/', '')
    ->toString();

$invoice = json_decode($json, true, flags: JSON_THROW_ON_ERROR);

I’d validate the result before it goes anywhere near your database. The model is reading a photo of a document, and “1.00” versus “l.00” is a real failure mode. For a scan of poor quality, it helps to tell it to append [?] to any word it isn’t sure about instead of guessing silently. Then you can grep for those and route them to a human.

Multilingual documents work in one pass, no language flag needed. If a document mixes languages and you want it kept that way, say so, otherwise the model sometimes helpfully translates everything into one language.

Answering questions across a stack of documents

Once your documents are Markdown, you can build a small retrieval setup on top. Chunk the text, embed the chunks, embed the question, and feed the closest matches to a model that’s good at answering.

Chunk on headings first, then on word count, with a bit of overlap so a sentence that straddles a boundary survives:

use InvalidArgumentException;

public function chunk(string $text, int $size = 400, int $overlap = 50): array
{
    if ($overlap >= $size) {
        throw new InvalidArgumentException('The overlap has to be smaller than the chunk size.');
    }

    return collect(preg_split('/^(?=# )/m', $text))
        ->flatMap(function (string $section) use ($size, $overlap) {
            $words = preg_split('/\s+/', trim($section), flags: PREG_SPLIT_NO_EMPTY);

            $chunks = [];

            for ($index = 0; $index < count($words); $index += $size - $overlap) {
                $chunks[] = implode(' ', array_slice($words, $index, $size));
            }

            return $chunks;
        })
        ->filter()
        ->values()
        ->all();
}

This is exactly why I asked for Markdown output earlier. Splitting on headings means a chunk tends to be about one thing, and that makes retrieval noticeably more accurate than slicing at arbitrary word counts. The guard at the top isn’t paranoia: the loop steps forward by $size - $overlap, so an overlap equal to or larger than the chunk size gives you a step of zero and a loop that never ends.

Embedding is a single call, and it takes an array:

public function embed(array $texts): array
{
    return $this->request()
        ->post('/embeddings', [
            'model' => 'Qwen3-Embedding-8B',
            'input' => $texts,
        ])
        ->collect('data')
        ->pluck('embedding')
        ->all();
}

Then cosine similarity, which is less scary than it sounds:

public function cosine(array $first, array $second): float
{
    $dotProduct = 0.0;
    $firstNorm = 0.0;
    $secondNorm = 0.0;

    foreach ($first as $index => $value) {
        $dotProduct += $value * $second[$index];
        $firstNorm += $value ** 2;
        $secondNorm += $second[$index] ** 2;
    }

    return $dotProduct / (sqrt($firstNorm) * sqrt($secondNorm) + 1e-9);
}

And the answer step:

public function answer(string $question, array $chunks): string
{
    $context = implode("\n\n---\n\n", $chunks);

    return $this->request()
        ->post('/chat/completions', [
            'model' => 'Qwen3.5-122B-A10B-FP8',
            'temperature' => 0.7,
            'top_p' => 0.8,
            'max_tokens' => 1024,
            'chat_template_kwargs' => ['enable_thinking' => false],
            'messages' => [
                [
                    'role' => 'system',
                    'content' => 'Answer questions based only on the provided document context. Be concise and precise.',
                ],
                [
                    'role' => 'user',
                    'content' => "Context:\n{$context}\n\nQuestion: {$question}",
                ],
            ],
        ])
        ->json('choices.0.message.content');
}

That enable_thinking key deserves a word. Qwen3.5 has thinking mode on by default, and when it’s on, the reply lands in reasoning_content instead of message.content. So you get an empty string back and no error to explain it. Turn it off and the answer shows up where you expect it.

Keeping vectors in a PHP array is fine for a prototype and nothing more. Once you have real volume, put them in Postgres with pgvector. The pgvector-php package plugs into Eloquent and you can drop the cosine function above entirely.

Reranking, if you need it

Embedding search is fast and approximate. On a large index full of near-identical chunks, it puts the right answer in the top ten but not always in the top three. A reranker is a slower model that actually compares the question to each candidate and scores it.

This is the exception I mentioned at the start. There’s no rerank endpoint in the OpenAI API, so this isn’t a portable call the way the other three are. It’s an extension, and it follows the shape Cohere and Jina settled on. vLLM serves it at /rerank. If you’re on something else, check that it exposes reranking at all before you plan around it.

The pattern is to retrieve broadly, then narrow:

public function rerank(string $question, array $candidates, int $topK = 3): array
{
    return $this->request()
        ->post('/rerank', [
            'model' => 'Qwen3-VL-Reranker-2B',
            'query' => $question,
            'documents' => $candidates,
        ])
        ->collect('results')
        ->sortByDesc('relevance_score')
        ->take($topK)
        ->map(fn (array $result) => $candidates[$result['index']])
        ->values()
        ->all();
}

Pull twenty chunks with embeddings, rerank down to three, send those. On a handful of documents you won’t notice a difference. On a few thousand, you will.

In closing

The thing I’d take away from this is the split in responsibilities. GLM-OCR reads, and it’s very good at reading. It’s not the model you ask about what it read. Chain them, keep the OCR prompt boring and specific, and strip those code fences.

None of this needs anything exotic on the PHP side. It’s Laravel’s HTTP client, one Spatie package for rasterizing, and a JSON body. If you’re on PHP and have been assuming document extraction means standing up a Python service next to your app, it doesn’t.

If you want to go further, the obvious next steps are moving the vectors into pgvector and putting the OCR calls on a queue, since a few hundred pages is a lot of waiting to do inside a request. Both are boring changes, which is the point.

#php#laravel#ai#ocr