Search by

hymns / disposable-email

hymns

Refuse sign-ups from disposable (throwaway) email providers. Offline check, no dependencies.

Package info

github.com/hymns/disposable-email

pkg:composer/hymns/disposable-email

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.1.0 2026-10-23 15:40 UTC

This package is auto-updated.

Last update: 2026-10-23 15:46:16 UTC


README

Refuse sign-ups from disposable (throwaway) email providers in PHP.

  • About 9,900 domains from disposable-email-domains (CC0), shipped in the package.
  • Subdomains are caught (a@x.mailinator.com), lookalikes are not (a@acmemailinator.com).
  • Mainstream providers (Gmail, Outlook, Yahoo, iCloud, Proton, …) are never refused, even if a wrong entry turns up upstream.
  • No network at check time and no dependencies. With opcache the list loads in about 0.04 ms per request and a check takes about 0.4 µs.
  • Works with any framework: it takes a string.
  • Optional refresh from cron, validated before use.
  • PHP 8.3 or later.
composer require hymns/disposable-email

The PHP counterpart of go-disposable-email: same list, same rules.

Check an address

use Hymns\DisposableEmail\Disposable;
use Hymns\DisposableEmail\DisposableEmailException;

if (Disposable::isDisposable($email)) {
    // refuse
}

try {
    Disposable::check($email);
} catch (DisposableEmailException $e) {
    // $e->getMessage():
    // "disposable email addresses are not accepted; please use a permanent email"
}

Neither one checks that the address is well formed; validate the format first. examples/plain is a runnable sign-up endpoint:

php -S localhost:8080 examples/plain/index.php
curl -i -d 'email=a@mailinator.com' localhost:8080/register   # 400

Laravel

A closure rule is enough:

use Hymns\DisposableEmail\Disposable;
use Hymns\DisposableEmail\DisposableEmailException;

$request->validate([
    'email' => ['required', 'email', function (string $attribute, mixed $value, Closure $fail) {
        if (Disposable::isDisposable((string) $value)) {
            $fail(DisposableEmailException::MESSAGE);
        }
    }],
]);

Symfony

use Hymns\DisposableEmail\Disposable;
use Hymns\DisposableEmail\DisposableEmailException;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;

#[Assert\Email]
public string $email;

#[Assert\Callback]
public function notDisposable(ExecutionContextInterface $context): void
{
    if (Disposable::isDisposable($this->email)) {
        $context->buildViolation(DisposableEmailException::MESSAGE)->atPath('email')->addViolation();
    }
}

Your own rules

use Hymns\DisposableEmail\Checker;

$checker = new Checker(
    blocked: ['new-throwaway.example'],  // not listed upstream yet
    allowed: ['partner.mailinator.com'], // listed, but you trust it
);
$checker->isDisposable($email);

The rules apply to subdomains too. At each level, from the full domain up, a blocked domain refuses, an allowed one accepts, and a listed one refuses. To give the static methods other rules, call Disposable::setDefault(new Checker(...)) once at boot.

Keep the list current

The shipped list is as fresh as the version you installed. To follow upstream daily, run a refresh from your scheduler and load the stored list where your app boots:

use Hymns\DisposableEmail\FileStore;
use Hymns\DisposableEmail\Refresher;

$refresher = new Refresher(store: new FileStore('/var/lib/myapp/disposable.php'));

// daily, from cron or your framework's scheduler
$refresher->refresh();     // int domains in a new list, or null if unchanged

// at boot, on every request
$refresher->loadStored();  // uses the stored list, if there is one

In Laravel, the boot call goes in a service provider and the refresh in routes/console.php:

// AppServiceProvider::boot()
(new Refresher(store: new FileStore(storage_path('app/disposable.php'))))->loadStored();

// routes/console.php
Schedule::call(fn () => (new Refresher(store: new FileStore(storage_path('app/disposable.php'))))->refresh())
    ->daily();

refresh() sends If-None-Match, waits at most 30 seconds and reads at most 10 MB. A download is used only if it has at least 5,000 domains and every line is a host name; otherwise it throws and the list in use stays. examples/refresh.php is a cron-ready script.

FileStore writes a PHP file in one rename, so opcache holds the stored list in shared memory just like the shipped one, and loadStored() costs about as much as the default. Keep the file on local disk on each server, or on a shared volume.

For a database or Redis, a Store is two methods. Cache get() if you call it on every request, because parsing a text list takes a few milliseconds:

use Hymns\DisposableEmail\DomainList;
use Hymns\DisposableEmail\Store;

final class PdoStore implements Store
{
    public function __construct(private PDO $db) {}

    public function get(): ?DomainList
    {
        $row = $this->db->query('SELECT domains, etag FROM email_blocklist WHERE id = 1')->fetch();

        return $row ? DomainList::parse($row['domains'], $row['etag'] ?? '') : null;
    }

    public function put(DomainList $list): void
    {
        $this->db->prepare('REPLACE INTO email_blocklist (id, domains, etag) VALUES (1, ?, ?)')
            ->execute([$list->toText(), $list->etag]);
    }
}

The default download uses PHP's stream wrapper (needs allow_url_fopen). To go through Guzzle or your framework's HTTP client, implement Fetcher and pass it as fetcher:.

Updating the shipped list

composer update-list

This downloads the upstream list, refuses it if it fails validation, rewrites data/domains.php with the upstream commit in its header, and runs the tests.

Design

The decisions and the reasons for them are in docs/adr/, and the map is in docs/ARCHITECTURE.md.

Licence

The code is MIT. The shipped list (data/domains.php) is from disposable-email-domains and is CC0 1.0.