revolution/salvager
Revolution Salvager is a Laravel package for salvaging recoverable data from failed or interrupted operations, helping you capture what would otherwise be lost and restore it cleanly. Lightweight, configurable, and built to fit existing Laravel workflows.
Salvager is a thin Laravel wrapper around Playwright, aimed at the case where Http::get() isn't enough because the page you want is rendered client-side. It is a small, single-maintainer package (7 stars, no known dependents), so treat the source as the documentation.
Prerequisites (budget for these first — they dominate setup time):
npx playwright install --with-deps chromium).Minimal steps:
composer require revolution/salvagerphp artisan vendor:publish --tag=salvager-config or similar) and read config/salvager.php for browser path, headless flag, and timeout settings.npx playwright install chromium. Confirm the path Playwright reports matches what the config expects.https://example.com and echoes the title. If that fails, the problem is Node/browsers, not your Laravel code.First use case: a single JS-rendered page. Something like:
// Illustrative — confirm method names against the package source/README
$html = Salvager::browse('https://example.com/pricing')
->waitForSelector('.pricing-table')
->content();
Then graduate to a two-URL crawl before touching concurrency. The natural first real job is "fetch a listing page, extract detail-page links, fetch each detail page, store structured rows" — but do it sequentially first.
Where to look first: src/ (there is little of it), the service provider to see what's bound into the container, and the config file. The README will be terse; the class signatures are the real API reference.
Wrap it in a queued job, always. Never call Salvager from a controller or a request cycle. A browser launch is 1–3 seconds of overhead and tens of megabytes of RAM.
class ScrapeListing implements ShouldQueue
{
public int $tries = 3;
public int $timeout = 120;
public array $backoff = [30, 120];
public function __construct(public string $url) {}
public function handle(): void
{
$html = Salvager::browse($this->url)->content();
// parse, persist, dispatch child jobs for detail pages
}
}
One job per URL, with a visited-set. Keep a crawled_urls table (or a cache key per normalized URL) and guard at the top of the job. Deduplicate on a normalized form — strip fragments, sort query params — or you will re-crawl the same page dozens of times.
Separate "fetch" from "parse". Store raw HTML (or a screenshot) to storage/app/scrapes/{hash}.html and parse from that. When a selector breaks you can re-parse the archive instead of re-hitting the site. This is the single highest-value habit with browser-driven scraping.
Bound concurrency explicitly. Playwright is memory-hungry. If you run queue:work with 8 workers, you get 8 Chromium instances. Either run a dedicated worker pool for scraping with --max-jobs/low worker count, or use RateLimited/WithoutOverlapping middleware to serialize per host.
public function middleware(): array
{
return [(new RateLimited('scraping'))->dontRelease()];
}
Integration points that pay off:
Schedule::command('scrape:listings')->hourly()), not from user requests.ScrapedPage model, so the rest of the app never imports the Salvager namespace. This is your escape hatch if the package is abandoned.Notification/mail or an export (maatwebsite/excel) — that's where the business value lands.Testing: never let tests hit the real network. Point the crawler at a local fixture server (php -S on a fixture directory) serving saved HTML, or stub your own repository interface whose implementation wraps Salvager. Assert on parsing output, not on the crawler.
--no-sandbox (or a properly configured user namespace) in most containers. If you see "Failed to launch browser" with no further detail, this is the first thing to check.finally block. Aborted jobs leak Chromium processes; after a week you'll have hundreds. Monitor with ps aux | grep chrome after a deploy.waitForSelector/waitForLoadState rather than sleep(3). Fixed sleeps are simultaneously too slow and too short.robots.txt, add jitter between requests, and cache aggressively. Getting IP-blocked during development is a rite of passage; getting blocked in production is an incident."revolution/salvager": "1.0.*") and read the diff before bumping.PageFetcher contract; swapping to symfony/panther, chrome-php/chrome, or Browsershot later becomes a one-class change.headless: false), capture a screenshot on failure to storage/app/scrapes/failures/, and log the final URL after redirects — a surprising number of "selector not found" bugs are redirects to a consent or login page.How can I help you explore Laravel packages today?