jayanta/laravel-ai-guard
Laravel AI Guard adds a lightweight middleware layer that detects, logs, and blocks unwanted AI traffic hitting your app, and lets you gate AI-powered endpoints behind simple, configurable rules—keeping scrapers and costly requests under control.
Install / upgrade
composer require jayanta/laravel-ai-guard # new installs
composer update jayanta/laravel-ai-guard # existing installs → 3.1.0
Requirements are broad: Laravel 10–13, PHP 8.1–8.4. If you publish the config, check for a new interop block in 3.1.0 — this is the only switch that matters for the new feature, and it is additive (nothing fires unless you listen and interop is enabled).
Where to look first
tests/Fixtures/interop/verdict-v1.json — this is the canonical payload shape. The package's own suite fails if it drifts, so treat it as your schema of record, not the docs.CHANGELOG.md for the 3.0.1 → 3.1.0 delta (events + a telemetry version fix).First use case: "who is hitting this endpoint?"
Register a listener in AppServiceProvider::boot() (Laravel 11+) or EventServiceProvider (Laravel 10) and log the verdict:
use Illuminate\Support\Facades\Event;
Event::listen('JayAnta\AiGuard\Events\BotClassified', function (object $event) {
if (($event->schema ?? null) !== 'ai-guard.verdict/1') {
return; // forward-compat guard
}
logger()->info('bot classified', [
'identity' => $event->identity, // 'chatgpt.com'
'category' => $event->category, // 'ai_agents' | 'search_engines' | ...
'method' => $event->method, // 'web_bot_auth' | ...
]);
});
That single listener is enough to prove the wiring works. From there, everything else in this assessment is about what you do with the verdict.
Subscribe by string, never by class. The whole point of the interop contract is that consumers keep working if AI Guard is uninstalled. Use Event::listen('JayAnta\AiGuard\Events\AgentVerified', ...) with a (object $event) type hint, not use JayAnta\AiGuard\Events\AgentVerified; with a concrete parameter type. A string listener for a class that no longer exists is a no-op; a typed listener with a missing class is a fatal error in an autoloader that resolves at call time. This is the single most important convention in the release.
Fan-out listener. Rather than duplicating the schema guard in five places, register one subscriber that normalises the verdict and forwards it to app-level concerns:
Event::listen([
'JayAnta\AiGuard\Events\BotClassified',
'JayAnta\AiGuard\Events\AgentVerified',
'JayAnta\AiGuard\Events\SpoofedBotDetected',
], function (object $event) {
if (($event->schema ?? null) !== 'ai-guard.verdict/1') {
return;
}
dispatch(new RecordBotVerdict(
identity: $event->identity,
category: $event->category,
method: $event->method,
));
});
Payloads are documented as scalar, read-only and queueable, so serialising them into a job is explicitly supported — do the database write off-request.
Middleware for access decisions. If you only need a yes/no at the edge (e.g. "don't burn LLM tokens on scrapers"), read the request attribute instead of wiring an event:
public function handle(Request $request, Closure $next): Response
{
$verdict = $request->attributes->get('ai_guard.verdict');
if ($verdict && ($verdict->schema ?? null) === 'ai-guard.verdict/1'
&& $verdict->category === 'ai_agents') {
// rate-limit, meter, or block
}
return $next($request);
}
This is the cheapest integration: no listeners, no queue, no persistence, and it composes with whatever throttle/can logic you already have.
Use it as a cross-package contract. The real leverage is other first-party packages and services depending on ai-guard.verdict/1 without depending on AI Guard. An analytics package, a WAF-ish middleware, a billing meter — each listens by event name and degrades to "unknown" when the package is absent. Keep the schema string check in every consumer; that is the versioning handshake.
Audit trail for spoofing. SpoofedBotDetected is the highest-signal event in the set: the client claimed a verifiable identity (Web Bot Auth signature, published IP ranges, reverse DNS) and failed every check. Persist these — they are far more interesting for security review than generic bot traffic, and they arrive at most once per request, so volume is bounded.
Testing. Use Event::fake() and assert against the string name, then assert the payload shape against verdict-v1.json-derived fixtures:
Event::fake(['JayAnta\AiGuard\Events\AgentVerified']);
$this->get('/pricing');
Event::assertDispatched('JayAnta\AiGuard\Events\AgentVerified', function ($event) {
return $event->schema === 'ai-guard.verdict/1'
&& $event->method === 'web_bot_auth';
});
Add one test per consumer asserting that a verdict with an unknown schema value (e.g. ai-guard.verdict/2) is ignored, not crashed on.
Gradual rollout. Ship listeners behind interop.enabled (the package switch) plus your own feature flag. Because nothing fires unless listened to, you can deploy the package wide and enable consumers one at a time.
Events overlap — dedupe deliberately. BotClassified fires for known bots and for verified/spoofed identities, so a spoofed crawler will trigger both BotClassified and SpoofedBotDetected for the same request. If you act on both, you will double-count, double-meter or double-alert. Pick a primary event per consumer (e.g. billing on BotClassified, security on SpoofedBotDetected) or key off the request ID.
Identity ≠ policy. A verified Googlebot reports category => 'search_engines' even though that category is not blocked by default. Do not infer "this request was blocked" or "this request was allowed" from the event. The events describe who the client is; your access logic is separate. This is the intended design, and assuming otherwise is the most likely source of a subtle bug.
Absence of an event means "not evaluated", not "human". Whitelisted requests are never evaluated, and interop can be switched off entirely. Any downstream logic must have an explicit unknown branch. Never write no event → treat as human → exempt from rate limiting.
Always null-check the request attribute. request()->attributes->get('ai_guard.verdict') returns null for whitelisted requests, for requests the package didn't run on, and (verify this in your setup) potentially when interop.enabled => false. Check the attribute exists and has the expected schema before touching identity/category.
Keep the schema guard, even though it feels redundant. Within v1, fields may be added but never renamed or removed — so a missing schema check won't break today. It exists for the day someone ships ai-guard.verdict/2 or you swap the package out. It costs three lines and is the entire stability story.
Listeners must be fast and must not throw — but you lose the work. The package protects the request from a throwing listener, which means exceptions in your listener are swallowed rather than surfaced. Wrap your own logic in a try/catch with logging, or your bot analytics will silently stop writing and you will not find out until the dashboards look quiet.
Respect the "at most once per request" guarantee. You cannot use these events to count how many checks ran, or to observe a re-classification after a redirect. If you need per-check granularity, you are on the wrong hook.
Treat identity as untrusted until method says otherwise. A spoofed client's claimed identity is exactly the string you least want to allowlist on. Only key trust decisions off identity when method is one of the verified methods (web_bot_auth, published-IP-range match, reverse DNS) — and even then, log the method alongside the identity so a future audit can tell how the decision was reached.
Long-running workers. Under Octane or in queue workers, request attributes are per-request; don't stash the verdict on a singleton or a static. Queue the scalar payload instead, as the fan-out pattern above does.
Version telemetry fix. If you consume SIEM or OpenTelemetry exports, 3.0.1 mis-reported the package version as 3.0.0. Upgrade if you filter or alert on package version, or your dashboards will keep showing stale data.
Extension point worth knowing: interop.enabled => false is your emergency off-switch if a consumer listener misbehaves in production — it stops the events without touching classification or blocking behaviour.
Maintenance signal: 482 tests, PHPStan level 6, Pint, on a package that now publishes a versioned public contract. The fixture-pinning test (verdict-v1.json) is the thing to watch in future releases — if it changes, treat it as a breaking change to your consumers regardless of the semver number.
How can I help you explore Laravel packages today?