laravel/agent-detector
Laravel Agent Detector is a lightweight PHP 8.2+ utility for detecting whether your code runs inside an AI agent or automated development environment, with known-agent checks and a simple standalone detectAgent() helper.
Install and confirm the shape of the API
composer require laravel/agent-detector # requires PHP 8.2+
Note this is not a typical Laravel package: there is no service provider, no facade, no published config file, and no Blade directives. It is a framework-agnostic static utility that gets pulled in and called directly. Don't go looking for config/agent-detector.php — it doesn't exist.
One call gives you everything:
use Laravel\AgentDetector\AgentDetector;
use Laravel\AgentDetector\KnownAgent;
$result = AgentDetector::detect();
$result->isAgent; // bool — am I inside *any* recognized agent?
$result->name; // string — the detected agent's name
$result->knownAgent(); // ?KnownAgent — null when it's a custom/unknown agent
There is also a standalone function if you're in a non-Laravel context (a Composer script, a standalone tool in bin/, a Pest bootstrap):
use function Laravel\AgentDetector\detectAgent;
Where to look first
KnownAgent enum case. Read the table, not the source.AgentDetector::AGENT_ENV_VARS is a public constant (made visible in v2.0.1). This is your primary extension point and the fastest way to answer "what exactly is being checked?" at runtime — dump it in php artisan tinker.KnownAgent::label() (added in v2.0.1) — use this for anything human-facing rather than hardcoding enum case names.Your first use case: stop prompting a robot
The single highest-value day-one integration is making interactive Artisan commands non-interactive when an agent is driving:
public function handle(): int
{
if (AgentDetector::detect()->isAgent) {
// Agents can't answer a confirm() prompt; assume the safe default.
$this->line('Agent environment detected — running non-interactively.');
$this->call('migrate', ['--force' => true]);
}
}
That alone removes a whole class of "the agent hung for 120 seconds and then timed out" failures.
Resolve once, inject everywhere
detect() is cheap (a handful of env lookups), but calling it scattered across controllers, commands and jobs creates duplication and inconsistency. Bind it once and treat it as read-only request context:
// AppServiceProvider::register()
$this->app->singleton(AgentResult::class, fn () => AgentDetector::detect());
Then inject AgentResult (or a thin AgentContext wrapper of your own) into commands, jobs, and services. A singleton is correct here because the environment cannot change mid-process — it is not a per-request value.
Log and tag by agent identity
Use KnownAgent->value for machine tokens (stable, greppable, safe for telemetry dimensions) and label() for humans. Always include a fallback path for custom agents:
$result = AgentDetector::detect();
$agentTag = $result->knownAgent()?->value ?? ($result->isAgent ? 'custom' : 'human');
Log::withContext(['agent' => $agentTag]);
That context line pays for itself the first time a weird bug turns out to only reproduce under Claude Code or Cursor.
Determinism guards in seeders, factories, and fixtures
Agents run your test suite and your db:seed over and over. Seeding with random_int() or now()-derived values produces churn that pollutes diffs the agent then "fixes" incorrectly. Gate randomness behind detection:
$faker->seed(AgentDetector::detect()->isAgent ? 1234 : random_int(1, PHP_INT_MAX));
The same applies to snapshot tests, generated fixture files, and anything that writes timestamps into committed files.
Output hygiene for agent-consumed commands
An agent parsing your command output wants machine-readable, low-noise text. Detect and adapt:
--no-ansi, or conditionally skip $this->progressBar()).--format=json output that you can default-on when detected.$this->openInBrowser(), dump(), interactive table pagination).--no-interaction semantics at the command level rather than relying on Laravel's global flag.Distinguish "agent" from "CI"
These overlap but are not the same question, and conflating them is a common source of wrong branches. Keep a separate predicate:
$isCi = (bool) env('CI');
$isAgent = AgentDetector::detect()->isAgent;
// Agent ≠ CI: a dev running Claude locally on their laptop is not CI,
// and a CI pipeline running a script is not an agent.
Use $isAgent to change interaction style and $isCi to change credential/store selection.
Custom agents via AI_AGENT
If you're standardizing an internal agent, a CI image, or a bespoke script runner, set AI_AGENT=my-custom-agent and it's detected — isAgent === true with knownAgent() === null. Prefer this over forking the package or hardcoding your own env-var check. It also gives you a testable switch in development: run AI_AGENT=my-agent php artisan whatever to reproduce agent-mode behavior on your own machine, no agent required.
Testing detection without an agent
Set the env var in phpunit.xml per-suite, or override it in a test. The package reads the process environment, so:
public function test_command_runs_non_interactively_for_agents(): void
{
putenv('AI_AGENT=test-agent');
$_SERVER['AI_AGENT'] = $_ENV['AI_AGENT'] = 'test-agent';
$this->artisan('your:command')
->expectsOutputToContain('Agent environment detected')
->assertSuccessful();
}
If the flag doesn't flip, set $_SERVER as well as putenv — different readers consult different sources, and that's the usual culprit.
Check isAgent before knownAgent() — always. knownAgent() is null for custom agents, and a large fraction of real detections are custom or AI_AGENT-based. Code that branches only on the enum silently treats real agent runs as human runs. Write if (! $result->isAgent) first, then switch on identity.
Never match on KnownAgent without a default arm. The enum grows with every minor release (six new cases in v2.0.4). A match ($knownAgent) will start throwing UnhandledMatchError the day you composer update and an agent you didn't know about appears. Use default =>, or match (true), or a foreach/array lookup. This is the single most likely way this package breaks your app.
AI_AGENT is overloaded, and ordering matters. It serves three jobs: an arbitrary custom-agent value, AI_AGENT=github-copilot / github-copilot-cli for Copilot, AI_AGENT=v0 for v0, and — since v2.0.2 — a Claude prefix match. So AI_AGENT=claude-something resolves to Claude, not "custom", and AI_AGENT=copilot-ish may not do what you expect. If you're inventing a custom value, don't pick something that prefixes an existing agent name.
Don't cache detection in config. The tempting move — 'is_agent' => AgentDetector::detect()->isAgent in a config file — breaks under php artisan config:cache. The value is frozen at cache-build time (often during a Docker build or a deploy step), so a config cached on your laptop can permanently report "not an agent" inside the agent's runtime. Read the environment at call time.
Detection reflects the process that started your server, not the one making the request. If an agent runs php artisan serve, the built-in PHP server inherits the agent's env vars and detection works. If the agent just sends HTTP requests to a server you started in another terminal, your app sees a totally normal request — there is nothing to detect. Detection is about the process, not the wire. This surprises people constantly.
This is a heuristic, not a security boundary. Every detection path except the Devin filesystem check is a user-settable environment variable. Anyone can set AI_AGENT=x and flip it. Never gate authorization, billing, rate limits, destructive-operation approval, or "trusted mode" on it. It's a UX switch and an observability tag.
Filesystem-based detection needs the filesystem. Devin is detected via /opt/.devin existing. Inside a container that doesn't bind-mount it, detection simply won't fire — and conversely, a developer who happens to have that path will be misdetected. Don't add more filesystem checks without accounting for both directions.
No service provider means no auto-discovery magic. There is nothing to disable, nothing to publish, and no extension points beyond what's public. Your options for extending are: (a) read AgentDetector::AGENT_ENV_VARS and add your own checks alongside it, or (b) maintain a small app-level enum for internal agents and map from AgentResult. Keep these in your own namespace — forking means re-merging a table that changes every few weeks.
Pin ^2.0 and watch for the namespace change. v2.0.0 renamed everything from AgentDetector\ to Laravel\AgentDetector\, and AgentResult::__construct no longer takes $isAgent (it's a public readonly property now). Any tutorial or LLM-generated snippet using the old namespace is v1-era garbage — this is a package agents will happily "help" you write wrong.
Long-running workers are fine; per-request mutation is not. Under Octane or a queue worker the result is stable for the process lifetime, so resolving it once is safe. What is not safe is writing to the AgentResult or stashing request-specific data on a singleton keyed by agent. Keep the singleton immutable.
Zero dependents means you're the QA. With essentially no downstream users, obscure platform quirks (a new agent shipping next week, a Copilot env-var rename, a Windows path assumption) will surface in your app first. If you rely on this for a hard behavioral branch, add a test that asserts your fallback path works when detection returns an unknown agent — that's the case most likely to arrive without warning.
How can I help you explore Laravel packages today?