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';
}
}
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.');
}
}
}
Use it next to your usual rules:
$request->validate([
'email' => ['required', 'email', 'unique:users,email', new FirstTrialForEmail],
]);
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');
});
}
};
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.');
}
}
}
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());
}
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');
});
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);
});
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);
});
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
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)