muchwat / opendataloader-pdf-laravel
Laravel wrapper around the opendataloader-pdf CLI: turn a PDF into Markdown, one string per physical page, via Laravel's Process facade.
Package info
github.com/Muchwat/opendataloader-pdf-laravel
pkg:composer/muchwat/opendataloader-pdf-laravel
Requires
- php: ^8.2
- illuminate/console: ^10.0|^11.0|^12.0|^13.0
- illuminate/process: ^10.0|^11.0|^12.0|^13.0
- illuminate/support: ^10.0|^11.0|^12.0|^13.0
Requires (Dev)
- laravel/pint: ^1.0
- orchestra/testbench: ^8.0|^9.0|^10.0|^11.0
- pestphp/pest: ^2.0|^3.0|^4.0
- phpmd/phpmd: ^2.15
README
A thin Laravel wrapper around the
opendataloader-pdf
CLI: hand it a PDF, get back Markdown — one string per physical page, blank
pages preserved — via Laravel's own Process facade. No queue, no bindings
to a specific PDF library, no state.
This package doesn't parse PDFs itself. It shells out to the CLI, asks for
its --markdown-page-separator output so physical pages survive the round
trip, and turns its exit codes and stderr into a small, catchable exception
that tells you whether you (a config problem) or the file (unreadable,
scanned, no text layer) is at fault.
Contents
- Requirements
- Installing the CLI
- Package installation
- Configuration
- Quick start
- Building an upload endpoint
- Rate limiting
- Error handling
- Verifying the setup
- Troubleshooting
- Testing
- License
Requirements
- PHP 8.2 or newer
- Laravel 10, 11, 12, or 13
- The
opendataloader-pdfCLI on the machine running your app (see below) — it is not a Composer dependency - Java 11+ on that same machine, since the CLI shells out to a bundled Java engine
Installing the CLI
The official quick-start is at opendataloader.org/docs/quick-start-python. The steps below are the same thing, adapted for a Ubuntu server running PHP-FPM — the two places people actually get stuck.
1. Java 11+.
sudo apt install openjdk-17-jdk java -version
Ubuntu registers this with update-alternatives, so java lands on
/usr/bin/java and is already on PATH for every user and service on the
box. (Unlike, say, a Homebrew install on macOS, which deliberately keeps
Java off PATH by default — set path in the config, or
OPENDATALOADER_PDF_PATH in .env, to point at it there.)
2. The CLI itself, installed somewhere every user can reach. The
upstream docs say pip install -U opendataloader-pdf, which works as-is on
Ubuntu 22.04 and earlier. On Ubuntu 23.04+ (including 24.04 LTS), the system
Python refuses a plain pip install outside a virtualenv
("externally-managed-environment", PEP 668) — install it as an isolated CLI
application with pipx instead:
sudo apt install pipx PIPX_HOME=/opt/pipx PIPX_BIN_DIR=/usr/local/bin pipx install opendataloader-pdf
PIPX_HOME/PIPX_BIN_DIR matter here: a bare pipx install puts
everything under the installing user's own home directory
(~/.local/...). That's fine as a normal deploy user, but if you run this
as root — common on a freshly provisioned VPS — it lands under /root,
which is chmod 700 by default and unreadable to every other account on
the box, including PHP-FPM. Pointing both variables at shared,
world-traversable locations instead (/opt/pipx for the venv,
/usr/local/bin for the executable, both already on everyone's PATH)
sidesteps that regardless of which user runs the install command.
Verify it landed where you expect:
ls -la /usr/local/bin/opendataloader-pdf
3. Confirm it as the user PHP-FPM actually runs as — not your login
shell. PATH and installed packages can differ between an interactive
SSH session and the service account:
ps aux | grep php-fpm | grep -v grep # usually www-data on Ubuntu sudo -u www-data /usr/local/bin/opendataloader-pdf --help
This must print the CLI's usage text, including --markdown-page-separator
— this package relies on that flag to keep physical pages apart. If it's
missing, update: pipx upgrade opendataloader-pdf. Once installed, the
package's own opendataloader-pdf:check command
automates this whole check.
Package installation
composer require muchwat/opendataloader-pdf-laravel
For local package development, use a path repository instead:
{
"repositories": [
{
"type": "path",
"url": "../opendataloader-pdf",
"options": { "symlink": true }
}
]
}
Laravel package discovery registers the service provider and
OpendataloaderPdf facade automatically.
Configuration
Publish the config file:
php artisan vendor:publish --tag=opendataloader-pdf-config
Then set these in .env — everything is off until you do:
OPENDATALOADER_PDF_COMMAND=/usr/local/bin/opendataloader-pdf # Only needed if the CLI's own internal call to `java` can't find it: OPENDATALOADER_PDF_PATH= OPENDATALOADER_PDF_TIMEOUT=120
OPENDATALOADER_PDF_COMMAND doubles as the on/off switch: it's empty by
default, enabled() is true once it isn't, so a fresh install of a host
application is never left silently trying to run an unconfigured feature.
Quick start
use Muchwat\OpendataloaderPdf\Exceptions\PdfExtractionException; use Muchwat\OpendataloaderPdf\PdfExtractor; $extractor = app(PdfExtractor::class); // or resolve it via constructor injection try { $pages = $extractor->extractPages($pdfPath); // list<string>, one entry per physical page $markdown = $extractor->extractMarkdown($pdfPath); // same content joined with blank lines } catch (PdfExtractionException $e) { // see "Error handling" below }
The facade offers the same three methods:
use Muchwat\OpendataloaderPdf\Facades\OpendataloaderPdf; if (OpendataloaderPdf::enabled()) { $pages = OpendataloaderPdf::extractPages($pdfPath); }
extractPages() always returns at least one element. A blank physical page
comes back as an empty string in its correct position rather than being
dropped, so page count and page order both survive the round trip. Output
from a CLI version that predates page markers still comes back correctly —
as a single-element array — rather than throwing.
Building an upload endpoint
The package deliberately ships no controller or route — how a PDF reaches
extractPages() is entirely up to the host application (an upload form, a
queued job, an Artisan command). A typical HTTP endpoint looks like this:
use Illuminate\Http\Request; use Muchwat\OpendataloaderPdf\Exceptions\PdfExtractionException; use Muchwat\OpendataloaderPdf\PdfExtractor; class PdfImportController { public function status(PdfExtractor $extractor) { // Lets a frontend decide whether to show an "Import PDF" button at // all, without spawning a process just to check. return response()->json(['enabled' => $extractor->enabled()]); } public function extract(Request $request, PdfExtractor $extractor) { if (! $extractor->enabled()) { return response()->json(['status' => 'disabled']); } $request->validate([ 'attachment' => 'required|file|mimes:pdf|max:25600', ]); try { $pages = $extractor->extractPages($request->file('attachment')->getRealPath()); return response()->json(['status' => 'ok', 'pages' => $pages]); } catch (PdfExtractionException $e) { $message = $e->isConfigurationIssue && ! $request->user()?->isAdmin() ? 'Automatic PDF import is not available right now. Please paste the text in manually.' : $e->getMessage(); return response()->json(['status' => 'error', 'message' => $message]); } } }
Route::get('pdf-extraction', [PdfImportController::class, 'status'])->middleware('auth'); Route::post('pdf-extraction', [PdfImportController::class, 'extract'])->middleware(['auth', 'throttle:pdf-extraction']);
Returning HTTP 200 with a status: 'error' body (rather than a 4xx/5xx) for
anything past file validation is a deliberate choice worth keeping: a
misconfigured server shouldn't turn into a hard failure for the person
uploading — they can still paste the text in by hand.
Rate limiting
Each call spawns a CLI process — a JVM under the hood — so it is meaningfully more expensive than an ordinary request. If you expose extraction over HTTP, throttle it:
use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Support\Facades\RateLimiter; RateLimiter::for('pdf-extraction', function ($request) { return Limit::perMinute(5)->by($request->user()->id); });
Extraction runs synchronously within the request; there's nothing here that
needs a queue worker, but do raise your web server's own request timeout to
comfortably cover OPENDATALOADER_PDF_TIMEOUT if you increase it.
Error handling
PdfExtractionException::$isConfigurationIssue (readonly bool) tells you
whether the server is misconfigured — disabled, CLI missing, Java
unreachable, timeout too low — or the file is the problem — unreadable,
scanned/image-only, malformed. Use it to decide how much detail is safe to
show:
try { $pages = $extractor->extractPages($pdfPath); } catch (PdfExtractionException $e) { report($e); $message = $e->isConfigurationIssue ? 'PDF import is temporarily unavailable.' // show an admin the real $e->getMessage() instead : $e->getMessage(); // safe to show anyone - it only describes this one file }
A configuration problem is also logged via Laravel's Log facade
(warning level) with the resolved command and, where relevant, stderr —
so it's visible in storage/logs/laravel.log even for a user who only sees
the generic message.
Verifying the setup
php artisan opendataloader-pdf:check
Confirms the configured command resolves and runs, that it can see a Java
runtime, and that it supports --markdown-page-separator. Run it as the
same user your app actually runs as in production:
sudo -u www-data php artisan opendataloader-pdf:check
Troubleshooting
"Unable to locate a Java Runtime"
java resolves fine in your own shell, but the extraction process still
can't see it — typically a service manager with a hardened/restricted
PATH= override. Confirm the directory with which java (usually
/usr/bin on Ubuntu), then set:
OPENDATALOADER_PDF_PATH=/usr/bin
Re-run opendataloader-pdf:check to confirm.
Command not found, but it works in your terminal
You're most likely testing as a different user than the one running your
app. Confirm PHP-FPM's actual user (ps aux | grep php-fpm) and re-check as
that user specifically — see step 3 above. Prefer
the CLI's full resolved path in OPENDATALOADER_PDF_COMMAND over a bare
name in production.
Every extraction fails with a generic "could not run the command" error
If your deployment restricts PHP's disable_functions, make sure
proc_open is not in that list — Laravel's Process facade needs it to
start the subprocess at all. With proc_open disabled, every attempt fails
the same way regardless of whether the CLI itself is installed correctly.
pip install fails with "externally-managed-environment"
Ubuntu 23.04+ (including 24.04 LTS). Use pipx instead — see
step 2 above.
"No text could be extracted from this PDF"
The PDF has no text layer — usually a scanned document. This package can't do OCR; the file needs it done upstream before extraction.
Testing
composer test
Runs the package's own Pest suite against
orchestra/testbench, with
Process::fake() standing in for the real CLI — no Java or CLI install is
required to run the tests.
License
MIT. See LICENSE.