codeguy/arachnid
Arachnid is a lightweight PHP web crawler and scraper with a simple, expressive API for fetching pages, following links, and extracting content. Use it to automate site navigation and data collection in Laravel or any PHP app, and chain requests and selectors to build custom spiders quickly.
Arachnid is a thin, synchronous crawler: one Crawler object, one URL, one adapter that fetches HTML, and a parser that pulls links and SEO fields out of the response. Before writing any integration code, read three things in the repo (in this order):
src/Codeguy/Arachnid/Crawler.php — constructor signature, traverse(), and getUniqueLinks(). This is the whole public API.src/Codeguy/Arachnid/Adapters/ — the adapter interface, the Guzzle adapter (default, static HTML), and the PhantomJS adapter (JS-rendered sites).Install and confirm dependency resolution early, because the package is old:
composer require codeguy/arachnid
Then the smallest useful call — crawl the homepage to depth 1 and dump the deduplicated internal links:
require 'vendor/autoload.php';
use Codeguy\Arachnid\Crawler;
$crawler = new Crawler('https://example.com');
$crawler->traverse(); // check Crawler.php for depth/limit args
foreach ($crawler->getUniqueLinks() as $url) {
echo $url, PHP_EOL;
}
First real use case: point it at a staging domain and eyeball getUniqueLinks() against your expected page inventory. That single run tells you whether the crawl scope, URL normalization, and depth handling match your expectations before you build a report on top of it.
Swap the adapter, keep the crawler. The adapter is the seam that lets one code path handle static and JS-rendered sites. Keep the adapter selection in one place (a factory, or a config flag) so the rest of your code never branches on "is this an SPA":
use Codeguy\Arachnid\Crawler;
use Codeguy\Arachnid\Adapters\PhantomJS;
$crawler = new Crawler(
$baseUrl,
$site->is_spa ? new PhantomJS() : null // null = default Guzzle adapter
);
Audit as an Artisan command. For day-to-day use, wrap the crawl in a command with a progress bar and a CSV/JSON writer. Persist one row per page with the extracted SEO fields, then diff runs to spot regressions (missing titles, duplicate meta descriptions, canonical drift):
$crawler = new Crawler($baseUrl);
$crawler->traverse();
$rows = [];
foreach ($crawler->getUniqueLinks() as $url) {
$rows[] = ['url' => $url] + /* per-page SEO fields from Crawler */ [];
}
Storage::put('audits/'.now()->format('Y-m-d').'.csv', csv($rows));
One queued job per site, not one per page. The crawler is blocking and holds everything in memory, so isolate each audit in a job with $timeout and $tries set, and raise memory_limit if you crawl more than a few hundred pages. The scheduler (->weekly()) turns this into a recurring SEO regression check for free.
Fixture-based tests via a fake adapter. Implement the adapter interface to return canned HTML from tests/fixtures/*.html. You get deterministic tests for link extraction and SEO parsing without hitting the network, and it is the only practical way to unit-test crawler behaviour.
Link inventory / orphan-page detection. Export getUniqueLinks() and cross-reference against sitemap.xml. URLs in the sitemap and absent from the crawl are either orphaned or blocked; URLs in the crawl and absent from the sitemap are crawl-budget leaks. This is the highest-value, lowest-effort use of the package.
Normalize before you dedupe. Post-process links through a normalizer (lowercase host, strip fragments and tracking params, resolve relative URLs, unify trailing slash and http/https) and feed the normalized base URL into the constructor. Arachnid dedupes what it is given; it does not know your canonical policy.
Persistence shape. Store url, status, title, meta_description, canonical, h1_count, word_count, crawled_at. That schema supports duplicate-title reports, missing-meta reports, and thin-content reports without re-crawling.
composer.json before committing; either pin an isolated service, fork and bump constraints, or use it only in a CLI tool separate from the app's dependency tree.traverse() is blocking and unbounded by default. Always set a max depth and a page cap. Without one, pagination, calendars, and session-ID URLs produce effectively infinite crawls that exhaust memory before they finish.usleep() between requests and honor robots.txt, and set a descriptive User-Agent — otherwise you will get 403'd halfway through a run.hrefs, the same-domain filter fails and the crawl stops early. Confirm links are resolved to absolute URLs; if not, resolve them yourself against the page URL and handle <base href>.http vs https, www vs apex, trailing slash, and ?utm_source= are all distinct strings to the crawler. Pick a canonical base URL and normalize the collected links.title/meta come back blank via the JS adapter, the render did not wait for the app to mount — add a wait or switch to a headless-Chrome-based fetcher.Crawler is for changing which links are followed or how dedupe works. There is no plugin system, no config file, and no event hooks — everything is constructor arguments and overrides, so keep your customizations in one small, well-documented subclass.How can I help you explore Laravel packages today?