llm-html-extractor/symfony-bundle
Symfony bundle for extracting structured data from HTML using large language models. Integrates LLM-powered HTML parsing into your Symfony app with configurable services and a simple API for turning messy web pages into clean, usable JSON.
This release adds two first-class "reader" drivers — Jina Reader (hosted r.jina.ai URL-to-markdown service) and vLLM (any OpenAI-compatible self-hosted endpoint) — alongside HTML-based custom extractors for sites you want to parse deterministically instead of via an LLM.
Start here, in this order:
php artisan vendor:publish --tag="<vendor>-config" and read the readers / drivers block first. That block is the whole mental model of the package: an ordered map of driver name → options, plus a default key.jina needs JINA_API_KEY and gives you zero-infra markdown extraction; vllm needs VLLM_BASE_URL + VLLM_MODEL pointing at your own server and gives you no per-request cost and no data leaving your network.$doc = Reader::make() // uses config('...default')
->read('https://example.com/some-article');
$doc->markdown(); // normalized markdown body
$doc->title();
$doc->metadata(); // driver-dependent, see gotchas
The package version is 0.1, so treat the public API surface as narrow-but-settling: read(), the Document value object, and the extractor registry are the parts you should depend on. Anything else, wrap it.
Driver-per-call, not driver-globally. The default driver should be whichever one you can afford to fail. For most apps that's jina for exploratory work and vllm in production, or vice versa if you can't ship content to a third party. Select explicitly at the call site rather than flipping config between jobs:
Reader::driver('vllm')->read($url);
Reader::driver('jina')->read($url);
Fallback chain. Neither driver is reliable in isolation — Jina rate-limits and can return a degraded page for JS-heavy sites; a self-hosted vLLM instance is one OOM away from a 500. Wrap in a small service of your own that tries the custom extractor first, then the LLM driver, then the hosted reader:
class ArticleReader
{
public function read(string $url): ?Document
{
foreach (['extractor', 'vllm', 'jina'] as $driver) {
try {
return Reader::driver($driver)->read($url);
} catch (ReaderException $e) {
report($e);
}
}
return null;
}
}
Catch the package's exception type, not \Throwable — you want genuine bugs to surface.
Custom extractors as a whitelist, keyed narrowly. Register on hostname plus path prefix, not bare hostname. A blog.example.com extractor that matches on host alone will silently mangle /docs and /pricing the moment the site restructures. Keep the selector logic boring — one content root, one title, one date — and return null when the root selector misses so your fallback chain kicks in rather than storing an empty document.
Queue everything. Both drivers are network-bound with multi-second tail latency. A job with $timeout = 120, $tries = 3, and backoff() of [10, 60, 300] is the shape you want. Set retryUntil rather than relying on tries alone if the upstream can be down for minutes.
Cache by content identity, not URL. Store $doc->markdown() under a hash of the URL and driver name, with a TTL of hours-to-days. Re-running the same URL through vLLM on every page view is the single easiest way to turn a cheap feature into an expensive one.
Sanitize before you render. Markdown coming back from either driver is attacker-influenced input if you're reading arbitrary URLs. Run it through your existing HTML purifier after converting back to HTML, or render with a CommonMark config that disables raw HTML. Do not blade-echo it.
Fake it in tests. Assert on driver selection and on the shape of the Document, not on extracted prose — the prose will change the first time you upgrade a model or the vendor tweaks their extractor. A container binding that returns a fixture Document keeps your suite fast and offline.
vllm is not an endpoint, it's a contract. Anything OpenAI-compatible works in principle; in practice the package sends a specific prompt and parses a specific response shape. If your server ignores response_format or wraps output in a chatty preamble, extraction degrades quietly into "the model talked about the page" rather than failing. Smoke-test with a page you know cold before trusting it.truncated flag on the document; if it doesn't, compare input length against your model's limit yourself and route long pages to the HTML extractor path.published_at, author, language may or may not be populated. Never ->metadata('author') into a non-nullable column. Treat metadata as best-effort enrichment and default it at the boundary of your own code.php artisan config:cache in deploy means env() inside the package config returns null. Confirm the package's config file uses env() only at the top level and that your deploy pipeline re-caches; then debug "driver not found" by checking config('...readers') at runtime, not your .env.0.1 with a feature list this broad means the driver option names will move. Constrain to 0.1.* and read the diff before every bump; the failure mode here is a silently-changed option key that falls back to a default and doubles your inference bill.How can I help you explore Laravel packages today?