Laravel’s registration scaffolding validates the shape of the input: a valid email, a confirmed password, a unique address. It has no way to know that the same browser has already registered four times with four inboxes. If your app hands out a free trial, credits or a referral bonus at registration, that is the hole people farm.
This guide adds a per-device account limit to Laravel using the Prynt PHP SDK. The Fortify version lives in a validation rule; the Breeze version is a middleware. Both follow the same three steps: identify in the browser, check the event on the server, link the new user id.
Install the SDK and bind it
composer require prynt/server
The SDK needs PHP 8.0+ with curl, openssl and json. Register it as a singleton so every request reuses one client:
// app/Providers/AppServiceProvider.php
use Prynt\PryntServer;
public function register(): void
{
$this->app->singleton(PryntServer::class, fn () =>
new PryntServer(config('services.prynt.secret'))
);
}
// config/services.php
'prynt' => [
'public' => env('PRYNT_PUBLIC_KEY'), // pk_…, safe in the browser
'secret' => env('PRYNT_SECRET_KEY'), // sk_…, server only
'max_accounts_per_device' => (int) env('PRYNT_MAX_ACCOUNTS_PER_DEVICE', 2),
],
The secret key (sk_…) stays on the server. It only sees events from its own environment, so a test key cannot read live events.
Identify in the registration view
Fortify is headless, so your register view is whatever you passed to Fortify::registerView. Add the agent and a hidden field:
<script src="https://api.pryntid.com/cdn/prynt.umd.js"></script>
<form method="POST" action="{{ route('register') }}" id="register-form">
@csrf
{{-- name, email, password fields --}}
<input type="hidden" name="prynt_request_id" id="prynt_request_id">
<button type="submit">Create account</button>
</form>
<script>
const ready = Prynt.load({ apiKey: '{{ config('services.prynt.public') }}' })
.then((agent) => agent.identify({ tag: { action: 'signup' } }))
.catch(() => null);
document.getElementById('register-form').addEventListener('submit', async (e) => {
e.preventDefault();
const result = await ready;
document.getElementById('prynt_request_id').value = result?.requestId || '';
e.target.submit();
});
</script>
Identifying on page load, not on submit, means the requestId is usually ready before the user finishes typing. The .catch keeps registration working if the script is blocked.
Fortify: a validation rule in CreateNewUser
Create a rule class that fetches the event and enforces the limit. ValidationRule is the Laravel 10+ interface.
// app/Rules/DeviceAccountLimit.php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Prynt\PryntException;
use Prynt\PryntServer;
class DeviceAccountLimit implements ValidationRule
{
public ?array $event = null;
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_string($value) || $value === '') {
return; // missing id: allow, flag later
}
try {
$this->event = app(PryntServer::class)->getEvent($value);
} catch (PryntException $e) {
if ($e->getCode() === 404) {
$fail('Please reload the page and try again.');
}
return; // Prynt unreachable: fail open
}
$limit = config('services.prynt.max_accounts_per_device');
$count = $this->event['accountsOnDevice']['count'] ?? 0;
if (! empty($this->event['linkedId'])) {
$fail('Please reload the page and try again.'); // requestId already used
} elseif (($this->event['decision'] ?? null) === 'block' || $count >= $limit) {
$fail('We could not create an account from this device.');
}
}
}
Then wire it into the action Fortify already calls for every registration:
// app/Actions/Fortify/CreateNewUser.php
public function create(array $input): User
{
$deviceRule = new DeviceAccountLimit();
Validator::make($input, [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', Rule::unique(User::class)],
'password' => $this->passwordRules(),
'prynt_request_id' => ['nullable', 'string', 'max:64', $deviceRule],
])->validate();
$user = User::create([
'name' => $input['name'],
'email' => $input['email'],
'password' => Hash::make($input['password']),
'prynt_flagged' => $deviceRule->event === null,
]);
if ($deviceRule->event) {
app(PryntServer::class)->updateEvent($input['prynt_request_id'], (string) $user->id);
}
return $user;
}
A few things are happening here:
- The limit.
accountsOnDevice.countis the number of your account ids already linked to this device. With the default of 2, a household can have two accounts before the third is refused. Use 1 if the rule is strictly one trial per device. - Replays. An event that already has a
linkedIdwas used for an earlier registration. Refuse it instead of linking again, because a secondupdateEventwould overwrite the first account on that event. - Linking.
updateEventattaches the new user id, so the next registration from this device sees it inaccountsOnDevice. - Missing ids. The account is created with a
prynt_flaggedcolumn (add it in a migration). Gate the costly part of your trial on email or phone verification for those users.
If you want a freshness check too, compare the event’s createdAt with the current time and treat anything older than about 15 minutes as stale. That stops someone harvesting one clean requestId and reusing it for a script.
Breeze: the same check as middleware
Breeze puts registration in RegisteredUserController@store. You can add the same rule to its $request->validate() call, but a middleware keeps the controller untouched and is easy to reuse on other sensitive routes.
// app/Http/Middleware/CheckDeviceAccounts.php
public function handle(Request $request, Closure $next): Response
{
$rid = (string) $request->input('prynt_request_id', '');
if ($rid !== '') {
try {
$event = app(PryntServer::class)->getEvent($rid);
$count = $event['accountsOnDevice']['count'] ?? 0;
if (($event['decision'] ?? null) === 'block'
|| $count >= config('services.prynt.max_accounts_per_device')) {
return back()->withErrors(['email' => 'We could not create an account from this device.'])->withInput();
}
$request->attributes->set('prynt_event', $event);
} catch (PryntException $e) {
// fail open; the controller sees no event and flags the user
}
}
return $next($request);
}
Apply it to the POST route in routes/auth.php:
Route::post('register', [RegisteredUserController::class, 'store'])
->middleware(CheckDeviceAccounts::class);
In the controller, after User::create(...), read $request->attributes->get('prynt_event') and call updateEvent($rid, (string) $user->id) when it is present.
Choosing the response
Blocking outright is the right call only when every extra account costs real money. For most apps, a softer path works better: create the account but withhold credits until a phone or card is verified when the device already has accounts. Use decision and riskScore from the event to separate the obvious automation from the shared family laptop. The reason codes in risk.reasons tell you which signal drove it, which matters when support has to answer an angry email.
For the general PHP integration, including WordPress, see PHP fraud detection integration. For how to pick a limit, read device-based sign-up limits, and for the economics of farming, stopping free-trial farming. The server SDK reference is on the SDKs page.
Ship it in observe-only mode first: log the count and decision for a week without refusing anyone, then set the limit from what you see.
Try it free
Prynt is device intelligence with a free tier — visitor IDs, bot & fraud Smart Signals, and behavioral biometrics, powered by a cross-site network. Start free.