How to extract text from a scanned PDF in Laravel
You point a PDF parser at a document, get back an empty string, and nothing threw an exception. The instinct is to try another library. Don't: every library will give you the same empty string, because they are all right.
A PDF is a container. It can hold text, or it can hold a photograph of text. Your parser reads the first kind. The second kind needs OCR, and knowing which one you have is the whole problem.
The empty string is the signal
This is the part worth internalising, because it changes how you write the code. An empty result from text extraction is not a failure to handle, it is a classification you just performed for free:
$text = $file->text(); if ($text === '') { // Not broken. Scanned. Escalate. $text = $file->vision('ai'); }
Free, fast and correct for the majority of documents; expensive and slow only for the ones that need it. Running OCR on everything because some of it is scanned is the mistake that turns a twenty minute job into an overnight one.
The two levels
use EduLazaro\Larascraper\Scraper; use EduLazaro\Larascraper\Support\ScraperResponse; class DocumentScraper extends Scraper { protected function handle(string $url): ScraperResponse { $file = $this->scrape($url)->click('a.pdf')->capture()->file(); $text = $file->text(); if ($text === '') { $text = $file->vision('ai'); } return $text === '' ? $this->fail('no_text') : $this->ok(['text' => $text]); } }
->file() gives you a CapturedFile, and it has both halves:
$file->text($engine = 'gs') |
Reads the existing text layer. No OCR, fast, free. Returns '' when there is none. |
$file->vision($engine = 'ai') |
Rasterizes every page to an image and reads it. |
$file->bytes() / $file->save($path) |
The raw bytes. |
$file->contentType() / $file->size() |
Metadata. |
Which text engine
Three, and they disagree more often than you would expect on the same file:
| Engine | Tool | |
|---|---|---|
gs |
ghostscript | The default. Best general behaviour on the mess that real PDFs are. |
poppler |
pdftotext |
Better at preserving layout on multi-column documents. |
smalot |
smalot/pdfparser | Pure PHP, no system binary. Useful when you cannot install one. |
If your output has columns interleaved into nonsense, try text('poppler') before you reach for OCR. That is a layout problem, not a missing text layer, and OCR will not fix it any better.
Which vision engine
$file->vision('tesseract'); // local, free, needs the language pack $file->vision('ai'); // a vision model, per-page API cost
tesseract is right for clean scans: a flatbed image of a printed page at a reasonable resolution is exactly what it was built for, and it costs nothing.
ai earns its cost on the hard ones. Photographed pages, skewed scans, stamps and signatures over text, historical typefaces, forms where the layout carries meaning. A vision model reads context; tesseract reads shapes.
The pragmatic arrangement is the same escalation one level down. Try tesseract, and fall back to the model when what comes back is too short to be a page:
$text = $file->vision('tesseract'); if (mb_strlen($text) < 200) { $text = $file->vision('ai'); }
Configure the ai engine in config/larascraper.php with openai_key, vision_model, vision_lang and vision_dpi, or leave the key to OPENAI_API_KEY.
vision_dpi is the one to know about. Too low and characters do not resolve; too high and you are paying to send a poster. If OCR quality is bad on documents that look fine to you, raise it before you blame the engine.
The system packages Composer cannot install
These are OS packages, not Composer dependencies. Composer cannot install them for you, and this is where the whole thing usually falls over in a container:
| Feature | Binary | Debian/Ubuntu |
|---|---|---|
text(), the default gs engine |
gs |
apt-get install ghostscript |
text('poppler') and page rasterization |
pdftotext, pdftoppm |
apt-get install poppler-utils |
vision('tesseract') |
tesseract |
apt-get install tesseract-ocr |
The trap: vision('ai') still needs poppler-utils. It rasterizes each page with pdftoppm before sending anything anywhere. "I am using the cloud engine so I do not need local tools" is wrong, and it fails at the point where you have already paid for the fetch.
A missing binary fails with a clear message naming it, so at least you find out fast.
Do not always go through the browser
If the PDF sits at a plain URL, there is no reason to launch Chromium for it. The HTTP driver downloads binaries through the same terminal:
class LawScraper extends Scraper { protected string $driver = 'http'; protected function handle(string $url): string { return $this->scrape($url)->file()->text() ?: ''; } }
A binary response is exposed through ->file() on both drivers; text responses still arrive as HTML. On a corpus of any size this is the difference between a crawl that finishes and one that does not, and it is also the only path that can run concurrently in a Spider.
When the file has no URL of its own, because it only exists inside a viewer, that is a different problem: see how to download a PDF with no direct URL.
Two things that will bite you
A PDF can be partly scanned. Twenty pages of text and three photographed annexes is normal in official documents. text() returns the twenty pages and silently drops the three, and nothing is empty so no fallback fires. If completeness matters, compare extracted length against page count and escalate the whole document when the ratio looks wrong.
An empty string and a failure are different. text() returning '' means there is no text layer. A missing binary throws. Do not collapse both into a try/catch that quietly falls through to OCR, or a broken container will look like an archive of scanned documents and cost you a fortune to find out otherwise.
The full CapturedFile API is in the files and PDF chapter.
written by Edu Lazaro · August 2026