Making the phpstan-drupal playground work for agents
The phpstan-drupal playground exists to reproduce bugs. You paste PHP, run PHPStan with phpstan-drupal against a real Drupal core install, and share the result link in an issue.
It never quite worked right, and I never got around to fixing it. The bugs were small, individually boring, and always someone else's turn on my list. What finally cleared them was working through them with an agent, which is also how the rest of this post happened. The workflow still assumed a person in a browser. Now it works for an agent too.
Two things I wanted:
- A reporter tells their agent, "push this reproduction to the playground," and gets a link to paste into the issue.
- I hand my agent a share link, and it reads the code and the error, then goes to fix the bug.
Neither worked. The site is a Svelte app, so fetching a share link returned an empty <div id="app">. The API hostname was only discoverable by reading the source. The frontend implied the share link format. A dead link returned a 500.
Tell the agent where the API is
The site now serves /llms.txt. It documents the API base URL, the request body, the response shape, and a recipe for filing a bug. The HTML shell served for /r/<uuid> carries a comment and a <noscript> block pointing there, so an agent that fetches a share link and finds nothing can still find its way to the data.
<!--
Agents and other non-browser clients: this page is a JavaScript app.
Shared results at /r/<uuid> are available as Markdown or JSON from the
API. See https://phpstan-drupal.mglaman.dev/llms.txt for the endpoints
and request format.
-->
Nothing fancy. A text file and a comment. That is most of the "agent-friendly" work.
The base URL is an API Gateway hostname, and llms.txt defines it. The examples below assume it is in the environment:
# Current value; llms.txt is the source of truth if this ever moves.
BASE=https://gkyhj54sul.execute-api.us-east-1.amazonaws.com/prod
Return the share link, not just an ID
POST /analyse saved the result and returned an id. The frontend built /r/<id> from it. An agent had no way to know that rule without reading App.svelte. The response now includes the full URL.
curl -s -X POST "$BASE/analyse" \
-H 'Content-Type: application/json' \
-d '{"code":"<?php\n\n$node = \\Drupal::entityTypeManager()->getStorage(\"node\")->load(1);\necho $node->label();","level":"9"}'And the response
{
"id": "89fb8411-671e-4025-b115-d49604285222",
"url": "https://phpstan-drupal.mglaman.dev/r/89fb8411-671e-4025-b115-d49604285222",
"versions": {"phpstan": "2.2.12", "phpstan-drupal": "2.1.2", "drupal": "11.4.5"},
"tabs": [{"title": "PHP 8.3 – 8.4 (1 error)", "errors": [...]}]
}
The agent pastes url into the issue. Done.
Every run mints its own uuid, so yours will differ. Results are public and stored forever, which is the point for a bug report and the wrong thing for an agent iterating on a reduction. Send "saveResult": false while it narrows the snippet down, then one final run with the default to get a link worth sharing.
Render a share link as Markdown
GET /result?id=<uuid> already returned the saved code and errors as JSON, plus a fresh re-run against the current runner. JSON works for an agent, but it has to correlate line numbers with the code on its own, and it has to notice that tabs, the errors saved when the link was created, and upToDateTabs, the same code re-run against today's release, are two different things.
Adding format=markdown renders the whole reproduction in one document.
curl -s "$BASE/result?id=e8500ee1-9159-4fea-a49b-27a46112101d&format=markdown"And the result
# phpstan-drupal playground result
- Share link: https://phpstan-drupal.mglaman.dev/r/e8500ee1-9159-4fea-a49b-27a46112101d
- Level: 9
- Strict rules: on
- Bleeding edge: on
- Treat PHPDoc types as certain: on
## Code
```php
<?php
// ...
```
## Current analysis
Analysed with PHPStan 2.2.12, phpstan-drupal 2.1.2, Drupal 11.4.5.
### PHP 8.3 – 8.4 (1 error)
- Line 22: Dumped type: *NEVER*
- Code: `\PHPStan\dumpType($active);`
- Identifier: `phpstan.dumpType`
- Ignorable: no
The errors saved with this share link match the current analysis.
That is a real link. Fetch it, and you get the same document.
Two details matter for the maintainer workflow. Each error quotes the source line it points at, so the agent does not have to count lines in the code block. And the last line compares what was saved with the link against a re-run on the current release. When they differ, the Markdown shows both sets, and the agent knows the bug was probably already fixed before it starts digging.
Status codes are part of the contract
A malformed body or a missing code field returns 500. A link to a result that does not exist returns 500. An agent treats 500 as "try again", which is the wrong move for both. They now answer 400 and 404 with a JSON error body.
curl -s -w '\n%{http_code}\n' "$BASE/result?id=00000000-0000-0000-0000-000000000000"And the result
{"error":"Result not found"}
404The 404 needed more than a code change. The handler checks for the NoSuchKey error from S3, and the unit tests, which mock S3, passed. Production kept answering 500.
S3 only reports NoSuchKey for a missing object when the caller may list the bucket. Otherwise, it answers 403, so nobody can probe for key names, and the handler never sees the error it was looking for. The role had s3:GetObject on the objects and nothing on the bucket. One IAM statement fixed it.
- Effect: Allow
Action:
- s3:ListBucket
Resource: "arn:aws:s3:::phpstan-drupal-playground"
Mocked tests cannot catch that class of bug. Check production after a deploy that touches error handling around AWS calls.
Try it
Give your agent a phpstan-drupal share link and ask it what the bug is. Or ask it to push a reproduction and hand you the link. The API description it needs is at phpstan-drupal.mglaman.dev/llms.txt, and the playground source is on GitHub.