Tools,

Laravel OCR, tested on a scanned PDF

60 s

until queue:work kills your OCR job

Get the fixes

A 10-page scan works as is

1,879 of 1,955 words found

39 seconds in a queued job

On a scan, spatie/pdf-to-text gave 0 characters and no error.

So the job turns each page into an image with pdftoppm and reads it with Tesseract. That is Laravel 13.42 with every default. Then I made the file bigger, and four defaults broke it, one at a time.

PHP drops a 6.5 MB upload

PHP's default upload limit is 2 MB. My 10-page scan is 6.5 MB. The validator answered with HTTP 422:

The scan failed to upload.

In the official PHP Docker image, add this file as /usr/local/etc/php/conf.d/uploads.ini:

upload_max_filesize = 50M
post_max_size = 51M

Then the same upload gave HTTP 200, and the job went on the queue.

30 pages: killed at 60 seconds

Three copies of the scan make 30 pages. The worker's default --timeout is 60 seconds. At 60 seconds it killed the job:

Illuminate\Queue\TimeoutExceededException: App\Jobs\OcrPdfNaive has timed out.

pdftoppm kept running for about 30 seconds after that. Process::run has its own 60-second default. With only the job limit raised, it stopped pdftoppm with ProcessTimedOutException.

The job, with both limits raised

<?php

namespace App\Jobs;

use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Storage;
use Spatie\PdfToText\Pdf;

class OcrPdf implements ShouldQueue
{
    use Queueable;

    public $timeout = 600; // the worker's 60 s default kills a 30-page scan

    public function __construct(public string $path) {}

    public function handle(): void
    {
        Log::info('ocr start', ['path' => $this->path, 'attempt' => $this->attempts()]);
        $pdf = Storage::path($this->path);
        $text = Pdf::getText($pdf); // a scan gives an empty string
        if (trim($text) === '') {
            $dir = "$pdf-pages";
            @mkdir($dir);
            Process::timeout(600)->run(['pdftoppm', '-r', '300', '-gray', '-png', $pdf, "$dir/p"])->throw();
            $text = '';
            foreach (glob("$dir/p-*.png") as $png) {
                $text .= Process::timeout(600)->run(['tesseract', $png, '-'])->throw()->output();
            }
        }
        Storage::put("$this->path.txt", $text);
    }
}

Two workers: a false failure

With that job and two workers, the 30-page scan finished in 1m 58s. But at 90 seconds the second worker took the same job and logged:

Illuminate\Queue\MaxAttemptsExceededException: App\Jobs\OcrPdf has been attempted too many times.

The text was fine. The database queue's retry_after is 90 seconds, and it must be longer than the job's limit. Add this line to .env:

DB_QUEUE_RETRY_AFTER=660

Then the job ran once, with nothing in failed_jobs.

Seven runs, one scan

Laravel 13.42 in Docker, 2 CPUs
Run Result Seconds
10 pages, defaults 1,879 of 1,955 words 39
30 pages, defaults Timed out 60
30 pages, Process default Timed out 60
30 pages, 2 workers Done, and marked failed 118
30 pages, retry_after 660 5,637 of 5,865 words 122
OCR Actor, Http default cURL error 28 30
OCR Actor, timeout(300) 1,874 of 1,955 words 142

Each run had a fresh container and the database queue, the Laravel default. The Actor's time includes its start in the cloud.

No Tesseract on the server? Call the Actor

Our OCR Actor turns a PDF URL into Markdown. Laravel's HTTP client stops waiting after 30 seconds:

cURL error 28: Operation timed out after 30002 milliseconds with 0 bytes received

The run went on and finished anyway. You pay for a result you never see. Set the timeout:

// config/services.php: 'apify' => ['token' => env('APIFY_TOKEN')],
Artisan::command('ocr:actor {url}', function (string $url) {
    $items = Http::withToken(config('services.apify.token'))
        ->timeout(300) // the 30 s default gives up before the Actor answers
        ->post('https://api.apify.com/v2/acts/brasspage~pdf-ocr-to-markdown/run-sync-get-dataset-items', [
            'documentUrls' => [$url],
        ])->throw()->json();
    Storage::put('scan.md', collect($items)->pluck('markdown')->implode("\n\n"));
});

It found 1,874 of 1,955 words, 5 fewer than Tesseract. It costs $0.006 a page.

How I tested it
  • Scan: the 10-page PDF of OCR in n8n, the first 10 of 55 proofread Wikisource book scans, with no text layer. 1,955 true words. The 30-page file is that scan 3 times.
  • php:8.4-cli (PHP 8.4.27, Debian 13.7) with Tesseract 5.5.0, Poppler 25.03.0 and pcntl, which queue:work needs for timeouts. Laravel 13.42.0 and spatie/pdf-to-text 1.55.0, from Composer.
  • Each run: a fresh container with 2 CPUs. Workers ran php artisan queue:work and started again when they stopped. The upload went to php artisan serve.
  • Score: true words found in order, with the scorer of the tessdata test. The code blocks above are the files that ran. The Actor: real runs on the public 10-page scan.
What I did not test
  • Redis and SQS queues, Horizon, php-fpm and Octane.
  • Other OCR languages, photos, tables and handwriting.
  • Laravel 12 and older, and faster or slower CPUs.
Sources

Two ways to skip the work

Run it yourself. Our Apify Actor turns scanned PDFs into Markdown, tables included.

Try the Actor

Or send us the scan. Tack, our AI, does the job for $29, up to 50 pages. First page free.

Get one page free

Questions people ask

How do I OCR a scanned PDF in Laravel?

Read the text with spatie/pdf-to-text first. If it is empty, run pdftoppm and Tesseract in a queued job. My 10-page scan gave 1,879 of 1,955 words in 39 seconds.

Why does my Laravel OCR job time out?

queue:work and Process::run both stop at 60 seconds by default. Set public $timeout = 600 on the job, and call Process::timeout(600).

Why is a finished job in failed_jobs?

The job ran longer than retry_after, 90 seconds by default. A second worker took it and marked it failed. Make retry_after longer than the job's $timeout.