Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Php Readability Laravel Package

j0k3r/php-readability

PHP library that extracts the main readable content and title from web pages. Based on an improved full-text-rss Readability fork, it offers namespaced code, simple tests, optional Tidy HTML cleanup, and PSR-3 logging for debugging parsing issues.

View on GitHub
Deep Wiki
Context7

Getting Started

Install

composer require j0k3r/php-readability

The package requires PHP >= 7.4 (2.0.x line; 1.3.x for older stacks). It suggests but does not require the Tidy extension — install it if your sources are messy:

# Debian/Ubuntu
apt-get install php8.3-tidy   # match your PHP version
# or via your base image
docker-php-ext-install tidy

Confirm it is live (php -m | grep tidy). Without Tidy the library silently falls back to libxml, which behaves differently on malformed markup.

First call

Nothing Laravel-specific exists — it is a plain class, so wrap it once and reuse. Fetch with Laravel's HTTP client, not file_get_contents():

use Illuminate\Support\Facades\Http;
use Readability\Readability;

$url  = 'https://example.com/some-article';
$html = Http::timeout(10)->withHeaders(['User-Agent' => 'Mozilla/5.0 ...'])->get($url)->body();

$readability = new Readability($html, $url); // Tidy auto-used if available
if (! $readability->init()) {
    abort(422, 'No article content found');
}

$title   = $readability->getTitle()->textContent;
$content = $readability->getContent()->ownerDocument->saveHTML($readability->getContent());

Note the two conversions: getTitle() / getContent() return DOMElements, not strings. ->textContent gives plain text; saveHTML($node) gives back HTML.

Where to look first

  1. Readability\Readability — the whole public surface is init(), getTitle(), getContent(), setLogger(), loadHtml(). That is the entire API.
  2. The constructor signature: new Readability(string $html, string $url, string $parser = 'libxml', bool $useTidy = true).
  3. The tests/ directory for fixture HTML you can borrow as regression samples.
  4. The README is short and honest — the source is dense by the maintainer's own admission ("really hard to understand"), so read the tests, not the internals.

First real use case

Ship a "Reader view" endpoint or a save-for-later payload: one route, one service class, one cached result. Do not start by trying to replace a headless browser — this library parses HTML you already have, it does not render JavaScript.

Implementation Patterns

1. One service class, resolved from the container

Keep Readability out of controllers. Bind a thin wrapper so you can swap the parser, inject config, and normalise the return shape.

namespace App\Services;

use Readability\Readability;

class ArticleExtractor
{
    public function extract(string $html, string $url, bool $tidy = true): ?array
    {
        $readability = new Readability($html, $url, 'libxml', $tidy);
        $readability->setLogger(logger()->getLogger()); // PSR-3

        if (! $readability->init()) {
            return null;
        }

        $node    = $readability->getContent();
        $titleEl = $readability->getTitle();

        return [
            'title'   => $titleEl ? trim($titleEl->textContent) : null,
            'content' => $node ? $node->ownerDocument->saveHTML($node) : null,
        ];
    }
}

Register it as a singleton or scoped binding; the class is cheap to construct, but a binding lets you feature-flag Tidy per environment.

2. Inject a Monolog channel for debugging

This is the single most valuable integration point, because failures are opaque:

// config/logging.php
'readability' => [
    'driver' => 'single',
    'path'   => storage_path('logs/readability.log'),
    'level'  => 'debug',
],
$readability->setLogger(Log::channel('readability')->getLogger() ?: new \Psr\Log\NullLogger());

Log::channel(...) returns a Logger wrapping Monolog; ->getLogger() exposes the PSR-3 instance the library expects. Turn the channel off (NullLogger) in production so you are not writing megabytes of DOM traces.

3. Queue the extraction, cache the result

Parsing is CPU-bound and unbounded in input size. Never do it inside a web request on a hot path.

class ExtractArticle implements ShouldQueue
{
    public $timeout = 60;
    public $tries   = 2;

    public function __construct(public int $articleId) {}

    public function handle(ArticleExtractor $extractor)
    {
        $article = Article::findOrFail($this->articleId);
        $html    = Http::timeout(15)->get($article->source_url)->body();

        $result = $extractor->extract($html, $article->source_url);

        $article->update([
            'title'          => $result['title'] ?? $article->title,
            'content_html'   => $result['content'],
            'extracted_at'   => now(),
            'extract_status' => $result ? 'ok' : 'failed',
        ]);
    }
}

Wrap with Cache::remember("article:{$id}:readable", now()->addDay(), fn () => ...) if the payload is served to users directly.

4. Sanitise before rendering, always

The output is derived from untrusted third-party HTML. Readability strips nav/ads but is not a security filter. Run the result through an allow-list cleaner (mews/purifier, HTMLPurifier) before saving or echoing it:

'title'   => $title,
'content' => clean($result['content'], 'article'), // config/purifier.php profile

Also resolve relative links and lazy-load images, since the extracted DOM keeps them untouched:

$dom = new \DOMDocument();
@$dom->loadHTML('<?xml encoding="utf-8"?>' . $result['content']);
foreach ($dom->getElementsByTagName('img') as $img) {
    if ($src = $img->getAttribute('data-src') ?: $img->getAttribute('data-lazy-src')) {
        $img->setAttribute('src', $this->absoluteUrl($src, $url));
    }
}

5. Two-tier strategy: cheap parse, expensive fallback

Try Readability first. If init() fails (JS-rendered pages, paywalls, image-only articles), escalate to a headless browser (Browsershot/Puppeteer) or a third-party extraction API. Log which tier succeeded — the ratio tells you whether the package is earning its keep for your corpus.

6. Store both the raw HTML and the extracted HTML

Keep a raw_html column (compressed/LONGTEXT) alongside content_html. When you upgrade the library — and it does get parser-behaviour changes between minors, e.g. 2.0.0 resynced with mozilla/readability — you can re-run extraction over your archive without re-fetching sources that may have rotted.

7. Test against fixtures, not the network

public function test_extracts_medialens_article(): void
{
    $html = file_get_contents(base_path('tests/fixtures/medialens-thatcher.html'));
    $result = app(ArticleExtractor::class)->extract($html, 'https://www.medialens.org/...');

    $this->assertNotNull($result);
    $this->assertStringContainsString('Thatcher', $result['title']);
    $this->assertStringNotContainsString('<nav', $result['content']);
}

Assert on properties (nav gone, main heading present, length above a floor) rather than exact byte matches — the algorithm is heuristic and output shifts between releases.

8. Expose the parser choice as config

// config/readability.php
return [
    'parser' => env('READABILITY_PARSER', 'libxml'), // or 'html5lib'
    'tidy'   => env('READABILITY_TIDY', true),
];

html5lib routes through masterminds/html5 (used by Symfony) and handles HTML5 elements better; libxml is faster but chokes on <article>/<figure> nesting in older builds. Being able to flip this per environment without a deploy is worth the nine lines.

Gotchas and Tips

init() returns false more often than you expect. Always branch. For a reader-mode feature, a false result should degrade to "show the original page" rather than an error page.

Do not inject the logger after init(). loadHtml() moved from the constructor into init() in 2.0.0 specifically so a logger could be attached first. Set the logger, then call init(). (2.0.9 relaxed this by exposing loadHtml() publicly, so you can call loadHtml() independently — but keep the ordering habit.)

Character encoding is the classic breakage. The library has had repeated regressions around charset detection — notably when a <title> precedes <meta charset> (fixed 2.0.7) and with tidy_repair_string mangling encoding (fixed 1.3.1/2.0.8). If you see mojibake:

  • Pin to a recent 2.0.x.
  • Force UTF-8 on the input: $html = mb_convert_encoding($html, 'UTF-8', $detected); using the Content-Type header charset.
  • Don't pass an already-corrupted string and expect the parser to recover it.

Tidy present vs absent changes results materially. Historical bug: "body can be wiped without tidy" (1.2.7), a TypeError when tidy is disabled (2.0.9). Your staging and production containers must match on whether ext-tidy is installed. Add an assertion to your deploy checklist or a health-check route.

getContent() is a DOMElement, not a string. echo $readability->getContent() will not do what you want. Use ->textContent for plain text and $node->ownerDocument->saveHTML($node) for markup. The content node is a JSLikeHTMLElement subclass, so saveHTML($node) on the owner document is the reliable route.

Relative URLs stay relative. Readability does not rewrite href/src. If you render the extracted HTML on a different domain, every link and image breaks. Post-process with the source URL as base.

Lazy-loaded images vanish. Sites using data-src, data-lazy-src, srcset with a 1×1 placeholder will appear as empty figures. Swap attributes in post-processing, and note the library keeps empty <figure> cleanup behaviour (1.2.8) — so a figure whose only image lost its src may be dropped entirely.

It does not execute JavaScript. For SPAs and client-rendered sites you must prerender. Budget for a headless-browser fallback or accept a lower extraction hit rate. This is the number-one reason a Laravel team abandons the package.

Guard against pathological input. Large documents can exhaust memory_limit inside DOMDocument. Cap the input size before parsing (Str::length($html) > 2_000_000 → reject or truncate), and give queued jobs an explicit $timeout.

Never patch vendor/. The internals are acknowledged as hard to read, and the project cuts frequent patch releases (2.0.1 → 2.0.9 in a short window). Every fix you make in place will be clobbered. Isolate workarounds in your ArticleExtractor wrapper, where they survive composer update.

composer.lock is not committed upstream — the package's own dev dependencies float. That doesn't affect you as a consumer, but it means CI failures and behaviour changes can land in patch releases. Run your fixture tests against --prefer-lowest and latest in CI to catch it.

Extension points are minimal — plan around that. There is no plugin system, no options array, no callback hook for "keep this selector." Your only levers are: the parser flag, the Tidy flag, and pre/post-processing of the HTML string yourself. If you need per-site rules (e.g. "for this domain, start at .article-body"), implement pre-processing before handing HTML to Readability — trim the DOM to the container you want. That is far more effective than post-filtering.

Licensing and dependency risk is low. Apache-2.0, no transitive runtime dependencies beyond psr/log (and optionally masterminds/html5, ext-tidy, ext-mbstring). With zero dependents on Packagist it is a small project, but the maintenance is real and active (Dependabot, PHPStan, PHP 8.4 CI) — which is more than can be said for the Bitbucket original it forks. Pin a minor ("j0k3r/php-readability": "^2.0.9") and read the release notes on upgrade, since algorithm syncs can change output shape.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
encore2/laravel-admin2
cboxdk/laravel-console-kit
shapecode/doctrine-dbal-datetimeutc
gilads-otiannoh254/inertia-protocol
dakiquang/laravel-oauth-refresh-lock
woda/laravel-worktrees
dalefranks/ramasite
faghy/laracashbook
roundly-consulting/google-places-for-laravel
roundly-consulting/kubernetes-api-for-laravel
roundly-consulting/plausible-for-laravel
gvp/gvp-laravel-template
metasyncsite/laravel-metasync-client
roundly-consulting/onboarding-for-laravel
adachsoft/cli-process-tester
adachsoft/changelog-linter
adachsoft/collection
adachsoft/php-code-style
adachsoft/api-version-bundle
cgv/localized-resource