Detect Any Website's Tech Stack in PHP with the StackPeek API
PHP still powers over 75% of the web. If you are building lead qualification tools in Laravel, competitive intelligence dashboards in Symfony, or agency tools on vanilla PHP, you already have a runtime capable of programmatic technology detection. No headless browser, no Node.js sidecar, no $250/month Wappalyzer bill. This guide covers every practical PHP integration pattern for the StackPeek API: native cURL, Guzzle HTTP client, a Laravel Artisan command for batch scanning, MySQL/PostgreSQL storage for competitive intelligence, Redis caching, and a simple web UI to display results—all with complete, copy-paste-ready code.
- Simple PHP script: cURL for single URL detection
- Guzzle HTTP client with async batch scanning
- Laravel Artisan command for batch tech stack detection
- Storing results in MySQL/PostgreSQL for competitive intelligence
- Caching with Redis to avoid redundant API calls
- Building a simple PHP web UI to display results
- StackPeek vs Wappalyzer: pricing and feature comparison
- Pricing and rate limits
- FAQ
1. Simple PHP Script: cURL for Single URL Detection
The StackPeek API is a single REST endpoint. You send a GET request with a url query parameter, and it returns a JSON object containing a technologies array. Each technology has a name, category, and confidence score. No API key is required for the free tier (100 scans/day).
Here is the simplest possible detection script using PHP's built-in cURL extension—no Composer packages needed:
<?php
declare(strict_types=1);
// StackPeek API endpoint
$apiUrl = 'https://us-central1-todd-agent-prod.cloudfunctions.net/stackpeekApi/api/v1/detect';
$targetUrl = 'https://shopify.com';
$ch = curl_init($apiUrl . '?' . http_build_query(['url' => $targetUrl]));
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_HTTPHEADER => ['Accept: application/json'],
]);
$body = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($body === false) {
die("cURL error: {$error}\n");
}
if ($httpCode !== 200) {
die("API returned HTTP {$httpCode}\n");
}
$data = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
echo "Technologies detected for {$data['url']}:\n\n";
foreach ($data['technologies'] as $tech) {
echo sprintf(
" %-20s %-15s confidence: %d%%\n",
$tech['name'],
"({$tech['category']})",
$tech['confidence'] * 100
);
}
Save this as detect.php and run it with php detect.php. The output looks like this:
Technologies detected for https://shopify.com:
React (framework) confidence: 97%
Next.js (framework) confidence: 94%
Cloudflare (cdn) confidence: 99%
Google Analytics (analytics) confidence: 88%
Stripe (payments) confidence: 91%
The response JSON structure is straightforward:
{
"url": "https://shopify.com",
"technologies": [
{"name": "React", "category": "framework", "confidence": 0.97},
{"name": "Next.js", "category": "framework", "confidence": 0.94},
{"name": "Cloudflare", "category": "cdn", "confidence": 0.99},
{"name": "Google Analytics", "category": "analytics", "confidence": 0.88},
{"name": "Stripe", "category": "payments", "confidence": 0.91}
]
}
Tip: Wrap the API call in a reusable class (e.g., App\Services\StackPeekClient) so that your scripts, Artisan commands, and web controllers all share the same client code. This also makes it easy to swap in your API key later when you upgrade to a paid plan.
2. Guzzle HTTP Client with Async Batch Scanning
If your project uses Composer (and it should), Guzzle gives you retries, connection pooling, and async support. Install it with composer require guzzlehttp/guzzle.
Here is a complete, reusable service class with both single and batch detection:
<?php
declare(strict_types=1);
namespace App\Services;
use GuzzleHttp\Client;
use GuzzleHttp\Promise\Utils;
use GuzzleHttp\Exception\GuzzleException;
class StackPeekClient
{
private Client $http;
private const BASE_URL = 'https://us-central1-todd-agent-prod.cloudfunctions.net/stackpeekApi';
public function __construct(?Client $http = null)
{
$this->http = $http ?? new Client([
'base_uri' => self::BASE_URL,
'timeout' => 15,
'headers' => ['Accept' => 'application/json'],
]);
}
/**
* Detect technologies for a single URL.
*
* @return array{url: string, technologies: array}
* @throws GuzzleException
*/
public function detect(string $url): array
{
$response = $this->http->get('/api/v1/detect', [
'query' => ['url' => $url],
]);
return json_decode(
$response->getBody()->getContents(),
true,
512,
JSON_THROW_ON_ERROR
);
}
/**
* Scan multiple URLs concurrently using Guzzle's promise pool.
*
* @param string[] $urls
* @return array<string, array> keyed by URL
*/
public function detectBatch(array $urls, int $concurrency = 5): array
{
$promises = [];
foreach ($urls as $url) {
$promises[$url] = $this->http->getAsync('/api/v1/detect', [
'query' => ['url' => $url],
]);
}
$results = Utils::settle($promises)->wait();
$output = [];
foreach ($results as $url => $result) {
if ($result['state'] === 'fulfilled') {
$output[$url] = json_decode(
$result['value']->getBody()->getContents(),
true
);
} else {
$output[$url] = [
'technologies' => [],
'error' => $result['reason']->getMessage(),
];
}
}
return $output;
}
}
// Usage:
$client = new StackPeekClient();
// Single URL
$result = $client->detect('https://vercel.com');
foreach ($result['technologies'] as $tech) {
echo "{$tech['name']} ({$tech['category']})\n";
}
// Batch scan 5 URLs concurrently
$batch = $client->detectBatch([
'https://stripe.com',
'https://linear.app',
'https://notion.so',
'https://figma.com',
'https://github.com',
]);
foreach ($batch as $url => $data) {
$names = array_column($data['technologies'] ?? [], 'name');
echo "{$url}: " . implode(', ', $names) . "\n";
}
The detectBatch() method fires all requests concurrently. For a list of 100 URLs, this cuts wall-clock time from several minutes down to about 30 seconds. Adjust the $concurrency parameter based on your API plan limits.
Free tier: 100 scans per day, no API key, no account required. For higher volumes, the Starter plan at $9/month gives you 5,000 scans. That is 28x cheaper than Wappalyzer's $250/month plan.
3. Laravel Artisan Command for Batch Tech Stack Detection
Laravel makes CLI tooling effortless. Here is a complete Artisan command that reads a list of competitor domains from a text file, scans each one through the StackPeek API, caches results with Laravel's cache system, and stores everything in your database for competitive intelligence tracking.
Create the file at app/Console/Commands/ScanCompetitorStacks.php:
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
class ScanCompetitorStacks extends Command
{
protected $signature = 'stackpeek:scan
{file : Path to a text file with one domain per line}
{--concurrency=5 : Number of concurrent requests}
{--cache-hours=24 : Hours to cache results}';
protected $description = 'Scan competitor websites for tech stack data via StackPeek API';
private const API_URL = 'https://us-central1-todd-agent-prod.cloudfunctions.net/stackpeekApi/api/v1/detect';
public function handle(): int
{
$file = $this->argument('file');
$cacheHours = (int) $this->option('cache-hours');
if (!file_exists($file)) {
$this->error("File not found: {$file}");
return self::FAILURE;
}
// Read domains, skip comments and blanks
$domains = array_filter(
array_map('trim', file($file)),
fn($line) => $line !== '' && !str_starts_with($line, '#')
);
$this->info("Scanning " . count($domains) . " domains...");
$bar = $this->output->createProgressBar(count($domains));
$bar->start();
$results = [];
foreach ($domains as $domain) {
$cacheKey = "stackpeek:{$domain}";
$data = Cache::remember(
$cacheKey,
now()->addHours($cacheHours),
function () use ($domain) {
$url = str_starts_with($domain, 'http')
? $domain
: "https://{$domain}";
try {
$response = Http::timeout(15)
->get(self::API_URL, ['url' => $url]);
return $response->successful()
? $response->json()
: ['technologies' => []];
} catch (\Throwable $e) {
return [
'technologies' => [],
'error' => $e->getMessage(),
];
}
}
);
// Store in database
DB::table('competitor_stacks')->updateOrInsert(
['domain' => $domain],
[
'technologies' => json_encode($data['technologies'] ?? []),
'tech_names' => implode('|', array_column(
$data['technologies'] ?? [], 'name'
)),
'tech_count' => count($data['technologies'] ?? []),
'scanned_at' => now(),
]
);
$results[] = [
'domain' => $domain,
'count' => count($data['technologies'] ?? []),
'techs' => implode(', ', array_column(
$data['technologies'] ?? [], 'name'
)),
];
$bar->advance();
usleep(300000); // 300ms delay between requests
}
$bar->finish();
$this->newLine(2);
$this->table(['Domain', 'Technologies', 'Stack'], $results);
$this->info('Done. Results stored in competitor_stacks table.');
return self::SUCCESS;
}
}
Run it like this:
# Scan a list of competitors
php artisan stackpeek:scan competitors.txt
# Cache results for 48 hours
php artisan stackpeek:scan competitors.txt --cache-hours=48
# Schedule as a weekly cron job in app/Console/Kernel.php:
$schedule->command('stackpeek:scan', ['competitors.txt'])
->weeklyOn(1, '06:00');
CI/CD integration: Add php artisan stackpeek:scan watchlist.txt to your deployment pipeline to detect when a competitor changes their stack. The command exits with a non-zero code on failures, which can trigger alerts.
4. Storing Results in MySQL/PostgreSQL for Competitive Intelligence
Running scans is only half the picture. To track technology adoption over time, catch competitor stack changes, and build competitive intelligence dashboards, you need to store and query the results. Here is the Laravel migration and an Eloquent model for the competitor_stacks table.
Create the migration with php artisan make:migration create_competitor_stacks_table:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('competitor_stacks', function (Blueprint $table) {
$table->id();
$table->string('domain')->unique();
$table->json('technologies');
$table->string('tech_names', 1000)->nullable();
$table->unsignedInteger('tech_count')->default(0);
$table->timestamp('scanned_at')->nullable();
$table->timestamps();
$table->index('tech_count');
$table->fullText('tech_names'); // MySQL full-text search
});
// History table for tracking changes over time
Schema::create('stack_snapshots', function (Blueprint $table) {
$table->id();
$table->string('domain');
$table->json('technologies');
$table->unsignedInteger('tech_count')->default(0);
$table->timestamp('scanned_at');
$table->index(['domain', 'scanned_at']);
});
}
public function down(): void
{
Schema::dropIfExists('stack_snapshots');
Schema::dropIfExists('competitor_stacks');
}
};
Now create the Eloquent model at app/Models/CompetitorStack.php:
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class CompetitorStack extends Model
{
protected $fillable = [
'domain', 'technologies', 'tech_names',
'tech_count', 'scanned_at',
];
protected $casts = [
'technologies' => 'array',
'scanned_at' => 'datetime',
];
/**
* Find all competitors using a specific technology.
*/
public function scopeUsingTech($query, string $tech)
{
return $query->where('tech_names', 'LIKE', "%{$tech}%");
}
/**
* Get all unique technologies across all competitors.
*/
public static function allTechnologies(): array
{
return self::query()
->whereNotNull('tech_names')
->pluck('tech_names')
->flatMap(fn($names) => explode('|', $names))
->unique()
->sort()
->values()
->toArray();
}
}
// Usage in a controller or Tinker:
// All competitors running React:
$reactSites = CompetitorStack::usingTech('React')->get();
// Competitors with the largest stacks:
$complex = CompetitorStack::orderByDesc('tech_count')->take(10)->get();
// All unique technologies you have seen:
$allTech = CompetitorStack::allTechnologies();
With the stack_snapshots table, you can write a scheduled job that copies the current row into a snapshot before each rescan, giving you a full history of every competitor's stack changes over weeks and months. This is the foundation of a competitive intelligence dashboard.
5. Caching with Redis to Avoid Redundant API Calls
If you are scanning the same domains repeatedly—in a SaaS application, a lead scoring pipeline, or a monitoring dashboard—you must cache. Technology stacks do not change hourly. A 24–72 hour cache TTL is aggressive enough to catch meaningful changes while keeping API usage low.
Vanilla PHP with the Redis Extension
<?php
declare(strict_types=1);
namespace App\Cache;
class CachedStackPeekClient
{
private \Redis $redis;
private int $ttl;
private const API_URL = 'https://us-central1-todd-agent-prod.cloudfunctions.net/stackpeekApi/api/v1/detect';
public function __construct(
string $redisHost = '127.0.0.1',
int $redisPort = 6379,
int $ttlSeconds = 86400 // 24 hours
) {
$this->ttl = $ttlSeconds;
$this->redis = new \Redis();
$this->redis->connect($redisHost, $redisPort);
}
public function detect(string $url): array
{
// Normalize to domain for cache key
$domain = parse_url($url, PHP_URL_HOST) ?? $url;
$cacheKey = "stackpeek:v1:{$domain}";
// Check cache first
$cached = $this->redis->get($cacheKey);
if ($cached !== false) {
return json_decode($cached, true);
}
// Cache miss — call the API
$endpoint = self::API_URL . '?' . http_build_query(['url' => $url]);
$ch = curl_init($endpoint);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => ['Accept: application/json'],
]);
$body = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($body === false || $code !== 200) {
return ['technologies' => [], 'error' => "HTTP {$code}"];
}
$result = json_decode($body, true);
// Only cache successful results
if (!empty($result['technologies'])) {
$this->redis->setex($cacheKey, $this->ttl, json_encode($result));
}
return $result;
}
/**
* Batch check: use MGET to find cached entries, only scan uncached.
*/
public function detectBatch(array $urls): array
{
$results = [];
$keys = [];
$urlByKey = [];
foreach ($urls as $url) {
$domain = parse_url($url, PHP_URL_HOST) ?? $url;
$key = "stackpeek:v1:{$domain}";
$keys[] = $key;
$urlByKey[$key] = $url;
}
// Batch cache lookup
$cached = $this->redis->mGet($keys);
$uncached = [];
foreach ($keys as $i => $key) {
if ($cached[$i] !== false) {
$results[$urlByKey[$key]] = json_decode($cached[$i], true);
} else {
$uncached[] = $urlByKey[$key];
}
}
// Scan only cache misses
foreach ($uncached as $url) {
$results[$url] = $this->detect($url);
usleep(300000); // 300ms courtesy delay
}
return $results;
}
public function getCacheStats(): array
{
$keys = $this->redis->keys('stackpeek:v1:*');
return [
'cached_domains' => count($keys),
'memory_used' => $this->redis->info('memory')['used_memory_human'],
];
}
}
Laravel Cache (works with Redis, Memcached, or file driver)
If you are already using Laravel, you do not need the raw Redis class. Laravel's cache facade works with any backend:
<?php
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
function detectWithCache(string $url, int $ttlHours = 24): array
{
$domain = parse_url($url, PHP_URL_HOST) ?? $url;
return Cache::remember("stackpeek:{$domain}", now()->addHours($ttlHours), function () use ($url) {
$response = Http::timeout(15)->get(
'https://us-central1-todd-agent-prod.cloudfunctions.net/stackpeekApi/api/v1/detect',
['url' => $url]
);
return $response->successful() ? $response->json() : ['technologies' => []];
});
}
// First call hits the API, subsequent calls serve from cache:
$result = detectWithCache('https://stripe.com');
$result = detectWithCache('https://stripe.com'); // instant, from Redis
Cache math: With 24-hour TTL and 500 unique domains, you use at most 500 API calls per day to keep the entire cache warm. On the Starter plan ($9/month for 5,000 scans), that leaves headroom for 4,500 new domain scans per month. Without caching, scanning 500 domains hourly would burn 12,000 calls per day—far exceeding any affordable plan.
6. Building a Simple PHP Web UI to Display Results
A quick internal tool for your team: a single PHP file that lets anyone paste a URL, scan it, and see the detected technologies in a clean table. No framework required.
<?php
// tech-scanner.php — Serve with PHP's built-in server:
// php -S localhost:8080 tech-scanner.php
$apiUrl = 'https://us-central1-todd-agent-prod.cloudfunctions.net/stackpeekApi/api/v1/detect';
$technologies = [];
$scannedUrl = '';
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST' && !empty($_POST['url'])) {
$scannedUrl = filter_var($_POST['url'], FILTER_SANITIZE_URL);
$ch = curl_init($apiUrl . '?' . http_build_query(['url' => $scannedUrl]));
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => ['Accept: application/json'],
]);
$body = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($body !== false && $code === 200) {
$data = json_decode($body, true);
$technologies = $data['technologies'] ?? [];
} else {
$error = "API returned HTTP {$code}";
}
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Tech Stack Scanner — StackPeek</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 700px;
margin: 60px auto; padding: 0 20px; background: #0a0a0f; color: #eee; }
h1 { font-size: 24px; margin-bottom: 24px; }
form { display: flex; gap: 8px; margin-bottom: 32px; }
input[type="url"] {
flex: 1; padding: 12px 16px; border-radius: 8px; border: 1px solid #333;
background: #111; color: #eee; font-size: 15px;
}
button {
padding: 12px 24px; border-radius: 8px; border: none;
background: #22D3EE; color: #000; font-weight: 600; cursor: pointer;
}
button:hover { background: #67E8F9; }
table { width: 100%; border-collapse: collapse; }
th { text-align: left; padding: 10px; color: #888; font-size: 12px;
text-transform: uppercase; border-bottom: 1px solid #222; }
td { padding: 10px; border-bottom: 1px solid #1a1a1f; }
.high { color: #22D3EE; } .med { color: #FBBF24; } .low { color: #EF4444; }
.error { color: #EF4444; }
.powered { text-align: center; margin-top: 40px; font-size: 13px; color: #555; }
.powered a { color: #22D3EE; }
</style>
</head>
<body>
<h1>Tech Stack Scanner</h1>
<form method="post">
<input type="url" name="url" value="<?= htmlspecialchars($scannedUrl) ?>"
placeholder="https://example.com" required>
<button type="submit">Scan</button>
</form>
<?php if ($error): ?>
<p class="error"><?= htmlspecialchars($error) ?></p>
<?php endif; ?>
<?php if (!empty($technologies)): ?>
<p>Found <strong><?= count($technologies) ?></strong> technologies
on <strong><?= htmlspecialchars($scannedUrl) ?></strong></p>
<table>
<thead><tr><th>Technology</th><th>Category</th><th>Confidence</th></tr></thead>
<tbody>
<?php foreach ($technologies as $tech):
$pct = $tech['confidence'] * 100;
$cls = $pct >= 80 ? 'high' : ($pct >= 50 ? 'med' : 'low');
?>
<tr>
<td><strong><?= htmlspecialchars($tech['name']) ?></strong></td>
<td><?= htmlspecialchars($tech['category']) ?></td>
<td class="<?= $cls ?>"><?= round($pct) ?>%</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<p class="powered">Powered by <a href="https://stackpeek.web.app">StackPeek API</a></p>
</body>
</html>
Run it with PHP's built-in server: php -S localhost:8080 tech-scanner.php. Open your browser, paste a URL, hit Scan, and you get a dark-themed table showing every detected technology, its category, and a confidence percentage color-coded by strength. This works as an internal agency tool, a client demo, or a starting point for a SaaS feature.
7. StackPeek vs Wappalyzer: Pricing and Feature Comparison
If you have searched for “PHP website technology API” or “Wappalyzer alternative PHP,” you have probably considered Wappalyzer's API. Here is the honest comparison for PHP developers:
| Feature | StackPeek API | Wappalyzer API |
|---|---|---|
| Monthly price (entry paid) | $9/mo (Starter) | $250/mo (Business) |
| Free tier | 100 scans/day, no API key | 50 lookups/month |
| Monthly lookups (paid) | 5,000 (Starter) / 25,000 (Pro) | 5,000 (Business) |
| Annual cost | $108/yr (Starter) | $3,000/yr (Business) |
| Technologies detected | 120+ (core categories) | 1,200+ |
| API key required | No (free tier) | Yes (all tiers) |
| Works with cURL / Guzzle | Yes, standard REST | Yes, standard REST |
| Response format | Clean JSON with confidence scores | JSON |
| PHP integration effort | Single GET request, json_decode() | GET request + API key header |
Wappalyzer detects more niche technologies. If you need to identify obscure server-side language variants with high precision, their breadth is valuable. But for the core use cases that matter in PHP applications—identifying frameworks, CMS platforms, analytics, payments, CDNs, and hosting—StackPeek's 120+ technology coverage handles the vast majority of commercial scenarios, at $2,892 less per year.
The cost difference is not marginal. $250/month is $3,000/year. $9/month is $108/year. For an indie developer, a small agency, or a startup building tech detection into their product, the difference between $108 and $3,000 is the difference between “ship it” and “put it on the backlog.”
8. Pricing and Rate Limits
StackPeek offers three tiers, all using the same API endpoint:
| Plan | Scans | Price | Best for |
|---|---|---|---|
| Free | 100 / day | $0 | Solo devs, prototyping, internal tools |
| Starter | 5,000 / month | $9/mo | Small agencies, lead qualification, weekly competitor scans |
| Pro | 25,000 / month | $29/mo | SaaS products, daily bulk scans, competitive intelligence platforms |
For the web UI example (internal tool), the free tier is more than enough. For a Laravel Artisan command scanning 200 competitors weekly, Starter covers you. If you are building tech detection into a SaaS product that scans on behalf of users, Pro is the right fit at $29/month—that is $0.00116 per scan.
Start Detecting Tech Stacks from PHP
100 free scans per day. No API key required to get started.
Drop the cURL snippet into your next PHP project in under 5 minutes.
Frequently Asked Questions
How do I detect a website's tech stack using PHP?
Use PHP's built-in curl_init() or the Guzzle HTTP client to send a GET request to the StackPeek API endpoint at https://us-central1-todd-agent-prod.cloudfunctions.net/stackpeekApi/api/v1/detect?url=TARGET. The API returns a JSON object with a technologies array. Each entry has name, category, and confidence fields. Parse the response with json_decode($body, true). No API key is needed for the free tier (100 scans/day).
Is there a cheaper Wappalyzer alternative for PHP projects?
Yes. StackPeek's Starter plan costs $9/month for 5,000 scans. Wappalyzer's cheapest paid plan is $250/month. That makes StackPeek 28x cheaper for the same number of monthly lookups. StackPeek also has a more generous free tier: 100 scans per day (3,000/month) versus Wappalyzer's 50 per month. Both APIs return JSON and work with any PHP HTTP client.
Can I use the StackPeek API in Laravel without installing any packages?
Yes. Laravel ships with an HTTP client built on Guzzle (Illuminate\Support\Facades\Http). You do not need to install anything beyond a standard Laravel application. Call Http::get($apiUrl, ['url' => $target]) and you get back a response object with a ->json() method. The Artisan command example in this guide uses only built-in Laravel facades.
How should I handle API errors and timeouts in production PHP?
Always set a timeout (15 seconds is a good default) and wrap API calls in a try/catch block. For production systems, implement a circuit breaker pattern: after 3 consecutive failures, stop calling the API for 60 seconds and return cached or empty results. Guzzle's retry middleware makes this straightforward. Never let a failed tech detection call crash your application or block a user-facing request.
What is the best caching strategy for tech stack detection results?
Cache by domain (not full URL) with a TTL of 24–72 hours. Technology stacks change infrequently. Use Redis for multi-server deployments, APCu for single-server setups, or Laravel's Cache::remember() if you want backend-agnostic caching. For batch operations, use Redis MGET to check multiple keys in a single round-trip. This keeps latency low and API usage predictable.