What the agent does
The agent starts by reading what the app exposes and how it is secured, because the right limiter for a login form is not the right one for a comment endpoint. In an app like symfony/demo, composer.json requires PHP >=8.4 and Symfony ^8 with security-bundle, form, validator and monolog-bundle, but not symfony/rate-limiter. config/packages/security.yaml defines one main firewall with form_login on the security_login route, CSRF on, remember_me for a week and no login_throttling. config/routes.yaml prefixes every controller route with /{_locale}, so the login form posts to /en/login. src/Controller/BlogController.php has commentNew on POST /comment/{postSlug}/new, guarded by IsGranted('IS_AUTHENTICATED'), which persists a Comment whose entity validates five to ten thousand characters and an is_spam rule, and a blog_search action answering the search box.
It also reads how state and tests are set up, because a limiter stores counters and the tests roll back the database. config/packages/cache.yaml leaves cache.app on the filesystem adapter, which the RateLimiter component uses by default for its storage. phpunit.dist.xml registers the DAMA Doctrine extension so every test runs inside a transaction, and tests/Controller/BlogControllerTest.php already submits the Publish comment form with comment[content], while UserControllerTest.php shows how the suite logs in. .github/workflows/tests.yaml runs PHPUnit on PHP 8.4 and 8.5, and lint.yaml runs PHP-CS-Fixer, lint:container, PHPStan and composer audit, so any new service has to pass all of them.
From that it runs composer require symfony/rate-limiter, which updates composer.json, composer.lock and symfony.lock, and writes config/packages/rate_limiter.yaml with two limiters: comment_new, a token bucket keyed per user, and blog_search, a sliding window keyed per client address. Under the main firewall it adds login_throttling with max_attempts and interval values stated in the pull request, which is the component's built-in protection for form_login and needs no controller change. In commentNew it injects the RateLimiterFactory for comment_new by argument name, consumes one token before the form is handled, and on refusal returns a 429 response rendered through the existing error templates with a Retry-After header; blog_search gets the same treatment with a JSON body because that action answers the search box. A when@test block points the limiter storage at an array cache so counters do not leak between tests.
Then it writes the tests and runs everything the workflows run. A new SecurityControllerTest submits the login form with a wrong password past the attempt limit and asserts the throttling error on the next try, and a BlogControllerTest case posts comments past the bucket size and asserts the 429 and the header, logging in the way the existing tests do. It runs vendor/bin/phpunit, vendor/bin/php-cs-fixer check --diff, vendor/bin/phpstan analyze, bin/console lint:container --no-debug and bin/console lint:yaml config --parse-tags in the sandbox, and fixes anything until they all pass. The SQLite database from .env means no service has to be installed to run the suite.
Start it with one API call
POST https://api.prix.dev/o/{org}/sessions with this body:
{
"agent": "claude",
"input": "Add rate limiting to this Symfony app. Read composer.json, config/packages/security.yaml, config/packages/cache.yaml, config/packages/framework.yaml, config/routes.yaml, src/Controller/SecurityController.php, src/Controller/BlogController.php, src/Entity/Comment.php, phpunit.dist.xml, tests/Controller/BlogControllerTest.php, tests/Controller/UserControllerTest.php and the workflows under .github/workflows/ first. Install symfony/rate-limiter with composer. Enable login_throttling on the main firewall with max_attempts and an interval you state in the pull request. Define a per-user token bucket limiter for the comment_new action and a per-address sliding window limiter for the blog_search action, inject each RateLimiterFactory by name, consume before handling the request, and answer a refusal with 429 and a Retry-After header, HTML for the comment form and JSON for the search box. Point the limiter storage at an array cache in the test environment. Write functional tests for the login throttle and the comment limit using the same login approach the existing tests use. Run phpunit, php-cs-fixer check, phpstan, lint:container and lint:yaml in the sandbox, fix anything until they pass, and open a pull request that lists each limiter, its key, its limits and how to change them.",
"resources": [
{
"url": "https://github.com/your-org/your-app"
}
]
}- Get an API key. In the console, open Settings, then API keys, and create a key for your org. Send it in the Authorization header as a Bearer token.
- Send the request. POST the body in request.json to https://api.prix.dev/o/{org}/sessions, with your repository as the resources URL. The run starts in its own cloud sandbox.
- Read what came back. Follow the run on its live stream or wait for the run.completed webhook, then read the result and its trajectory.
What comes back
What comes back is a pull request on its own branch with the dependency change in composer.json, composer.lock and symfony.lock, the new config/packages/rate_limiter.yaml, the login_throttling block in security.yaml, the two controller changes, the test-environment storage override and two new test cases. The description lists each limiter with its policy, key, limit and interval, the configuration keys that change them, and why login throttling needed no controller code.
The transcript on the session keeps every command with its output: the composer require, each PHPUnit run including the one that proved the 429, PHP-CS-Fixer, PHPStan and the container and YAML lints. The repository's own Tests and Lint workflows run on the pull request too, so the PHP 8.4 and 8.5 matrix, composer audit and doctrine:schema:validate report beside the diff.
Before merging, a reviewer checks that the login limits fit real users who mistype a password, that the comment bucket size matches how fast a person can plausibly post, that the client address key reads the trusted proxy headers in production, and that the filesystem cache is acceptable for the counters or should move to Redis on a multi-server deployment. One follow-up message to the same session adds a limiter to another action, since the factory pattern is already in place.
Run it on every Linear issue labelled rate-limit
A team that tracks hardening work in Linear starts one run per issue from an automation with a linear trigger on the rate-limit label. The automation adds the issue after this prompt, so the agent reads which routes and limits the issue asks for and starts in the repository the automation names.
Each run opens its own pull request from its own sandbox, and an automation run has its own max_runs_per_hour cap. The pull request links back to the issue, and the transcript holds the PHPUnit, PHPStan and lint output for that change.
The use case this job follows, with its automation and trigger: Turn a Linear label into a PR.
Why run it on Rush?
The limiter itself is a few lines; the cost is in proving it under a strict toolchain. PHPStan, PHP-CS-Fixer and the container lint each reject something slightly off, the functional test has to spend a limit inside a transaction-wrapped suite, and the counters must not bleed from one test into the next. On a laptop that is a cycle of edit, run the suite, read a lint error, run again. In a Rush sandbox the agent has its own PHP, its own SQLite file and its own cache directory, and the sandbox parks when the turn ends.
The pull request carries its own proof. The throttled login attempt, the 429 on the comment form, the green PHPStan and lint runs are in the transcript beside the commands that produced them, so a reviewer can see the limits trigger and the toolchain pass rather than trusting the description.
Frequently asked questions
Why is login throttling configured in security.yaml instead of the controller?
Because form_login on the main firewall is handled by the security component, not by SecurityController, which only renders the form. The login_throttling option plugs the RateLimiter component into that authenticator, counts failures per username and per address, and shows the user the component's own error message, so no controller code is needed.
Where are the counters stored?
In the cache.app pool, which cache.yaml leaves on the filesystem adapter. That is correct for one server and per-server for several. The pull request says so, and the test environment uses an array cache so each test starts clean. Moving the pool to Redis is a one-line change in cache.yaml that a follow-up message can make.
Will the existing comment test still pass?
Yes. BlogControllerTest::testNewComment posts one comment, which is within any sensible bucket size, and the array cache in the test environment means no earlier test has consumed tokens for that user. The new case posts past the limit on purpose and asserts the 429.
Does it limit the search box too?
Yes, with a per-address sliding window on blog_search, because that action runs a query for every keystroke the search component sends. A refusal returns a JSON body with a 429 and Retry-After, so the component can show a short message instead of an error page.