madeitbelgium/wappalyzer
madeitbelgium/wappalyzer is a Laravel/PHP wrapper for Wappalyzer that detects the technologies behind any website, including CMSs, frameworks, analytics, servers, and more. Use its simple API to identify tech stacks for audits, lead research, or competitive analysis.
Install the package via Composer. It is a Laravel package, so the service provider and Wappalyzer facade are auto-discovered on Laravel 5.5+:
composer require madeitbelgium/wappalyzer
Publish the config file so you can tune HTTP behaviour (timeout, user-agent, rules path) without touching vendor code:
php artisan vendor:publish --provider="MadeItBelgium\Wappalyzer\WappalyzerServiceProvider"
This drops a config/wappalyzer.php into your app. On older Laravel versions register MadeItBelgium\Wappalyzer\WappalyzerServiceProvider in config/app.php and add the facade alias manually.
First call — hand it a URL and get back the detected technologies:
use MadeItBelgium\Wappalyzer\Facades\Wappalyzer;
$technologies = Wappalyzer::analyze('https://example.com');
foreach ($technologies as $tech) {
// name, categories, and often version/confidence depending on release
dump($tech);
}
It also works outside Laravel by instantiating the underlying class directly:
$wappalyzer = new \MadeItBelgium\Wappalyzer\Wappalyzer();
$result = $wappalyzer->analyze('https://example.com');
Where to look first:
analyze, setUrl, result shape) has shifted between releases, so match it to your pinned version.config/wappalyzer.php — timeouts and user-agent matter more than you think for detection accuracy.technologies.json equivalent) — this file is the detector. Everything the package can recognise lives there, and it is the first thing you will want to override or refresh.tests/ — the fastest way to see the expected input/output contract without reading the ported JS logic.Canonical first use case: domain enrichment. You have a list of company domains (leads, signups, vendors) and you want to tag each with their stack — Shopify vs. WooCommerce, WordPress vs. Next.js, Cloudflare, GA4, HubSpot — then use those tags for segmentation, routing, or competitive reporting. A single analyze() call in a tinker session against your own site is the right smoke test before wiring it into a pipeline.
Wrap the facade in your own service. Don't sprinkle Wappalyzer::analyze() through controllers. Bind a TechnologyDetector interface to a class that wraps the package, so you can swap the engine (headless browser, third-party API) later and mock it in tests:
class WappalyzerDetector implements TechnologyDetector
{
public function detect(string $url): Collection
{
try {
return collect(Wappalyzer::analyze($url))
->map(fn ($t) => TechnologyData::fromArray($t));
} catch (\Throwable $e) {
report($e);
return collect();
}
}
}
Always scan in a queued job, one URL per job. Each scan is an outbound HTTP request plus a regex pass over a large rules file. Doing that inline in a web request with more than a handful of URLs will blow your response time and possibly your memory limit:
class DetectTechnologies implements ShouldQueue
{
public $tries = 3;
public $backoff = [10, 60, 300];
public function __construct(public int $companyId) {}
public function handle(TechnologyDetector $detector): void
{
$company = Company::findOrFail($this->companyId);
$techs = $detector->detect($company->normalized_domain);
$company->technologies()->sync(
$techs->mapWithKeys(fn ($t) => [$t->slug => [
'version' => $t->version,
'detected_at' => now(),
]])->all()
);
}
}
Normalise into relational tables, not a JSON blob. A technologies table (slug, name, categories) plus a company_technology pivot with version and detected_at lets you query "all leads on Shopify with Klaviyo" with plain Eloquent. Keep a raw JSON column too if you want the audit trail, but don't build reporting on it.
Cache aggressively by domain. Site stacks change on the order of months, not minutes. Cache the raw result for 7–30 days keyed on the registrable domain (strip www, lowercase, handle punycode). This turns a re-scan of your CRM into a cache read:
$techs = Cache::remember("wappalyzer:{$domain}", now()->addDays(14), fn () =>
Wappalyzer::analyze("https://{$domain}")
);
Reuse already-fetched HTML when you can. If your crawler or link-preview service already pulled the page, you're paying for the request twice. Where the installed version allows passing a body/headers instead of a URL, do it; otherwise fetch once with Guzzle and hand the same response to both the scanner and your own parsing.
Batch with a command and a rate limiter. For backfills, use chunkById() and Laravel's RateLimiter or a simple usleep() so you don't hammer targets (or get your IP banned). Emit progress so a stuck job is obvious:
Company::whereNull('scanned_at')->chunkById(100, function ($companies) {
foreach ($companies as $company) {
RateLimiter::attempt("scan:{$company->id}", 1, fn () =>
DetectTechnologies::dispatch($company->id)
, 1);
}
});
Emit a domain event on detection. A TechnologiesDetected event lets lead scoring, segmentation, and Slack notifications subscribe without coupling to the scanner. This is the extension point that keeps the package at arm's length.
Layer signals for a fuller profile. Technology detection is one column in a company profile. Pair it with DNS records, TLS certificate issuer, HTTP headers, and WHOIS — then a "is this a Shopify store on Cloudflare" check becomes a single query instead of several integrations.
Derive categories, not just names. Wappalyzer tags each technology with categories (ecommerce, CMS, analytics, CDN). Store those and you can build a "sales-ready ecommerce lead" segment without maintaining a hardcoded list of 400 product names.
timeout (5–10s) and a browser-like user-agent in config.filter_var($url, FILTER_VALIDATE_URL), resolve the host, and reject private/link-local ranges and metadata endpoints.wp-content string tags "WordPress"; a script present in a CDN bundle gets attributed to every site using it. False positives are common and versions are best-effort and frequently absent — never build logic that requires a version string.Wappalyzer is generic. Import MadeItBelgium\Wappalyzer\Facades\Wappalyzer explicitly in every file rather than relying on the global alias, or you'll fight another package for the name.composer update, diff your config/wappalyzer.php against the vendor default — new options (timeouts, rules path, user-agent) silently fall back to package defaults otherwise.composer.json, cover the wrapper with your own tests, and keep the TechnologyDetector abstraction so a replacement engine is a one-class swap rather than a refactor.How can I help you explore Laravel packages today?