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.

Maintainers

Package info

github.com/nalabdou/disposable-email-bundle

Type:symfony-bundle

pkg:composer/nalabdou/disposable-email-bundle

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-04-06 20:29 UTC

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.