Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Referrer Spam Blacklist Laravel Package

piwik/referrer-spam-blacklist

A Composer package providing a regularly updated list of referrer spam domains in plain text, plus a small PHP builder that converts it to JSON. Use it to filter fake traffic in analytics, block spam referrers, or feed your own blacklist.

View on GitHub
Deep Wiki
Context7

Getting Started

1. Install the data, not a library.

composer require piwik/referrer-spam-blacklist

There is no service provider, no facade, no config file, and no autoloaded PHP classes. This package is a data shipment — a plain-text file of hostnames at the package root. Everything below the install step is code you write.

2. Look in the vendor directory first. Before writing anything, confirm the file name and shape:

ls vendor/piwik/referrer-spam-blacklist/
head -20 vendor/piwik/referrer-spam-blacklist/referrers.txt
wc -l vendor/piwik/referrer-spam-blacklist/referrers.txt

You'll see one hostname per line, lowercase, occasionally with a www. prefix, occasionally blank lines or comments. Treat the observed format as the contract — the package makes no schema guarantees, so parse defensively and add a fixture test so a format change fails loudly in CI rather than silently disabling your filter.

3. Resolve the path through Composer, not a hardcoded string. Vendor paths move (vendor/ vs build/vendor/, monorepo hoisting), so read it from the installed package metadata:

use Composer\InstalledVersions;

$path = InstalledVersions::getInstallPath('piwik/referrer-spam-blacklist')
    . '/referrers.txt';

4. First use case: stop junk form submissions from known spam referrers. Wrap the public, unauthenticated endpoints (contact form, guest comment, newsletter signup, public review) in a middleware that inspects the Referer header and rejects known spam hosts. This is the 20-minute version: it needs no database, no schema, no queue, and adds zero friction for real users (unlike a CAPTCHA interstitial).

// app/Http/Middleware/BlockSpamReferrers.php
public function handle(Request $request, Closure $next)
{
    $referer = $request->headers->get('referer');

    if ($referer && ReferrerSpamList::isSpam($referer)) {
        Log::channel('spam')->info('blocked', [
            'referer' => $referer,
            'ip' => $request->ip(),
            'path' => $request->path(),
        ]);

        abort(403);
    }

    return $next($request);
}

Apply it selectively — ->middleware(BlockSpamReferrers::class) on a route group, not globally.

Implementation Patterns

Central service with a cached, normalized set. Reading and parsing a few-thousand-line file on every request is unnecessary I/O. Register the parser as a singleton and cache the parsed result, keyed on the file mtime so composer update invalidates it automatically.

// app/Support/ReferrerSpamList.php
final class ReferrerSpamList
{
    public function __construct(private string $path) {}

    /** @return array<string,true> flipped set for O(1) lookups */
    public function domains(): array
    {
        $key = 'referrer-spam:' . md5($this->path) . ':' . @filemtime($this->path);

        return Cache::rememberForever($key, function () {
            $lines = @file($this->path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];

            $set = [];
            foreach ($lines as $line) {
                $line = trim($line);
                if ($line === '' || str_starts_with($line, '#')) {
                    continue;
                }
                $line = strtolower(rtrim($line, '.'));
                $set[$line] = true;
                // spam hosts often appear with a www. prefix in the wild
                $set[preg_replace('/^www\./', '', $line)] = true;
            }

            return $set;
        });
    }

    public function isSpam(string $urlOrHost): bool
    {
        $host = parse_url($urlOrHost, PHP_URL_HOST) ?: $urlOrHost;
        $host = strtolower(rtrim($host, '.'));

        if ($host === '' || filter_var($host, FILTER_VALIDATE_IP)) {
            return false; // never judge raw IPs from this list
        }

        $set = $this->domains();

        // walk up the label chain: a.b.spam.com -> b.spam.com -> spam.com
        do {
            if (isset($set[$host])) {
                return true;
            }
        } while (($pos = strpos($host, '.')) !== false && ($host = substr($host, $pos + 1)) !== '');

        return false;
    }
}

Bind it in a AppServiceProvider with a configurable path so tests can point at a fixture:

$this->app->singleton(ReferrerSpamList::class, fn () => new ReferrerSpamList(
    config('spam.referrer_list_path')
));

Always allow an override list. Ship config/spam.php with allow and block arrays and check allow first. Two reasons: the curated list is opaque and vendor-maintained, and a partner or internal domain occasionally turns up on it. One config edit beats a composer.json fork.

Validation-rule variant for form endpoints. If you'd rather reject at the validator (so you get a normal validation error and the user's input is preserved in the session), implement ValidationRule and attach it to the relevant field or run it in prepareForValidation():

Validator::extend('not_spam_referrer', fn ($attr, $value) => ! app(ReferrerSpamList::class)->isSpam((string) $value));

Prefer this over middleware when the traffic source is untrusted but the submission might still be legitimate — a soft rejection is kinder than a 403.

Analytics hygiene, not just forms. The primary historical use of this list is filtering fake traffic, not fake submissions. If you write your own page-hit table, add a is_spam_referrer boolean column populated by an Attribute or observer so you can exclude those rows from dashboards without losing the raw data:

protected static function booted(): void
{
    static::creating(function (PageHit $hit) {
        $hit->is_spam_referrer = app(ReferrerSpamList::class)
            ->isSpam((string) $hit->referrer);
    });
}

Do the flagging at write time, not in every dashboard query — one place to change, one place to audit.

Refresh on a schedule, not on deploy. The list only changes when the vendor publishes a release, and they publish patch releases frequently. Put the bump on a weekly job so a list refresh never blocks a sprint or a feature deploy:

$schedule->command('composer:update-package piwik/referrer-spam-blacklist')
    ->weekly();
// or simply: let Dependabot/Renovate open a PR; these are data-only patches.

Alternatively, snapshot the list into a spam_domains table via a small Artisan command (spam:import) if you want to query it, join on it, or let admins add entries through your own UI. That trades a cached array for a table read, but gives you provenance and an audit trail the vendor package doesn't offer.

Instrument the blocks from day one. Log blocks to a dedicated channel (config/logging.php → spam), and keep the raw Referer, IP, and route. When a real customer complains about a rejected contact form, this log is the only way you'll find out it was the blocklist — the package itself offers no observability whatsoever.

Gotchas and Tips

  • The Referer header is client-controlled and trivially spoofed. It is a hygiene filter, not a security control. Bots that matter will simply omit it or forge something plausible. Do not treat "not on the list" as "trusted," and do not rely on this for anything behind a login or an authenticated API.

  • Absent referer ≠ spam. Direct traffic, privacy browsers, Referrer-Policy: no-referrer, and HTTPS→HTTP downgrades all strip it. Only evaluate when the header is present and non-empty, or you'll block real users.

  • Never use str_contains for the match. str_contains($host, 'spam.com') matches notspam.com and spam.com.evil.example. Suffix-match on dot boundaries (walk the label chain as shown, or $host === $d || str_ends_with($host, '.' . $d)), or you'll create false positives that are very hard to debug.

  • Spam domains use random subdomains by design (x7f2k.spamdomain.com). Exact-match only is the most common implementation bug — you'll catch maybe a third of the traffic.

  • Cache invalidation is the sneakiest failure mode. If you cache parsed domains with rememberForever and a static key, composer update will pull a new list that your app never reads until the cache is cleared. Always fold filemtime() (or the installed package version) into the cache key.

  • The file may be missing at runtime. In slim production images, --no-dev is fine (this isn't a dev dependency), but aggressive vendor pruning or composer install --no-scripts in a layer that strips non-PHP files can drop the .txt. Degrade gracefully: @file() returning false should mean "no list," not a fatal error. Log loudly if the file vanishes.

  • No classes means no static analysis coverage. PHPStan/Rector/IDE navigation will tell you nothing about this dependency. Version-pin it, and write the one test that asserts your parser handles the real file:

    public function test_referrer_list_parses_and_finds_a_known_domain(): void
    {
        $list = new ReferrerSpamList(base_path('vendor/piwik/referrer-spam-blacklist/referrers.txt'));
        $this->assertNotEmpty($list->domains());
        $this->assertTrue($list->isSpam('http://sub.semalt.com/'));
        $this->assertFalse($list->isSpam('https://laravel.com/docs'));
    }
    

    That single test catches a file rename, a format change, or a broken install in one assertion.

  • Status code choice is a product decision. abort(403) confirms to the spammer that you blocklisted them and leaks that a filter exists. A 302 to / or a silent 204 degrades less obviously and confuses naive bots. Whichever you pick, make it consistent and document it.

  • Middleware ordering matters. Put the block before expensive work (session start, DB writes, queue dispatch) but after TrustProxies, so $request->ip() in your logs is the real client IP. Don't put it in the web group globally — route-group scoping keeps the blast radius small.

  • Overlaps with Matomo's built-in filter. If you already run Matomo with its default referrer-spam filter enabled, it uses this same list — don't duplicate the work in your analytics pipeline. This package is for your own Laravel code (custom form handling, custom hit tables), not for re-implementing an existing analytics feature.

  • It is a baseline, not a strategy. It does nothing against rate limiting, credential stuffing, adaptive bots, or headless-browser form fills with clean referers. Pair it with a honeypot field, Laravel's throttle middleware, and — if abuse escalates — a real bot-management evaluation. The blocklist's value is that it handles the long tail for free so you can spend effort on the attacks that actually matter.

  • Extension point is your allowlist, and that's it. There's no hook, no event, no config to publish. If you need provenance ("which release added this domain?"), per-decision observability, or an appeal workflow, you must build it yourself — either by logging around the calls or by importing the list into your own table. Decide that before you ship, not after the first false-positive support ticket.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings
coleus/apps
seampact/laravel
emmyconceptng/laravel-ng-postcode
jjsquady/round-robin