nalabdou / disposable-email-bundle
Production-grade Symfony bundle to detect and block disposable email addresses. Ships with 100K+ domains, extensible loaders, PSR-6 caching, Twig helpers, and a sync console command.
Package info
github.com/nalabdou/disposable-email-bundle
Type:symfony-bundle
pkg:composer/nalabdou/disposable-email-bundle
Requires
- php: >=8.2
- psr/cache: ^2.0|^3.0
- psr/log: ^2.0|^3.0
- symfony/config: ^6.4|^7.0
- symfony/console: ^6.4|^7.0
- symfony/dependency-injection: ^6.4|^7.0
- symfony/event-dispatcher: ^6.4|^7.0
- symfony/http-kernel: ^6.4|^7.0
- symfony/validator: ^6.4|^7.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.0
- phpstan/phpstan: ^1.10
- phpunit/phpunit: ^11.0
- symfony/cache: ^6.4|^7.0
- symfony/framework-bundle: ^6.4|^7.0
- symfony/twig-bundle: ^6.4|^7.0
This package is auto-updated.
Last update: 2026-04-06 20:34:28 UTC
README
The most complete Symfony bundle for detecting and blocking disposable (temporary) email addresses.
Ships with a built-in domain list, extensible loader/whitelist architecture, PSR-6 caching, Twig integration, two console commands, Symfony Events, and a rich value-object API.
✅ Features
| Feature | Description |
|---|---|
| 🔥 Bundled domain list | 500+ domains ready to use out of the box |
| 🌐 Remote sync | Pull 110K+ domains on demand via php bin/console disposable-email:sync |
| 🧩 Extensible loaders | Tag any service with disposable_email.domain_loader |
| 🛡️ Whitelist providers | Tag any service with disposable_email.whitelist_provider |
| 🏷️ PHP 8 Attributes | #[AsDomainLoader] #[AsWhitelistProvider] #[AsDomain] #[AsDomainList] #[AsWhitelistedDomain] |
| 📦 Inline config domains | Add domains directly in YAML without a file |
| 🧠 Symfony Validator | #[NotDisposableEmail] PHP 8 Attribute + YAML support |
| ⚙️ Rich service API | check() returns a full CheckResult value object |
| 🧁 Twig helpers | Function, filter, and test for templates |
| 🔔 Symfony Events | DisposableEmailCheckedEvent + DomainListSyncedEvent |
| 🗃️ PSR-6 caching | Plug in any Symfony cache pool |
| 🐛 Debug command | disposable-email:debug for runtime inspection |
| 📝 PSR-3 logging | All operations are logged at appropriate levels |
| ✅ Symfony 6.4 + 7.x | Tested on PHP 8.2+ |
🚀 Installation
composer require nalabdou/disposable-email-bundle
Register the bundle in config/bundles.php:
return [ // ... Nalabdou\DisposableEmailBundle\DisposableEmailBundle::class => ['all' => true], ];
That's it. Zero configuration required to get started.
⚙️ Configuration
Publish the example config:
cp vendor/nalabdou/disposable-email-bundle/config/packages/disposable_email.yaml config/packages/
Full reference (config/packages/disposable_email.yaml):
disposable_email: # Directory for custom .txt blacklists and remote sync output blacklist_directory: '%kernel.project_dir%/var/disposable' # Domains added directly in config (no file required) extra_domains: - mycompetitor-fake.com # Set false to skip the bundled list entirely use_bundled_list: true # Remote sources for the sync command remote_sources: - url: 'https://remote_source_for_disposable_email_list' timeout: 30 # Domains that are NEVER flagged as disposable whitelist: - mycompany.com - staging.mycompany.com cache: enabled: false # true = highly recommended in production pool: 'cache.app' # any PSR-6 Symfony cache pool ttl: 86400 # 24 h key_prefix: 'disposable_email' # Dispatch events on every check (set false for max throughput) dispatch_events: true
⚙️ Usage
1. Validator Constraint — PHP 8 Attribute (recommended)
use Nalabdou\DisposableEmailBundle\Constraint\NotDisposableEmail; use Symfony\Component\Validator\Constraints as Assert; class RegistrationDto { #[Assert\NotBlank] #[Assert\Email] #[NotDisposableEmail] public string $email = ''; }
With a custom error message:
#[NotDisposableEmail(message: 'Please use a real, permanent email address.')] public string $email = '';
2. Validator Constraint — YAML
# config/validator/App.Entity.User.yaml App\Entity\User: properties: email: - NotBlank: ~ - Email: ~ - Nalabdou\DisposableEmailBundle\Constraint\NotDisposableEmail: ~
3. Runtime Service — Simple API
Inject DisposableEmailChecker anywhere via constructor injection:
use Nalabdou\DisposableEmailBundle\Service\DisposableEmailChecker; class RegistrationHandler { public function __construct( private readonly DisposableEmailChecker $checker, ) {} public function handle(string $email): void { if ($this->checker->isDisposable($email)) { throw new \DomainException('Disposable emails are not allowed.'); } } }
Accepts bare domains too:
$checker->isDisposable('mailinator.com'); // true $checker->isValid('gmail.com'); // true $checker->count(); // number of loaded disposable domains
4. Runtime Service — Rich CheckResult API
$result = $checker->check('user@mailinator.com'); $result->isDisposable(); // bool $result->isValid(); // bool $result->isWhitelisted(); // bool $result->domain; // 'mailinator.com' $result->detectedBy; // 'bundled' — which loader flagged it $result->fromCache; // bool — was result served from PSR-6 cache? echo $result; // [DISPOSABLE] user@mailinator.com (domain: mailinator.com, cache: no, detected_by: bundled)
5. Twig
{# Twig test (most readable) #} {% if user.email is disposable_email %} <p class="text-red-600">⚠ Disposable email detected.</p> {% else %} <p class="text-green-600">✔ Email looks valid.</p> {% endif %} {# Twig filter #} {% if user.email|is_disposable_email %} <span class="badge badge-danger">Disposable</span> {% endif %} {# Full CheckResult object via function #} {% set result = disposable_email_check(user.email) %} {% if result.disposable %} Flagged by loader: {{ result.detectedBy }} {% endif %}
🔄 Console Commands
disposable-email:sync — Update the domain list
# Sync all configured remote_sources php bin/console disposable-email:sync # Show a per-source table php bin/console disposable-email:sync --stats
Schedule automatic sync with cron:
# /etc/cron.d/disposable-email
0 3 * * * www-data /var/www/html/bin/console disposable-email:sync >> /var/log/disposable-email-sync.log 2>&1
Or with the Symfony Scheduler:
use Symfony\Component\Scheduler\Attribute\AsSchedule; use Symfony\Component\Scheduler\RecurringMessage; use Symfony\Component\Scheduler\Schedule; use Symfony\Component\Scheduler\ScheduleProviderInterface; #[AsSchedule] final class AppSchedule implements ScheduleProviderInterface { public function getSchedule(): Schedule { return (new Schedule())->add( RecurringMessage::cron('0 3 * * *', new SyncDisposableEmailsMessage()), ); } }
disposable-email:debug — Inspect runtime state
# Overview (loader count, total domains, etc.) php bin/console disposable-email:debug # Check a specific email or domain php bin/console disposable-email:debug user@mailinator.com php bin/console disposable-email:debug mailinator.com # List all registered loaders and whitelist providers php bin/console disposable-email:debug --loaders # Search for domains containing a substring php bin/console disposable-email:debug --search=mailinator # Show total domain count php bin/console disposable-email:debug --count
📝 Custom Blacklist
Drop any .txt file (one domain per line) into the configured blacklist_directory:
# var/disposable/my_domains.txt
competitor-disposable.com
internal-test.local
No command needed. The file is picked up automatically on the next request (or cache refresh).
🏷️ PHP 8 Attributes
All five bundle attributes are in the Nalabdou\DisposableEmailBundle\Attribute\ namespace. They are the zero-YAML alternative to YAML tags and inline config — pure PHP, fully type-safe, discovered automatically at container compile time.
#[AsDomainLoader] — Register a loader service
use Nalabdou\DisposableEmailBundle\Attribute\AsDomainLoader; use Nalabdou\DisposableEmailBundle\Contract\DomainLoaderInterface; use Doctrine\DBAL\Connection; #[AsDomainLoader(priority: 20)] final class DatabaseDomainLoader implements DomainLoaderInterface { public function __construct(private readonly Connection $db) {} public function load(): iterable { return $this->db->fetchFirstColumn('SELECT domain FROM disposable_domains'); } public function getName(): string { return 'database'; } public function isEnabled(): bool { return true; } }
Override the name surfaced in CheckResult::$detectedBy and debug output:
#[AsDomainLoader(priority: 20, name: 'my_custom_source')] final class DatabaseDomainLoader implements DomainLoaderInterface { ... }
Priority reference:
| Source | Priority |
|---|---|
| Bundled list | -10 |
| Custom blacklist files | 0 |
#[AsDomain] / #[AsDomainList] attributes |
5 |
Inline extra_domains YAML |
10 |
Your #[AsDomainLoader] classes |
20+ (recommended) |
#[AsWhitelistProvider] — Register a whitelist provider service
use Nalabdou\DisposableEmailBundle\Attribute\AsWhitelistProvider; use Nalabdou\DisposableEmailBundle\Contract\WhitelistProviderInterface; #[AsWhitelistProvider] final class CompanyWhitelistProvider implements WhitelistProviderInterface { public function __construct(private readonly Connection $db) {} public function getWhitelistedDomains(): iterable { return $this->db->fetchFirstColumn('SELECT domain FROM trusted_domains'); } }
With an optional description visible in disposable-email:debug --loaders:
#[AsWhitelistProvider(description: 'Company-approved domains from CRM')] final class CompanyWhitelistProvider implements WhitelistProviderInterface { ... }
#[AsDomain] — Mark a constant or enum case as a disposable domain
On class constants:
use Nalabdou\DisposableEmailBundle\Attribute\AsDomain; final class KnownDisposableDomains { #[AsDomain] public const MAILINATOR = 'mailinator.com'; #[AsDomain] public const GUERRILLA = 'guerrillamail.com'; // No attribute — not loaded public const SOME_INTERNAL = 'internal.local'; }
On a string-backed enum (recommended for domain modelling):
use Nalabdou\DisposableEmailBundle\Attribute\AsDomain; enum DisposableDomain: string { #[AsDomain] case Mailinator = 'mailinator.com'; #[AsDomain] case Trashmail = 'trashmail.com'; }
With an explicit domain override:
#[AsDomain(domain: 'actual-domain.com')] public const LEGACY_CONSTANT_NAME = 'old-value.com'; // Loads 'actual-domain.com', not 'old-value.com'
Register your class as a service to have it discovered:
# config/services.yaml (or use autowire: true on the whole App\ namespace) App\Mail\KnownDisposableDomains: public: false
#[AsDomainList] — Declare an inline or static-method domain list
Lighter than #[AsDomainLoader] — no interface required. Perfect for simple static lists that don't need lifecycle control.
Inline domains:
use Nalabdou\DisposableEmailBundle\Attribute\AsDomainList; #[AsDomainList( domains: ['fakeinbox.com', 'tempmail.io', 'throwaway.email'], priority: 15, )] final class ProjectBlockList {}
Via a static method:
#[AsDomainList(method: 'getDomains', priority: 15, name: 'project_blocklist')] final class ProjectBlockList { public static function getDomains(): array { return ['fakeinbox.com', 'tempmail.io', 'throwaway.email']; } }
Multiple lists on one class (IS_REPEATABLE):
#[AsDomainList(domains: ['competitor-a.com', 'competitor-b.com'], priority: 20)] #[AsDomainList(method: 'getRegionalDomains', priority: 15)] final class AllBlockLists { public static function getRegionalDomains(): array { ... } }
The method must be static. The compiler pass validates this and throws a \LogicException at compile time if the method is missing or non-static.
#[AsWhitelistedDomain] — Mark a constant or enum case as a trusted domain
On class constants:
use Nalabdou\DisposableEmailBundle\Attribute\AsWhitelistedDomain; final class TrustedDomains { #[AsWhitelistedDomain] public const COMPANY = 'mycompany.com'; #[AsWhitelistedDomain] public const STAGING = 'staging.mycompany.com'; // No attribute — not whitelisted public const PARTNER_LEGACY = 'old-partner.com'; }
On a string-backed enum:
use Nalabdou\DisposableEmailBundle\Attribute\AsWhitelistedDomain; enum TrustedDomain: string { #[AsWhitelistedDomain] case Company = 'mycompany.com'; #[AsWhitelistedDomain] case Staging = 'staging.mycompany.com'; }
With an explicit domain override:
#[AsWhitelistedDomain(domain: 'real-domain.com')] public const PLACEHOLDER = 'draft-name.com';
Attribute quick-reference
| Attribute | Target | Purpose |
|---|---|---|
#[AsDomainLoader] |
Class | Register a DomainLoaderInterface service |
#[AsWhitelistProvider] |
Class | Register a WhitelistProviderInterface service |
#[AsDomain] |
Class constant / enum case | Mark a single value as a disposable domain |
#[AsDomainList] |
Class | Declare an inline or static-method domain list |
#[AsWhitelistedDomain] |
Class constant / enum case | Mark a single value as a trusted (whitelisted) domain |
🧩 Extending — Custom Domain Loader
Implement DomainLoaderInterface and tag the service:
namespace App\Mail; use Nalabdou\DisposableEmailBundle\Contract\DomainLoaderInterface; use Doctrine\DBAL\Connection; final class DatabaseDomainLoader implements DomainLoaderInterface { public function __construct(private readonly Connection $db) {} public function load(): iterable { return $this->db->fetchFirstColumn('SELECT domain FROM disposable_domains'); } public function getName(): string { return 'database'; } public function isEnabled(): bool { return true; } }
# config/services.yaml App\Mail\DatabaseDomainLoader: tags: - { name: disposable_email.domain_loader, priority: 20 }
Note: when two loaders provide the same domain, the higher-priority one is credited in CheckResult::$detectedBy.
🛡️ Extending — Custom Whitelist Provider
namespace App\Mail; use Nalabdou\DisposableEmailBundle\Contract\WhitelistProviderInterface; final class DatabaseWhitelistProvider implements WhitelistProviderInterface { public function __construct(private readonly Connection $db) {} public function getWhitelistedDomains(): iterable { return $this->db->fetchFirstColumn('SELECT domain FROM trusted_domains'); } }
App\Mail\DatabaseWhitelistProvider: tags: - { name: disposable_email.whitelist_provider }
🔔 Events
DisposableEmailCheckedEvent (on every check)
use Nalabdou\DisposableEmailBundle\Event\DisposableEmailCheckedEvent; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; #[AsEventListener(event: DisposableEmailCheckedEvent::NAME)] final class DisposableEmailListener { public function __invoke(DisposableEmailCheckedEvent $event): void { $result = $event->getResult(); if ($result->isDisposable()) { $this->logger->warning('Disposable email attempt', [ 'email' => $result->input, 'domain' => $result->domain, 'detected_by' => $result->detectedBy, ]); } } }
DomainListSyncedEvent (after sync command)
use Nalabdou\DisposableEmailBundle\Event\DomainListSyncedEvent; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; #[AsEventListener(event: DomainListSyncedEvent::NAME)] final class SyncCompletedListener { public function __invoke(DomainListSyncedEvent $event): void { if ($event->hasFailures()) { $this->notifyOpsChannel('Disposable email sync had failures!'); } $this->metrics->gauge('disposable_email.total_domains', $event->getTotalDomains()); } }
🧠 Caching (Production)
Strongly recommended in production to avoid rebuilding the 100K+ domain set on every request:
# config/packages/disposable_email.yaml disposable_email: cache: enabled: true pool: 'cache.app' # or 'cache.redis', any PSR-6 pool ttl: 86400
After syncing a new domain list, the cache is invalidated automatically. To clear it manually:
php bin/console cache:pool:clear cache.app
📁 Directory Structure
DisposableEmailBundle/
├── composer.json
├── phpunit.xml.dist
├── README.md
│
├── config/
│ ├── services.php
│ └── packages/
│ └── disposable_email.yaml
│
├── resources/
│ └── domains/
│ └── disposable_domains.txt
│
└── src/
├── DisposableEmailBundle.php
│
├── Attribute/
│ ├── AsDomain.php
│ ├── AsDomainList.php
│ ├── AsDomainLoader.php
│ ├── AsWhitelistedDomain.php
│ └── AsWhitelistProvider.php
│
├── Command/
│ ├── SyncDisposableEmailListCommand.php
│ └── DebugDisposableEmailCommand.php
│
├── Constraint/
│ ├── NotDisposableEmail.php
│ └── NotDisposableEmailValidator.php
│
├── Contract/
│ ├── CheckResult.php
│ ├── DomainLoaderInterface.php
│ ├── SyncResult.php
│ └── WhitelistProviderInterface.php
│
├── DependencyInjection/
│ ├── Compiler/
│ │ ├── DomainLoaderPass.php
│ │ ├── RegisterAttributesPass.php
│ │ └── WhitelistProviderPass.php
│ ├── Configuration.php
│ └── DisposableEmailExtension.php
│
├── Event/
│ ├── DisposableEmailCheckedEvent.php
│ └── DomainListSyncedEvent.php
│
├── Exception/
│ ├── DisposableEmailException.php
│ ├── DomainLoaderException.php
│ └── SyncException.php
│
├── Loader/
│ ├── AbstractFileLoader.php
│ ├── AttributeDomainLoader.php
│ ├── BundledDomainLoader.php
│ ├── ChainDomainLoader.php
│ ├── CustomBlacklistLoader.php
│ └── InlineDomainsLoader.php
│
├── Provider/
│ ├── AttributeWhitelistProvider.php
│ ├── ChainWhitelistProvider.php
│ └── ConfigWhitelistProvider.php
│
├── Service/
│ ├── DisposableEmailChecker.php
│ └── DomainListSyncer.php
│
└── Twig/
└── DisposableEmailExtension.php
tests/
├── Attribute/
│ └── AttributesTest.php
├── Constraint/
│ └── NotDisposableEmailValidatorTest.php
├── DependencyInjection/
│ └── RegisterAttributesPassTest.php
└── Service/
├── ChainDomainLoaderTest.php
├── DisposableEmailCheckerTest.php
├── LoadersTest.php
├── ValueObjectsTest.php
└── WhitelistProviderTest.php
Running Tests
composer install vendor/bin/phpunit
License
This project is licensed under the MIT License — see the LICENSE file for details.