# Write PHPUnit Tests for a Symfony App

Yes. An agent writes WebTestCase tests for a Symfony admin area, covering the security voter, form validation errors and CSRF, runs PHPUnit and opens a pull request.

[Run this on Rush](https://cloud.getrush.ai/run/write-phpunit-tests/symfony/), in a Project that has your repository.

## What the agent does

The agent reads the test setup before it writes a test, because a Symfony functional suite depends on how the kernel, the database and authentication are wired for the `test` environment. In symfony/demo, `composer.json` requires PHP 8.4 or later, `phpunit/phpunit` 11.5 and `dama/doctrine-test-bundle`, with `symfony/browser-kit` and `symfony/css-selector` as the crawler. `phpunit.dist.xml` boots `tests/bootstrap.php`, turns `failOnDeprecation`, `failOnNotice` and `failOnWarning` on, and registers the DAMA extension that wraps every test in a transaction. `.env.test` points `DATABASE_URL` at `data/database_test.sqlite`, a file committed to the repository, and `config/packages/security.yaml` switches the `main` firewall to HTTP basic under `when@test`. `.github/workflows/tests.yaml` runs `vendor/bin/phpunit` on PHP 8.4 and 8.5 on Ubuntu and on 8.4 on macOS and Windows.

Then it maps what the admin area does against what `tests/Controller/Admin/BlogControllerTest.php` already covers. `src/Controller/Admin/BlogController.php` sits under `/admin/post` behind `#[IsGranted(User::ROLE_ADMIN)]`, with `new`, `show`, `edit` and `delete` actions; `show` calls `denyAccessUnlessGranted(PostVoter::SHOW, $post)`, `edit` and `delete` carry `#[IsGranted('edit')]` and `#[IsGranted('delete')]` on the post, and `delete` checks a CSRF token named `delete` from the request payload before removing anything. `src/Security/PostVoter.php` grants all three only when the logged-in user is the post's author. The existing test logs in as `jane_admin`, checks that `john_user` gets 403 on four URLs, and covers listing, creating, a duplicated title, showing, editing and deleting post 1, which `src/DataFixtures/AppFixtures.php` assigns to `jane_admin`.

The gaps are the paths that test fixture happens to avoid. `tom_admin` has `ROLE_ADMIN` too but is not the author of post 1, so `GET /en/admin/post/1`, `GET /en/admin/post/1/edit` and `POST /en/admin/post/1/delete` as `tom_admin` must be 403 from the voter, not from the role check. `src/Form/PostType.php` adds a `saveAndCreateNew` submit button that redirects to `admin_post_new` instead of the index. `src/Entity/Post.php` declares `NotBlank` on the summary with message `post.blank_summary`, `Length(max: 255)` on it, `Length(min: 10)` on the content with `post.too_short_content`, `Count(max: 4)` on tags with `post.too_many_tags`, and `UniqueEntity` on the slug reported on the title field. A delete request with a wrong token must redirect to the index and leave the post in place.

The agent writes those cases into the existing class, following its conventions: `loginUser` with a user from `UserRepository`, `submitForm` with `post[...]` field names, `assertResponseRedirects` with `Response::HTTP_SEE_OTHER`, and `assertSelectorTextContains('form .invalid-feedback', ...)` with the translated message from `translations/validators+intl-icu.en.xlf`. For the voter cases it clears the cookie jar and logs in as `tom_admin`; for the CSRF case it posts to the delete URL with a made-up token and then reads the post back through `PostRepository`. It runs `vendor/bin/phpunit`, which must stay free of deprecations because of the `failOn` flags, and `php-cs-fixer` on the changed file, then opens a pull request with the suite output.

## Start it with one API call

`POST https://api.prix.dev/o/{org}/sessions`

```json
{
  "agent": "claude",
  "input": "Write PHPUnit functional tests for this Symfony app's admin area, extending tests/Controller/Admin/BlogControllerTest.php in its existing style. Cover: an admin who is not the author getting 403 on show, edit and delete through PostVoter; the saveAndCreateNew button redirecting to the new-post form; validation errors for a blank summary, a summary over 255 characters, content under 10 characters and more than 4 tags, asserting the translated message in form .invalid-feedback; and a delete with an invalid CSRF token that redirects and leaves the post in place. Use loginUser with fixture users, keep production code unchanged, run vendor/bin/phpunit and php-cs-fixer on the changed file, and open a pull request with the suite output.",
  "resources": [
    {
      "url": "https://github.com/your-org/your-app"
    }
  ]
}
```

1. **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.
2. **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.
3. **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 that changes `tests/Controller/Admin/BlogControllerTest.php` and nothing under `src/`. The new methods sit beside the existing ones, use the same `loginUser` and `submitForm` calls, and name the fixture users and the post id they rely on. Its description lists each behavior now covered, quotes the `vendor/bin/phpunit` summary with no deprecation or warning failures, and shows the `php-cs-fixer` result.

Before merging, a reviewer checks that the voter cases log in as an admin who is not the author rather than as a regular user, since the role check would mask the voter; that the validation assertions match the English messages in `translations/validators+intl-icu.en.xlf`; and that the CSRF case asserts the post still exists after the redirect. A test that needed a change in `src/` to pass is a finding to discuss, not a test to merge.

## Run it on every night

Create an automation in the Project with this prompt and a `schedule` trigger (`cron` `0 2 * * *` in your `timezone`). The prompt names one gap, so once these cases are merged, change it to name the next controller or action without tests; each night's run writes the cases its prompt names and opens one pull request by morning, or opens none when they are already covered.

To have the link reach your server, start the nightly run from your own scheduler instead, with `POST /o/{org}/sessions` and an API key that has a webhook URL: Rush then POSTs `run.completed` with the `session_url` when the turn ends, and a handler can post the pull request link where the team reads first thing. One pull request per run keeps the review small.

## Why run it on Rush?

A Symfony functional suite is quick to run and slow to extend by hand: each new case needs the right fixture user, the right form field names, the exact translated message and a database the DAMA bundle can roll back. The agent reads all of that from the repository in its own sandbox, with `composer install` done and the committed SQLite test database in place, and writes tests that run green the first time or are fixed until they do.

The pull request shows the suite output and the trajectory keeps every run, including the failing attempts, so a reviewer sees which assertion was adjusted and why. The same request runs against every Symfony app you maintain in parallel, each in its own sandbox.

## Frequently asked questions

### How does it authenticate in tests?

With `loginUser` on the test client, as the existing test does, using users loaded from `UserRepository`: `jane_admin` and `tom_admin` with `ROLE_ADMIN` and `john_user` with `ROLE_USER`, all from `AppFixtures`. The `when@test` block in `security.yaml` also enables HTTP basic, but the agent follows the file it is extending.

### Will the new tests leave data behind?

No. `phpunit.dist.xml` registers the DAMA Doctrine test bundle extension, which starts a transaction before each test and rolls it back after, so a created or deleted post is gone before the next test runs. The agent does not add its own cleanup.

### Does it change the controller or the voter?

No. The prompt keeps production code unchanged. If a test it writes cannot pass without a change, for example a status code that differs from the one the controller documents, the pull request reports that as a finding and leaves the test out.

### What about the deprecation flags?

`failOnDeprecation`, `failOnNotice` and `failOnWarning` are on, so a test that triggers a deprecated call fails the suite. The agent runs the full suite rather than the one file so a new helper that trips a deprecation is caught before the pull request opens.
