How to download a PDF with no direct URL in Laravel

The document is on screen, inside an embedded viewer, and there is no link to right click. The network tab shows a request that returns the PDF, so you copy that URL, paste it into a fresh tab, and get an error page.

That is not you doing it wrong. The URL is frequently single use: bound to a session, a token, or a form post you did not make.

Capture the response instead of guessing the URL

The reliable move is to stop looking for a URL and take the file from the response the page produces:

use EduLazaro\Larascraper\Scraper;

class ReportScraper extends Scraper
{
    protected function handle(string $pageUrl): array
    {
        $file = $this->scrape($pageUrl)
            ->click('a.download-pdf')
            ->capture('application/pdf')
            ->file();

        $file->save(storage_path('app/report.pdf'));

        return ['bytes' => $file->size()];
    }
}

capture($expect) watches the file-like responses the page produces during the run and keeps the one that matches. $expect is a content-type substring, and PDFs also match on their %PDF magic bytes, which matters because plenty of servers send application/octet-stream for a PDF. With no argument it takes the first file-like response it sees.

->file() is the terminal: it runs the chain and hands back a CapturedFile, or raises a no_file scrape failure when nothing was captured. That failure is folded into the response as success = false, not thrown, so the caller branches rather than catches.

The one limitation to know up front

A forced download is not captured. If the server sends Content-Disposition: attachment, Chrome hands it to the download manager and the page never sees a response to intercept. What capture() grabs are files that render inline, which is exactly the viewer case this article is about.

If you hit that, the file usually does have a fetchable URL after all, and the HTTP driver is the right tool.

From a form, not a link

Often the document only comes back from submitting a form, because the parameters live in hidden fields and a token:

->submit('form')->capture('application/pdf')->file()

Submit and capture compose, which is the point. There used to be a single submitAndCapture() call and it is deprecated: two verbs that each do one thing combine with everything else in the chain, and one verb that does both does not.

When the viewer hides the URL in an attribute

Some viewers are an <object> or <embed> whose data or src attribute holds the real document URL, generated per session. There is no link to click and no form to submit.

->gotoAttr('object[type*="pdf"], embed[type*="pdf"]', 'data')

gotoAttr() reads the attribute off the live page and navigates to whatever is in it, resolved against the current page. You never have to know the URL, which is good, because you could not have known it.

Behind a captcha, which is where this gets real

Public records portals stack all of it: a viewer page, a per-session document URL, and a captcha that regenerates every time you touch it. Each attempt has to start from clean server state.

use EduLazaro\Larascraper\Support\Condition;

$file = $this->scrape($viewerUrl)
    ->repeatUntil(
        Condition::captured(),
        fn ($b) => $b
            ->visit($viewerUrl)
            ->gotoAttr('object[type*="pdf"], embed[type*="pdf"]', 'data')
            ->when(
                Condition::selectorExists('img[src*="captcha"]'),
                fn ($c) => $c->solveCaptcha('img[src*="captcha"]', 'input[name=captcha]'),
            )
            ->submit('form')
            ->capture('application/pdf'),
        max: 8,
        delay: 400,
    )
    ->file();

Four things in there earn their place:

Condition::captured() is true the moment a file is grabbed, so the loop stops on success rather than running a fixed number of times.

visit($viewerUrl) at the top of the body. Each attempt returns to the viewer, so the server issues a fresh token and a fresh captcha. Without it you retry against state you already burned.

when() around the captcha. Portals show it conditionally. An unguarded solveCaptcha() on a page without one is a failed action, which fails the fetch, which kills a loop that was working.

A failed attempt is not a failed run. If the body throws mid iteration, because gotoAttr() found no viewer on a transient page, that counts as one attempt: the loop re-checks the condition and goes again, bounded by max. Only when every attempt is spent and the condition still never held does the last error surface. That is what makes a loop that re-navigates each pass survivable.

Navigation waits, and the one that hangs

visit(), gotoAttr() and reload() take a Puppeteer wait condition, defaulting to networkidle2.

That default is right for most pages and catastrophic for a few. Some servers hold connections open and never reach network idle, so networkidle2 burns the entire timeout on a page that finished rendering seconds ago. Document viewers are unusually prone to it, because they stream.

The fix is to stop waiting on the network and wait on the content instead:

->visit($url, 'domcontentloaded')
->waitForSelector('object[type*="pdf"]')

If a scraper is timing out on a page that looks instant in your browser, this is almost always why.

Then read it

Once you hold a CapturedFile, getting text out is a separate problem with its own trap, namely that a scanned document returns an empty string and nothing throws. That is how to extract text from a scanned PDF.

And if the file does turn out to live at a plain URL, skip the browser entirely: the HTTP driver exposes binaries through the same ->file() terminal, at a fraction of the cost and with concurrency available.

More on capture, the terminals and what each driver refuses to do is in the files and PDF chapter.

written by Edu Lazaro · August 2026