palpalani/laravel-dns-deny-list-check
Laravel package that checks email server IPs against 12 verified DNS-based blacklists (DNSBL/RBL) to protect email deliverability. Supports Laravel 11.x and 12.x.
1. Install and confirm the environment.
composer require palpalani/laravel-dns-deny-list-check
php artisan vendor:publish --provider="palPalani\DnsDenyListCheck\DnsDenyListCheckServiceProvider" --tag="laravel-dns-deny-list-check-config"
Three hard requirements before you write a line of code:
ext-dns — verify with php -m | grep dns. It's easy to have it locally and not in a container. If you deploy to Docker, add docker-php-ext-install or install-php-extensions for dns to your image now, not at 2 a.m. during the first deploy.2. Look at the config file first. After publishing, open config/dns-deny-list-check.php. This is where the 12 curated DNSBLs live, each with name, host, tier (1–3), and priority (critical / important / supplementary). The list is the package's main opinionated asset — review it against your own sending domain before going live.
3. The primary class and the facade.
use palPalani\DnsDenyListCheck\DnsDenyListCheckFacade as DnsDenyListCheck;
$result = DnsDenyListCheck::check('203.0.113.10');
The facade resolves the underlying DnsDenyListCheck class from the container. You can also instantiate it directly (new DnsDenyListCheck()), which is useful for testing with custom servers and timeouts.
4. First real use case: audit your outbound mail IPs.
The clearest value is not "check a user's IP" — it's "check my mail server's IP before I trust a deliverability report." Wire this into an artisan command for a first win:
// app/Console/Commands/CheckMailIp.php
public function handle(DnsDenyListCheck $checker)
{
$ips = config('mail.outbound_ips'); // your mail relay addresses
foreach ($ips as $ip) {
$r = $checker->check($ip);
$this->line(sprintf(
'%s: %d listed / %d checked (%.2fs avg)',
$ip,
$r['stats']['total_listed'],
$r['stats']['total_checked'],
$r['stats']['average_response_time']
));
}
}
Schedule it weekly and log the diff. That single command gives you a reliable, dependency-free reputation signal you can correlate with bounce spikes.
Bind the checker once with config values. The README's basic instantiation (new DnsDenyListCheck()) is fine for scripts, but in an app you want a single container binding so timeout, IPv6, and concurrency settings are centralized:
// AppServiceProvider::register()
$this->app->singleton(DnsDenyListCheck::class, fn ($app) => new DnsDenyListCheck(
dnsblServers: config('dns-deny-list-check.servers'),
timeoutSeconds: (int) config('dns-deny-list-check.timeout', 10),
ipv6Enabled: (bool) config('dns-deny-list-check.ipv6_enabled', true),
concurrentEnabled: (bool) config('dns-deny-list-check.concurrent', false),
));
Then inject DnsDenyListCheck $checker into controllers, jobs, and commands and you're done. Note the dnsblServers argument is a name/host/tier/priority array — you can shadow the package config entirely if you need a custom list.
Pattern: pre-send gate for transactional mail. For a contact form or onboarding flow, check the submitting IP and log it, but don't hard-block on a single hit:
$result = $checker->check($request->ip());
if ($result['success'] && $result['stats']['total_listed'] > 0) {
// Look at which tiers are flagged
$flagged = collect($result['data'])
->where('listed', true)
->pluck('priority')
->unique();
if ($flagged->contains('critical')) {
return response()->json(['error' => 'Submission blocked'], 422);
}
Log::info('IP on secondary blacklists', ['ip' => $request->ip(), 'tiers' => $flagged]);
}
This is the pattern the package is actually designed for — success is transport success, stats.total_listed is the verdict, and data[*].priority lets you weight by severity. Treating "listed on one supplementary server" the same as "listed on SpamCop" will cause false positives.
Pattern: cache aggressively, keyed by IP. DNSBL lookups are 12 DNS queries; for high-traffic endpoints that's wasteful and some servers will rate-limit you.
$result = Cache::remember("dnsbl:{$ip}", now()->addHour(), fn () => $checker->check($ip));
Use Cache::put with a shorter TTL (5–15 minutes) for IPs that came back listed, and a longer TTL (hours) for clean ones.
Pattern: queue it and never block the request. Even with a 5s timeout, a full check can take multiple seconds when DNSBL servers are slow. For anything user-facing, dispatch a job:
CheckIpReputation::dispatch($ip, $userId)->onQueue('low');
Store the result on the model (a reputation_checked_at, blacklist_count pair of columns) and read it elsewhere. This also makes your 12 outbound DNS calls debuggable in failed_jobs.
Pattern: separate your "my infrastructure" vs "their infrastructure" checks. The two have different cadence, different thresholds, and different alerting. For your own IPs you want to alert on any listing; for user IPs you probably only care about tier 1.
Pattern: custom list extension. Because dnsblServers is just an array, you can build a two-instance setup: one using the package defaults, one using a short list of extra DNSBLs (or SURBL-style domain lists won't fit here — this package is IP-only) you care about. Keep the package default list as your base and append, rather than replacing, so you don't lose the curated coverage.
Testing pattern. Don't hit real DNSBL servers in CI — they will throttle you and make your pipeline flaky. Bind a fake:
$this->mock(DnsDenyListCheck::class, function ($mock) {
$mock->shouldReceive('check')->andReturn([
'success' => true,
'stats' => ['total_checked' => 12, 'total_listed' => 1, 'total_errors' => 0],
'data' => [/* ... */],
]);
});
Since the facade is an alias for the class, mocking the class name is sufficient.
success: true does not mean "clean." This is the single biggest footgun. success means "the check completed without a transport failure." A successful check with total_listed: 3 is still success: true. Always branch on stats.total_listed > 0, never on success alone. The message string ("... 0 blacklists found ...") is human-readable and should not be parsed.
total_listed and total_errors are separate failure modes. If a DNSBL times out, the entry is neither "listed" nor "unlisted" — it's an error. A result of total_checked: 12, total_listed: 0, total_errors: 5 is not a clean bill of health; it's "we don't know about 5 servers." For your own infrastructure checks, treat total_errors > 0 as a degraded signal to investigate (often your DNS resolver, not the package).
Resolver configuration matters more than the package. DNSBL lookups go through the host's configured resolver. Public resolvers (Cloudflare 1.1.1.1, Google 8.8.8.8) frequently return false negatives for DNSBLs — Spamhaus and others deliberately do not serve results to large public resolvers to prevent poisoning. If your mail servers use a public resolver, your results will be incomplete and you'll blame the package. Use your provider's resolver or a self-hosted unbound. This is a five-minute check that has saved people days.
Tier and priority are just metadata in your array. The checker iterates everything in the list. There's no checkTier(1) method. If you want tier-filtered checks, filter the server list before constructing the checker or filter data afterwards. Filtering before is faster:
$tier1 = collect(config('dns-deny-list-check.servers'))->where('tier', 1)->all();
$checker = new DnsDenyListCheck(dnsblServers: $tier1);
concurrentEnabled is documented as "(when available)." Read the source before enabling it in production. If it silently falls back to serial, you'll get the same latency with a false sense of speed. Benchmark with a deliberately blocked IP against time() before and after.
UCEPROTECT Level 1 is aggressive and lists entire provider ranges. It's marked critical in the config, but Level 1 is famous for listing shared IP space in bulk. For user-submitted IPs (e.g. residential visitors, VPS customers), a UCEPROTECT L1 hit is frequently a false positive. Consider treating it as advisory for user IPs and only critical for your own mail server IPs, or check L2/L3 semantics against your use case.
The package is new, with 3 stars and 0 dependents. That's a real risk factor for anything mission-critical. Mitigate by (a) pinning the version in composer.json, (b) wrapping the call behind your own interface (ReputationCheckerInterface) and one adapter class, so swapping implementations later is a one-file change, and (c) not putting this directly in a request lifecycle where a breaking change takes down a page. The code is small enough (~a few hundred lines) to read and vendor if needed.
No built-in rate limiting. Each call fires 12+ DNS queries. Some DNSBLs will throttle or block clients that hammer them. Cache on your side; if you're checking a batch of IPs (e.g. a nightly audit of 500 IPs = 6000 queries), add a usleep() between calls or chunk the job.
IPv6 reverse notation is handled, but validate inputs yourself for anything user-supplied. The package validates; still, if you're passing $request->ip() where the value can be null or a proxy header spoof (see Laravel's TrustProxies middleware), you can end up checking the wrong address entirely. Confirm your TrustProxies config is correct before trusting $request->ip() as the source of truth.
Cache invalidation after delisting is slow. If you're on a blacklist, get delisted, and want to confirm — clear the cache key manually or use short TTLs on listed IPs. Don't wait an hour staring at a stale total_listed: 1.
Log the raw result on failure, at least while you're integrating. $result['message'] is a summary; $result['data'] with per-server response_time is what tells you whether it was a timeout, an NXDOMAIN, or an actual listing. When something's off, dumping data for a single IP is the fastest way to diagnose.
How can I help you explore Laravel packages today?