smalot/pdfparser
Standalone PHP library to extract data from PDF files: parse objects and headers, read metadata, pull text from ordered pages, and handle compressed PDFs. Install via Composer. Note: limited maintenance; secured documents and form data extraction aren't supported.
Install and pin. composer require smalot/pdfparser requires PHP 7.1+ (modern 2.x releases support PHP 8.x). Because the project is under limited maintenance, pin a caret range ("smalot/pdfparser": "^2.9") and review changelogs before bumping minors — 2.x had large internal refactors (see the 2.8.0 notes) that changed behaviour on malformed files.
First read. doc/Usage.md for the API surface, then doc/CustomConfig.md. The README is a two-line teaser; almost everything useful lives in those two files.
First use case — text extraction from an uploaded PDF:
use Smalot\PdfParser\Parser;
$parser = new Parser();
$pdf = $parser->parseFile(storage_path('app/invoices/123.pdf'));
$text = $pdf->getText();
$meta = $pdf->getDetails(); // raw metadata array
$pages = $pdf->getPages(); // Page[]
In Laravel, prefer reading from the storage disk rather than passing an absolute path:
$content = Storage::disk('local')->get($path);
$pdf = (new Parser())->parseContent($content);
Sanity check before building on it. Extract a fixture PDF and assert strlen($pdf->getText()) > 0. If it's zero, you almost certainly have a scanned PDF (images only) — this library does no OCR, and no amount of config will fix that. Detect this early and route those files to an OCR pipeline instead.
Wrap the parser in a single service. Do not sprinkle new Parser() through controllers and jobs. One injectable class gives you a seam for swapping engines later (poppler's pdftotext, a cloud API, or a maintained fork) without touching call sites.
final class PdfTextExtractor
{
public function __construct(private Parser $parser) {}
/** @return array{pages: string[], text: string, meta: array} */
public function extract(string $content): array
{
$pdf = $this->parser->parseContent($content);
$pages = array_map(fn ($p) => $p->getText(), $pdf->getPages());
return [
'pages' => $pages,
'text' => implode("\n\n", $pages),
'meta' => $pdf->getDetails(),
];
}
}
Iterate pages, don't call Document::getText() for storage. Per-page extraction gives you page numbers for citations, lets you index pages individually for full-text search or LLM chunking, and lets you skip blank pages. Document::getText() is a convenience concatenation.
Always run parsing in a queued job. PDFs are unbounded in CPU and memory; a 200-page PDF on a PHP-FPM request will hit max_execution_time. Queue it, set $timeout, set $tries = 1 (parsing is not idempotent-safe on timeout — retrying a file that caused an OOM just wedges the worker again), and raise memory_limit inside the job.
Turn off image retention for text-only workflows. This is the single biggest performance lever and it is opt-in:
$config = new \Smalot\PdfParser\Config();
$config->setRetainImageContent(false);
$parser = new Parser([], $config); // note: Config is the *second* arg
Cache by content hash. Parsing is expensive and deterministic for a given file. Cache::remember('pdf:'.sha1($content), ...) or store the extracted text in a documents table (text, page_count, meta json, extracted_at). Then re-extraction becomes a backfill job, not a request-time cost.
Metadata → model mapping. getDetails() returns the raw key/value dictionary (Author, Title, CreationDate, Producer, …) and is inconsistent across producers. Normalise defensively: keys vary in case, values are sometimes wrapped in parentheses or PDF date strings (D:20240101120000+01'00'), and many files have none of it. Treat metadata as best-effort enrichment, never as validation.
OCR fallback pipeline. A pragmatic pattern: extract text; if mb_strlen(trim($text)) < 50 * pageCount, treat as scanned and dispatch to pdftoppm + tesseract (or a cloud OCR). This keeps the fast path fast and costs nothing for digital-native PDFs.
Fixture-driven tests. Commit 5–10 small representative PDFs (single column, two column, form feed, CID-keyed font, scanned). Assert on substrings, not full text equality — output whitespace is not stable across versions.
Unsupported by design: encrypted/password-protected PDFs (2.8 added a "workaround to ignore encryption", but the extracted text is typically garbage), AcroForm field values, and scanned images. If your domain needs any of these, plan for a second tool (qpdf --decrypt, pdftk, OCR) behind the same interface.
Text order is content-stream order, not visual order. Two-column layouts, sidebars, headers/footers and tables often come out interleaved or in an unexpected reading order. Recent 2.x versions expose Page::getTextXY() and getDataTm() with coordinates — use those to sort lines by y then x if layout fidelity matters, and verify the method exists in your installed version before relying on it.
Words glued together or split apart. Tune Config::setFontSpaceLimit() (the gap threshold at which a space is inferred) and setPdfWhitespaces() / setPdfWhitespacesRegex() if a producer emits unusual whitespace. This is the most common "the text is almost right" complaint and is usually a per-producer config, not a bug.
Encoding and glyph issues. Ligatures (fi), bullet glyphs, and CID fonts from InDesign/LaTeX can yield ?, empty strings, or mojibake. setContentDecoding(false) sometimes helps on already-decoded streams that throw. If the output is fine in Acrobat but not here, it's usually a font ToUnicode CMap gap — check upstream issues before forking.
Memory and pathological files. The parser builds the whole object graph in memory. Guard user uploads with a hard file-size cap, a page-count sanity check, and a job timeout. setDecodeMemoryLimit() exists to bound decompression of individual streams — set it, because compression-ratio abuse ("PDF bomb") is a real concern with untrusted input.
Exceptions, not error codes. parseFile/parseContent throw Smalot\PdfParser\Exception\ParserException on structural failure (truncated files, bad xref, missing header). Catch it, mark the document as failed, and store the original — don't let it bubble into a 500.
Debugging technique. When output is wrong, dump $pdf->getDetails() and, for a single page, $page->getRawData() / content stream to see whether the problem is at the object layer or the text-decoding layer. Reduce to a one-page repro before filing upstream; the maintainers require it and turnover is slow.
Licence: LGPL-3.0. Consuming it as a Composer dependency is fine for commercial apps. If you modify the library (vendoring a patched copy), those modifications must be released under LGPL — so prefer wrapping and overriding via Config over forking source files.
Maintenance risk mitigation. No active feature development and no guaranteed PR turnaround. Keep the dependency behind your own interface (as above), track the package for security/CVE advisories, and benchmark an alternative (spatie/pdf-to-text wrapping poppler) so a future migration is a one-class change rather than a rewrite.
How can I help you explore Laravel packages today?