Laravel OCR, tested on a scanned PDF
60 s
until queue:work kills your OCR job
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
| 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:workand started again when they stopped. The upload went tophp 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.
Or send us the scan. Tack, our AI, does the job for $29, up to 50 pages. First 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.