DEV Community

Elijah Brown
Elijah Brown

Posted on Originally published at elijahbrown.hashnode.dev

One free trial per person in Laravel: normalise Gmail dots and plus tags, then enforce it in the database

A free trial that hands out credits on signup has a simple loophole. Gmail ignores dots in the local part and drops anything after a plus sign, and it treats googlemail.com as the same mailbox as gmail.com. So jane.doe@gmail.com, janedoe+2@gmail.com and j.a.n.e.doe@googlemail.com all land in one inbox, and each one looks like a fresh account to a plain unique:users,email rule.

This post builds the fix in a fresh Laravel 13 app in four parts: a normaliser, a validation Rule for a friendly form error, a migration with a unique index on trial_claims, and a claim action that writes the claim and grants the credits in one DB::transaction. Then a Pest suite, including a test where four processes race for the same trial at once. Every snippet below comes from the app I ran the tests against.

1. Normalise the address

Keep the stored email exactly as the person typed it. Only the trial key gets normalised.

<?php

namespace App\Support;

class EmailIdentity
{
    private const GMAIL_DOMAINS = ['gmail.com', 'googlemail.com'];

    public static function normalise(string $email): string
    {
        $email = strtolower(trim($email));

        $at = strrpos($email, '@');

        if ($at === false) {
            return $email;
        }

        $local = substr($email, 0, $at);
        $domain = substr($email, $at + 1);

        if (! in_array($domain, self::GMAIL_DOMAINS, true)) {
            return $local.'@'.$domain;
        }

        $local = explode('+', $local, 2)[0];
        $local = str_replace('.', '', $local);

        return $local.'@gmail.com';
    }
}
Enter fullscreen mode Exit fullscreen mode

Two choices worth calling out. Dots and plus tags are only stripped for gmail.com and googlemail.com, because on other providers jane.doe and janedoe can be two different people. And googlemail.com is folded into gmail.com, so the two spellings share one key.

You could extend the plus rule to providers that document it, such as Outlook or Fastmail. I left that out on purpose: a team mailbox like billing+acme@company.com can belong to someone else entirely.

2. A Rule class for the signup form

<?php

namespace App\Rules;

use App\Models\TrialClaim;
use App\Support\EmailIdentity;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;

class FirstTrialForEmail implements ValidationRule
{
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        if (! is_string($value)) {
            return;
        }

        $taken = TrialClaim::where('normalised_email', EmailIdentity::normalise($value))->exists();

        if ($taken) {
            $fail('This email has already been used for a free trial.');
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Use it next to your usual rules:

$request->validate([
    'email' => ['required', 'email', 'unique:users,email', new FirstTrialForEmail],
]);
Enter fullscreen mode Exit fullscreen mode

This gives a clear message at the form. It is not the guarantee. Two requests can both run this check before either one writes, and both will pass. The database has to settle that case.

3. The migration

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table->unsignedInteger('credits')->default(0);
        });

        Schema::create('trial_claims', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')->constrained()->cascadeOnDelete();
            $table->string('normalised_email');
            $table->timestamps();

            $table->unique('normalised_email');
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('trial_claims');

        Schema::table('users', function (Blueprint $table) {
            $table->dropColumn('credits');
        });
    }
};
Enter fullscreen mode Exit fullscreen mode

The unique index on normalised_email is the line that actually enforces one trial per person. trial_claims is its own table rather than a column on users, so it can outlive the account. As written, deleting a user cascades and frees their email for another trial. If you want the claim to survive account deletion, make user_id nullable and use nullOnDelete() instead of the cascade.

4. Claim and grant in one transaction

<?php

namespace App\Actions;

use App\Exceptions\TrialAlreadyClaimed;
use App\Models\TrialClaim;
use App\Models\User;
use App\Support\EmailIdentity;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Facades\DB;

class ClaimTrial
{
    public const TRIAL_CREDITS = 500;

    public function __invoke(User $user): void
    {
        try {
            DB::transaction(function () use ($user) {
                TrialClaim::create([
                    'user_id' => $user->id,
                    'normalised_email' => EmailIdentity::normalise($user->email),
                ]);

                User::whereKey($user->id)->increment('credits', self::TRIAL_CREDITS);
            });
        } catch (UniqueConstraintViolationException) {
            throw new TrialAlreadyClaimed('This email has already been used for a free trial.');
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The claim row and the credit grant either both happen or neither does. If the insert hits the unique index, Laravel throws UniqueConstraintViolationException, the transaction rolls back, and the credits are never added. No lockForUpdate, no check then insert; the index does the work.

Call it after the user is created, and turn the exception into a normal response:

try {
    app(ClaimTrial::class)($user);
} catch (TrialAlreadyClaimed $e) {
    // The account still exists, it just starts without trial credits.
    session()->flash('status', $e->getMessage());
}
Enter fullscreen mode Exit fullscreen mode

5. Pest tests

The normaliser first, with a dataset of the variants that should all collapse to one key:

<?php

use App\Support\EmailIdentity;

it('folds Gmail dots, plus tags and googlemail.com into one address', function (string $input) {
    expect(EmailIdentity::normalise($input))->toBe('janedoe@gmail.com');
})->with([
    'janedoe@gmail.com',
    'jane.doe@gmail.com',
    'j.a.n.e.d.o.e@gmail.com',
    'janedoe+trial2@gmail.com',
    'Jane.Doe+again@GMAIL.com',
    'jane.doe@googlemail.com',
    '  jane.doe+x@googlemail.com ',
]);

it('leaves dots alone on other domains', function () {
    expect(EmailIdentity::normalise('Jane.Doe@Example.com'))->toBe('jane.doe@example.com');
});
Enter fullscreen mode Exit fullscreen mode

Then the claim flow. The third test is the important one: both requests pass the Rule, and the second one is still refused by the index.

<?php

use App\Actions\ClaimTrial;
use App\Exceptions\TrialAlreadyClaimed;
use App\Models\TrialClaim;
use App\Models\User;
use App\Rules\FirstTrialForEmail;
use Illuminate\Support\Facades\Validator;

uses(Illuminate\Foundation\Testing\RefreshDatabase::class);

it('grants the trial credits once', function () {
    $user = User::factory()->create(['email' => 'jane.doe@gmail.com']);

    (new ClaimTrial)($user);

    expect($user->fresh()->credits)->toBe(ClaimTrial::TRIAL_CREDITS)
        ->and(TrialClaim::count())->toBe(1);
});

it('rejects a Gmail variant in the form rule', function () {
    (new ClaimTrial)(User::factory()->create(['email' => 'jane.doe@gmail.com']));

    $validator = Validator::make(
        ['email' => 'JaneDoe+second@googlemail.com'],
        ['email' => ['required', 'email', new FirstTrialForEmail]],
    );

    expect($validator->fails())->toBeTrue()
        ->and($validator->errors()->first('email'))
        ->toBe('This email has already been used for a free trial.');
});

it('lets the database decide when two requests both pass the rule', function () {
    $first = User::factory()->create(['email' => 'jane.doe@gmail.com']);
    $second = User::factory()->create(['email' => 'janedoe+2@googlemail.com']);

    $rules = ['email' => [new FirstTrialForEmail]];
    expect(Validator::make(['email' => $first->email], $rules)->passes())->toBeTrue()
        ->and(Validator::make(['email' => $second->email], $rules)->passes())->toBeTrue();

    (new ClaimTrial)($first);

    expect(fn () => (new ClaimTrial)($second))->toThrow(TrialAlreadyClaimed::class)
        ->and($second->fresh()->credits)->toBe(0)
        ->and(TrialClaim::count())->toBe(1);
});
Enter fullscreen mode Exit fullscreen mode

6. The concurrency case

The test above runs the two claims one after another. To show the index holding under real parallel writes, this test forks four processes against a SQLite file, each claiming the trial for a different spelling of the same Gmail address. Exactly one should win.

<?php

use App\Actions\ClaimTrial;
use App\Exceptions\TrialAlreadyClaimed;
use App\Models\TrialClaim;
use App\Models\User;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;

beforeEach(function () {
    $this->dbFile = tempnam(sys_get_temp_dir(), 'trial').'.sqlite';
    touch($this->dbFile);

    config([
        'database.connections.sqlite.database' => $this->dbFile,
        'database.connections.sqlite.busy_timeout' => 5000,
    ]);
    DB::purge('sqlite');
    Artisan::call('migrate', ['--force' => true]);
});

afterEach(function () {
    DB::purge('sqlite');
    @unlink($this->dbFile);
});

it('gives the trial to exactly one of several processes racing at once', function () {
    $emails = ['jane.doe@gmail.com', 'janedoe@googlemail.com', 'j.anedoe+a@gmail.com', 'JANEDOE+b@gmail.com'];
    $ids = collect($emails)->map(fn ($e) => User::factory()->create(['email' => $e])->id);

    DB::purge('sqlite');
    $outDir = sys_get_temp_dir().'/trial-race-'.getmypid();
    @mkdir($outDir);
    $pids = [];

    foreach ($ids as $id) {
        $pid = pcntl_fork();

        if ($pid === 0) {
            DB::purge('sqlite');
            try {
                (new ClaimTrial)(User::findOrFail($id));
                $result = 'claimed';
            } catch (TrialAlreadyClaimed) {
                $result = 'refused';
            } catch (Throwable $e) {
                $result = 'error: '.$e->getMessage();
            }
            file_put_contents("$outDir/$id", $result);
            posix_kill(getmypid(), SIGKILL);
        }

        $pids[] = $pid;
    }

    foreach ($pids as $pid) {
        pcntl_waitpid($pid, $status);
    }

    $results = array_map(fn ($f) => file_get_contents($f), glob("$outDir/*"));
    array_map('unlink', glob("$outDir/*"));
    rmdir($outDir);

    $counts = array_count_values($results);

    expect($counts['claimed'] ?? 0)->toBe(1)
        ->and($counts['refused'] ?? 0)->toBe(count($emails) - 1)
        ->and(TrialClaim::count())->toBe(1)
        ->and((int) User::sum('credits'))->toBe(ClaimTrial::TRIAL_CREDITS);
});
Enter fullscreen mode Exit fullscreen mode

A few notes on making this work. The default test setup uses an in memory SQLite database, which a forked child cannot share, so this file points the connection at a temp file and migrates it. DB::purge before forking stops the children from inheriting the parent's PDO handle. busy_timeout makes a child wait for the write lock instead of failing at once. Each child writes its result to a file and kills itself, so it never runs the test runner's shutdown code. It needs the pcntl and posix extensions, which the PHP CLI usually has.

On MySQL or Postgres the same action behaves the same way: the loser gets a unique violation inside its transaction and rolls back.

Results

PASS  Tests\Unit\EmailIdentityTest
  ✓ it folds Gmail dots, plus tags and googlemail.com into one address with ('janedoe@gmail.com')
  ✓ it folds Gmail dots, plus tags and googlemail.com into one address with ('jane.doe@gmail.com')
  ✓ it folds Gmail dots, plus tags and googlemail.com into one address with ('j.a.n.e.d.o.e@gmail.com')
  ✓ it folds Gmail dots, plus tags and googlemail.com into one address with ('janedoe+trial2@gmail.com')
  ✓ it folds Gmail dots, plus tags and googlemail.com into one address with ('Jane.Doe+again@GMAIL.com')
  ✓ it folds Gmail dots, plus tags and googlemail.com into one address with ('jane.doe@googlemail.com')
  ✓ it folds Gmail dots, plus tags and googlemail.com into one address with (' jane.doe+x@googlemail.com ')
  ✓ it leaves dots alone on other domains

   PASS  Tests\Feature\TrialClaimConcurrencyTest
  ✓ it gives the trial to exactly one of several processes racing at on… 0.23s  

   PASS  Tests\Feature\TrialClaimTest
  ✓ it grants the trial credits once                                     0.04s  
  ✓ it rejects a Gmail variant in the form rule                          0.01s  
  ✓ it lets the database decide when two requests both pass the rule     0.01s  

  Tests:    12 passed (21 assertions)
  Duration: 0.36s
Enter fullscreen mode Exit fullscreen mode

I also ran the race test 20 times in a row and it passed every time.

What this does not cover

It only closes the Gmail address trick. Someone with two genuinely different mailboxes still gets two trials, and that needs other signals, such as the phone number, the IP or the email domain.

I built Orisift, which handles the other signup checks; there's a free tier. orisift.com

Top comments (0)