Laravel
Laravel's HTTP client wraps Guzzle and is already installed. The Laravel-specific trap is $request->ip(), which returns your proxy's address unless TrustProxies is configured — and the default configuration does not trust anything.
Setup
- 1Add the key to .env as LAYERCALL_KEY and reference it through config/services.php, never env() directly outside config.
- 2Configure TrustProxies so $request->ip() returns the visitor, not your load balancer.
- 3POST to https://www.layercall.com/v1/score/user with Http::withHeaders()->timeout().
- 4Fail the validator on block so the form redirects back with the message.
- 5php artisan config:cache after any config change, or production keeps reading the old value.
Code
<?php namespace App\Rules; use Closure; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; class NotFraudulent implements ValidationRule { public function __construct(private string $ip) {} public function validate(string $attribute, mixed $value, Closure $fail): void { try { $res = Http::withHeaders([ 'X-Api-Key' => config('services.layercall.key'), ]) ->timeout(5) ->post('https://www.layercall.com/v1/score/user', [ 'ip' => $this->ip, 'email' => $value, ]); $res->throw(); } catch (\Throwable $e) { Log::warning('layercall unavailable', ['e' => $e->getMessage()]); return; // fail open } if ($res->json('verdict') === 'block') { $fail($res->json('summary')); } } } // Usage in a controller or FormRequest: // // $request->validate([ // 'email' => ['required', 'email', new NotFraudulent($request->ip())], // ]);
What to do with each verdict
Three outcomes, and the middle one is the one worth getting right — refusing a real customer usually costs more than reviewing them.
| Verdict | In Laravel |
|---|---|
| allow | Validation passes and the controller continues. |
| review | Do not fail the rule. Store the verdict on the user row and dispatch a job to notify whoever reviews — blocking a legitimate signup costs more than reviewing it late. |
| block | $fail($res->json('summary')). Laravel puts it straight into the error bag, and the summary is already written to be read by a person. |
What comes back
A real response, generated from the live API rather than written by hand. Branch on verdict; summary is a sentence written to be shown to a person, and components_checked tells you what actually went into the score.
Show the full response(POST /v1/score/user)
{ "risk_score": 65, "verdict": "review", "summary": "Needs review (65/100) — Tor exit node, commercial VPN and datacenter ASN.", "components": { "email": { "email": "test@guerrillamail.com", "normalized_email": "test@guerrillamail.com", "risk_score": 100, "verdict": "block", "status": "do_not_mail", "sub_status": "disposable", "deliverability_score": 0, "did_you_mean": null, "signals": { "syntax_valid": true, "mx_found": true, "is_disposable": true, "is_homograph": false, "is_role_account": true, "is_free_provider": false, "is_suspicious_handle": true, "is_tagged": false, "is_risky_tld": false, "is_new_domain": null, "has_spf": true, "has_dmarc": true, "has_website": true, "mailbox_exists": null, "is_catch_all": null, "mailbox_status": "unavailable", "has_digital_footprint": null }, "domain": "guerrillamail.com", "domain_age_days": null, "mx_provider": null, "mx_records": [ "mail.guerrillamail.com." ], "abuse_reports": 0, "digital_footprint": { "has_gravatar": false, "gravatar_profile_url": null, "breach_count": null, "seen_in_breach": null } }, "phone": { "parse_status": "ok", "phone": "+14155552671", "risk_score": 0, "signals": { "syntax_valid": true, "is_possible": true, "is_voip": false, "is_premium_rate": false, "is_toll_free": false, "assigned_area_code": true, "is_fictional": false }, "number": { "e164": "+14155552671", "country": "US", "national": "(415) 555-2671", "international": "+1 415 555 2671", "line_type": "fixed_line_or_mobile" }, "verdict": "allow", "abuse_reports": 0 }, "ip": { "ip": "185.220.101.1", "risk_score": 60, "signals": { "is_vpn": true, "is_proxy": true, "is_datacenter": true, "is_tor": true, "recent_abuse": false, "is_hijacked_netblock": false }, "geo": { "country": "DE", "city": "Berlin", "asn": "AS60729", "isp": "Stiftung Erneuerbare Freiheit" }, "vpn_provider": null, "hijacked_source": null, "verdict": "review", "abuse_reports": 0 } }, "components_checked": [ "email", "phone", "ip" ], "linkage": { "device_email_count": null, "email_device_count": 0, "email_ip_count": 0, "subnet_rate_1h": 2, "domain_rate_1h": 0 }, "actor": { "type": "unknown", "proven": false, "basis": "none", "operator": null, "trigger": null, "detail": "No signature and no device fingerprint. Drop fp.js on the page, or pass the agent's signed request, to get an answer here." }, "top_signals": [ "ip: tor exit node", "ip: commercial vpn", "ip: datacenter asn", "email: disposable domain", "email: role account", "email: machine-generated handle" ] }
Traps specific to Laravel
Laravel ships TrustProxies trusting nothing, which is the safe default but means every visitor behind your load balancer scores as the same datacenter IP. Set the proxies (or '*' if your LB is the only way in) in bootstrap/app.php, or the IP signal is worthless.
After php artisan config:cache, env() outside a config file returns null — so a key read with env('LAYERCALL_KEY') in a rule works locally and 401s in production. Read it through config('services.layercall.key').
Without ->throw() a failed request returns a response object whose json('verdict') is null, which is not 'block', so the rule silently passes everything. That is a fraud check that reports success while doing nothing.
Questions
Rule, middleware or controller?
A rule. It composes with the rest of your validation, shows the message in the right place, and applies anywhere the FormRequest is used. Middleware runs too early to see the validated email; a controller covers one route.
How do I test this without spending lookups?
Http::fake() in your test suite — the rule never leaves the process. For an end-to-end check against the real API, use a tl_test_ key, which returns fixtures and is not billed.