- What a usable archive record needs
- Rendering the page to a document
- The snapshots table
- The capture job
- Do not fingerprint the PDF file
- Pages that are not ready on first paint
- Watching a list of pages
- Searching the archive
- What this proves, and what it does not
A supplier edits their terms page and you need to show what it said on the day you signed. A regulator asks for the version of your own policy that was live in March. A competitor quietly rewrites a pricing claim you referenced in a proposal. In each case you need the page as it existed on a date, and a screenshot pasted into Slack is not a record.
This article builds a page archive in Laravel. A queued job renders any URL to a PDF with a real text layer, stores it with a checksum, skips the capture when nothing has changed, and leaves you with an archive you can search years later.
What a usable archive record needs
Four things, and most homegrown attempts miss at least two.
The document itself, in a format that still opens in a decade. The extracted text, because a folder of 4,000 files you cannot search is a folder you will never use. The capture time, stored separately from the file's own metadata. And a fingerprint of the content, so you can tell one version from the next without opening anything.
That last pair is why PDF beats a PNG here. A screenshot is a picture of words. A PDF rendered from the live DOM carries a selectable text layer, which means you can extract it, hash it, index it and search it. The same file also prints at full quality if it ever has to go in front of someone in a meeting room.
Rendering the page to a document
The capture itself is one request. Send the URL to the URL to PDF API with the format set to pdf and you get back a URL to a finished document. The same endpoint family handles your own markup if you ever need to archive something you generate rather than something you fetch, which the HTML to PDF API page covers.
$response = Http::withHeaders(['X-API-Key' => config('services.html2img.key')])
->timeout(120)
->post('https://app.html2img.com/api/screenshot', [
'url' => 'https://supplier.example.com/terms',
'format' => 'pdf',
])
->throw()
->json();
// ['success' => true, 'id' => '82b4a0f9…', 'url' => 'https://i.html2img.com/….pdf']
Keep the id from that response. It is the renderer's own reference for the job, and it costs you a database column to store something that later proves which render produced which file.
The snapshots table
Schema::create('page_snapshots', function (Blueprint $table) {
$table->id();
$table->string('url');
$table->string('text_hash', 64);
$table->string('render_id')->nullable();
$table->string('pdf_path');
$table->unsignedInteger('pdf_bytes');
$table->longText('extracted_text');
$table->timestamp('captured_at');
$table->timestamps();
$table->index(['url', 'captured_at']);
$table->index('text_hash');
$table->fullText('extracted_text');
});
The fullText index is MySQL specific and is what makes whereFullText() work later. On Postgres, swap it for a tsvector column and a GIN index.
The capture job
Everything happens on a queue. Third party pages are slow, occasionally down, and never worth blocking a web request on.
namespace App\Jobs;
use App\Models\PageSnapshot;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Smalot\PdfParser\Parser;
class ArchivePage implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public array $backoff = [30, 120, 600];
public function __construct(public string $url)
{
}
public function handle(): void
{
$render = Http::withHeaders(['X-API-Key' => config('services.html2img.key')])
->timeout(120)
->post('https://app.html2img.com/api/screenshot', [
'url' => $this->url,
'format' => 'pdf',
'ms_delay' => 1500,
])
->throw()
->json();
$pdf = Http::timeout(60)->get($render['url'])->throw()->body();
$text = $this->extractText($pdf);
$hash = hash('sha256', $text);
$previous = PageSnapshot::where('url', $this->url)
->latest('captured_at')
->first();
if ($previous && $previous->text_hash === $hash) {
return;
}
$path = sprintf(
'archive/%s/%s.pdf',
hash('sha256', $this->url),
now()->format('Y-m-d-His')
);
Storage::disk('s3')->put($path, $pdf);
PageSnapshot::create([
'url' => $this->url,
'text_hash' => $hash,
'render_id' => $render['id'] ?? null,
'pdf_path' => $path,
'pdf_bytes' => strlen($pdf),
'extracted_text' => $text,
'captured_at' => now(),
]);
}
private function extractText(string $pdf): string
{
$text = (new Parser())->parseContent($pdf)->getText();
return trim(preg_replace('/\s+/', ' ', $text));
}
}
smalot/pdfparser is a pure PHP dependency, so there is no binary to install on the queue workers. If you already have poppler on the box, shelling out to pdftotext is faster on large documents.
Do not fingerprint the PDF file
The obvious move is to hash the PDF bytes and compare. It does not work, and it fails quietly, which is worse.
Render the same unchanged page twice and you get two files of identical length and different checksums. PDFs embed a creation timestamp:
$ pdfinfo first.pdf | grep CreationDate
CreationDate: Sat Jul 25 15:06:35 2026
$ pdfinfo second.pdf | grep CreationDate
CreationDate: Sat Jul 25 15:06:56 2026
Twenty one seconds apart, same page, same 20,726 bytes, different hash. If you dedupe on the file you will store a fresh copy of every page on every run and conclude that the entire internet changes daily.
Hash the extracted text instead. That is what the job above does, and it is why the text extraction happens before the storage write rather than after.
Pages that are not ready on first paint
Plenty of pages need a moment, or need something removed, before they represent what a person would see.
ms_delay buys a fixed wait. wait_for_selector is better when you know what you are waiting for, because it returns as soon as the element exists instead of always burning the full delay.
'wait_for_selector' => '#pricing-table',
Cookie banners are the other regular offender. They cover the content you came for and they are trivially removable, because the renderer accepts CSS alongside the request:
'css' => '#cookie-banner, .consent-overlay { display: none !important; }',
Be careful with that one. Hiding a consent overlay is reasonable housekeeping. Hiding a price disclaimer or a footnote is tampering with your own evidence, and the whole point of the archive is that it holds up when someone challenges it. Keep the CSS to chrome, never to content, and store the exact parameters you sent alongside the snapshot if the archive is ever going to matter legally.
Watching a list of pages
// routes/console.php
use App\Jobs\ArchivePage;
use Illuminate\Support\Facades\Schedule;
Schedule::call(function () {
foreach (config('archive.watchlist') as $url) {
ArchivePage::dispatch($url)->delay(now()->addSeconds(random_int(0, 600)));
}
})->weeklyOn(1, '03:00');
The random delay spreads the captures over ten minutes. Firing forty renders at the same instant is how you get rate limited by the target site and end up archiving forty copies of a 429 page.
Because the job discards unchanged captures, a weekly schedule over a year of a stable page leaves you with one row, not fifty two. Change frequency, not run frequency, decides how much you store.
Searching the archive
$hits = PageSnapshot::where('url', $url)
->whereFullText('extracted_text', 'termination for convenience')
->orderByDesc('captured_at')
->get(['id', 'captured_at', 'pdf_path']);
This is the payoff for extracting the text at capture time. You can answer "when did that clause appear" with a query rather than by opening files one at a time, and you can diff two snapshots straight from the stored text:
$diff = array_diff(
explode('. ', $newer->extracted_text),
explode('. ', $older->extracted_text)
);
What this proves, and what it does not
Be straight with yourself about the evidence value. You have generated a document, on your own infrastructure, with a timestamp you control. That is a strong record of your own process and it is enough for most commercial disputes, internal audit and "prove the page said this" conversations with a supplier.
It is not third party attestation. If a snapshot might end up in front of a court or a regulator, add something you do not control: an RFC 3161 trusted timestamp over the text hash, a copy submitted to a public web archive, or both. The hash column already gives you the value to submit, which is the useful half of the work.
Store the render id, the parameters you sent and the capture time next to every file. An archive nobody can audit is just a large folder.
Interested in proving your knowledge of this topic? Take the PHP Fundamentals certification.
PHP Fundamentals
Covering the required knowledge to create and build web applications in PHP.
$99