Sentinel ships official SDKs for Node and Python but not PHP, which is fine — the API is a single POST and Laravel's HTTP client handles it in a few lines. This is the integration written the way a Laravel application would actually structure it: a service class, middleware, and a config entry.
No package to install, nothing to wait for.
Config and service
Start with a config entry so the key is never inlined, then a small service class you can bind and fake in tests.
// config/services.php
'sentinel' => [
'key' => env('SENTINEL_KEY'),
'url' => env('SENTINEL_URL', 'https://sntlhq.com'),
'timeout' => env('SENTINEL_TIMEOUT', 4),
],
The service class
<?php
// app/Services/Sentinel.php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class Sentinel
{
public function evaluate(
string $token,
?string $fingerprintEventId = null,
?string $accountId = null,
?string $email = null
): array {
try {
$response = Http::withToken(config('services.sentinel.key'))
->timeout(config('services.sentinel.timeout'))
->acceptJson()
->post(config('services.sentinel.url').'/v1/evaluate', array_filter([
'token' => $token,
'fingerprintEventId' => $fingerprintEventId,
'accountId' => $accountId,
'email' => $email,
]));
if ($response->failed()) {
Log::warning('sentinel http error', ['status' => $response->status()]);
return $this->degraded();
}
return $response->json();
} catch (\Throwable $e) {
// Fail open: a detection outage must not become a signup outage.
Log::error('sentinel unavailable', ['error' => $e->getMessage()]);
return $this->degraded();
}
}
private function degraded(): array
{
return ['decision' => 'allow', 'risk_score' => 0, 'reasons' => [], 'degraded' => true];
}
}
Middleware, parameterised by strictness
Laravel middleware parameters make the per-action threshold natural — sentinel:review on the routes that deserve it, sentinel elsewhere.
<?php
// app/Http/Middleware/SentinelGuard.php
namespace App\Http\Middleware;
use App\Services\Sentinel;
use Closure;
use Illuminate\Http\Request;
class SentinelGuard
{
public function __construct(private Sentinel $sentinel) {}
public function handle(Request $request, Closure $next, string $blockAt = 'block')
{
$token = $request->input('monocle') ?? $request->input('token');
if (! $token) {
// Ad blockers and CSP failures legitimately stop the collector.
$request->attributes->set('sentinel', ['decision' => 'allow', 'degraded' => true]);
return $next($request);
}
$verdict = $this->sentinel->evaluate(
$token,
$request->input('sentinel_fp'),
optional($request->user())->id,
$request->input('email'),
);
$request->attributes->set('sentinel', $verdict);
// Route on decision — isSuspicious does not cover Tor or datacenter.
$refuse = $blockAt === 'review'
? $verdict['decision'] !== 'allow'
: $verdict['decision'] === 'block';
if ($refuse) {
return response()->json(['error' => 'Request declined'], 403);
}
return $next($request);
}
}
Wiring the routes
Register the alias, then apply it where the cost of being wrong justifies it.
// bootstrap/app.php (Laravel 11+)
->withMiddleware(function (Middleware $middleware) {
$middleware->alias(['sentinel' => \App\Http\Middleware\SentinelGuard::class]);
})
// routes/web.php
Route::post('/register', RegisterController::class)
->middleware('sentinel'); // block only the worst band
Route::post('/promo/redeem', RedeemController::class)
->middleware(['auth', 'sentinel:review']); // strictest gate on the site
Route::post('/account/password', PasswordController::class)
->middleware(['auth', 'sentinel:review']);
The Blade side
Load the collector once in your layout and mark the forms you want evaluated. The SDK injects monocle and sentinel_fp as hidden fields, which the middleware above already reads.
{{-- resources/views/layouts/app.blade.php --}}
<script async src="https://sntlhq.com/assets/sentinel.js"></script>
{{-- resources/views/auth/register.blade.php --}}
<form method="POST" action="/register" class="monocle-enriched">
@csrf
<input type="email" name="email" required>
<input type="password" name="password" required>
<button type="submit">Create account</button>
</form>
Testing it without hitting the network
Because the service uses Laravel's HTTP client, Http::fake() covers the whole thing — including the failure paths, which are the ones worth asserting.
use Illuminate\Support\Facades\Http;
public function test_blocked_signup_is_refused(): void
{
Http::fake(['*/v1/evaluate' => Http::response(['decision' => 'block', 'risk_score' => 95], 200)]);
$this->post('/register', ['email' => '[email protected]', 'password' => 'x', 'monocle' => 'tok'])
->assertStatus(403);
}
public function test_signup_survives_a_detection_outage(): void
{
Http::fake(['*/v1/evaluate' => Http::response(null, 500)]);
// Fail open: the signup must still succeed.
$this->post('/register', ['email' => '[email protected]', 'password' => 'x', 'monocle' => 'tok'])
->assertSuccessful();
}Frequently Asked Questions
Is there an official PHP or Laravel SDK for Sentinel?
No. Official SDKs exist for Node (@sentinelsup/sdk) and Python (the sentinelsup package), and there is no PHP package. It is not a gap worth waiting on: the API is a single authenticated POST to /v1/evaluate, and Laravel's HTTP client wraps it in a few lines inside a service class you can bind and fake in tests. That also avoids taking a dependency you would need to keep updated.
Where should the Sentinel check go in a Laravel app?
In middleware, parameterised by strictness, applied per route. Laravel middleware parameters map naturally onto action risk: plain sentinel on registration so only the worst band is refused, and sentinel:review on promotion redemption and password changes where anything that is not clean should be declined. Putting it in a controller works but tends to get duplicated; putting it globally applies one threshold to actions whose costs are not comparable.
How do I test the integration without calling the API?
Use Http::fake() with a pattern matching */v1/evaluate. Because the service class goes through Laravel's HTTP client rather than raw cURL, faking covers both the success paths and the failure paths. The failure cases are the ones actually worth asserting: a 500 or a timeout should leave the signup succeeding, since failing closed turns a detection outage into a signup outage.
What should happen when the request has no token?
Let it through, marked degraded, for ordinary actions. An ad blocker or a content-security-policy problem can legitimately prevent the client collector from running, and refusing those visitors blocks real customers for a reason that is not their fault. On genuinely high-risk actions you can require the token instead, because there the cost of letting an unscreened request through outweighs the occasional false positive.
Add it to your Laravel app
No package required. One POST, sub-40ms server-side p95.
Try Sentinel free →