# Add a CSV Export to a Symfony Admin Backend from a Linear Issue

Yes. An agent reads the labelled Linear issue, adds a CSV export route to the Symfony admin controller with a PHPUnit test, and opens a pull request.

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

## What the agent does

The agent starts from the issue text and the repository as it is. In an application shaped like symfony/demo it reads `composer.json` and sees PHP 8.4 or newer, `symfony/framework-bundle` at 8.1.0 in `composer.lock`, Doctrine ORM, Twig with `symfony/ux-icons`, and no `symfony/serializer`, so it writes the CSV with `fputcsv` on a `StreamedResponse` rather than adding a package for a one-column-per-field export. It then reads `config/routes.yaml`, where every controller route sits under a `/{_locale}` prefix, and `src/Controller/Admin/BlogController.php`, whose class carries `#[Route('/admin/post')]` and `#[IsGranted(User::ROLE_ADMIN)]`, and whose `index` action loads `PostRepository::findBy(['author' => $user], ['publishedAt' => 'DESC'])` for the signed-in user.

The export follows that shape. A new `export` action with `#[Route('/export.csv', name: 'admin_post_export', methods: ['GET'])]` is placed above the `/{id:post}` routes so the literal segment wins, loads the same author-scoped list the index uses, and streams one header row and one row per post with the `Post` entity's `title`, `slug`, `summary`, `publishedAt` and the tag names from its `tags` collection, which `src/Entity/Post.php` orders by name. The response sets `Content-Type: text/csv; charset=UTF-8` and a `Content-Disposition` attachment filename, and a `utf8` byte-order mark is left out unless the issue asks for one for a spreadsheet import.

The button goes into `templates/admin/blog/index.html.twig` next to the existing `admin_post_new` link, with a `tabler` icon from `symfony/ux-icons` and a label through the `trans` filter like every other string on that page. The new key is added to `translations/messages+intl-icu.en.xlf`; the repository has 31 message catalogues, and the pull request says which ones still need the string rather than pasting English into each of them. The lint workflow in `.github/workflows/lint.yaml` runs `lint:twig`, `lint:xliff` and `lint:translations` on the pull request, so a malformed XLIFF or an untranslatable key fails there, not in production.

Tests go into `tests/Controller/Admin/BlogControllerTest.php`, which already signs in as `jane_admin` with `loginUser` in `setUp` and relies on the DAMA Doctrine test bundle in `phpunit.dist.xml` to roll each test back. A new `testAdminExportCsv` requests `/en/admin/post/export.csv`, asserts a successful response, the `text/csv` content type and a first line that is the header row, and the `getUrlsForRegularUsers` data provider gains the export URL so the existing `testAccessDeniedForRegularUsers` proves a `ROLE_USER` account gets 403. The agent runs `vendor/bin/phpunit`, then `vendor/bin/php-cs-fixer fix --dry-run` and `vendor/bin/phpstan`, the same tools the lint workflow runs, until all three are clean.

## Start it with one API call

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

```json
{
  "agent": "claude",
  "input": "A Linear issue labeled agent says: \"Add a CSV export to the admin backend. From the post list, an admin should be able to download their own posts as a CSV with title, slug, summary, published date and tags.\" Read config/routes.yaml, src/Controller/Admin/BlogController.php, src/Entity/Post.php, templates/admin/blog/index.html.twig and tests/Controller/Admin/BlogControllerTest.php first. Add the route to the existing admin controller under the ROLE_ADMIN guard, stream the CSV with fputcsv, add the button with a translated label, add the English translation key, add a PHPUnit test for the export and for a regular user being refused, run vendor/bin/phpunit, php-cs-fixer and phpstan, and open a pull request that links the issue.",
  "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 on its own branch. The diff touches `src/Controller/Admin/BlogController.php` for the new action, `templates/admin/blog/index.html.twig` for the button, `translations/messages+intl-icu.en.xlf` for the label, and `tests/Controller/Admin/BlogControllerTest.php` for the export test and the extra data-provider row. No new Composer package appears in `composer.json` or `composer.lock`, because the CSV is written with PHP's own `fputcsv`. The description quotes the issue, lists the columns in order, and names the catalogues that still need a translation.

The checks are the proof. The `Tests` workflow in `.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, so the new test runs on all four. The `Lint` workflow runs PHP-CS-Fixer, `composer validate`, and the `lint:yaml`, `lint:twig`, `lint:xliff`, `lint:translations` and `lint:container` console commands. The session's trajectory at `GET /o/{org}/sessions/{id}/messages` keeps every PHPUnit and PHPStan invocation with its output, so a reviewer reads the suite passing rather than taking the description's word for it.

Before merging, a reviewer checks three things: that the export is scoped to the signed-in author exactly as the index is, so one admin cannot download another's drafts; that the route is declared before the `/{id:post}` routes and the test for a regular user answers 403; and that the column order matches what the issue asked for. Nothing reaches main until the pull request merges.

## Run it on every Linear issue labelled agent

Sign in to Linear once with `POST /me/connections {"type": "app", "name": "linear"}` and finish the authorize URL in the console. Then add an automation to the Project with this prompt and a `linear` trigger on `linear.issue.labeled`, with the label you reserve for the agent in `config.labels` and, if only some teams may hand work off, their keys in `config.teams`. The trigger adds the issue after the prompt and fills in no placeholder, so the agent reads the request from the event itself. The automation's `max_runs_per_hour`, between 1 and 30, bounds how many issues it takes on in an hour, and each one gets its own sandbox and its own pull request.

From your own server instead, a handler that receives Linear's webhook for the label sends `POST /o/{org}/sessions` with the issue URL in `resources` beside the repository: `https://linear.app/<workspace>/issue/ENG-42` puts the issue text in the prompt, read through your Linear connection. Send a `request_id` derived from the issue id so a redelivered webhook returns the run it already started. Give that key a `webhook_url` and Rush POSTs `run.completed`, `run.failed` or `run.cancelled` when the turn ends, signed per Standard Webhooks, with `data.session_url` to read for the pull request link to post back on the issue.

## Why run it on Rush?

An export is a small feature with a long tail: the route, the guard, the template, the translation catalogue and the test each live in a different directory, and the lint workflow checks all of them. On a laptop that means a PHP 8.4 install, a `composer install`, and three tools run by hand before the first push. On Rush the run happens in its own sandbox with its own clone: the agent runs `vendor/bin/phpunit`, `php-cs-fixer` and `phpstan` there, and pushes only when all three are clean, while your working tree stays where you left it.

The result is checkable rather than claimed. The pull request carries the diff and the issue link, the repository's own `Tests` and `Lint` workflows run on it across PHP versions and operating systems, and the trajectory keeps every command with its output. Five labelled issues mean five sandboxes and five independent pull requests, within the automation's hourly cap, instead of five serial checkouts on one machine.

## Frequently asked questions

### Why not add the Symfony Serializer and its CSV encoder?

The demo does not require `symfony/serializer`, and `composer.lock` has no entry for it. For a fixed set of scalar columns, `fputcsv` on a `StreamedResponse` does the job with no new dependency and no normalizer configuration. If your application already uses the Serializer, the agent reads `composer.json`, sees it, and uses the `csv` encoder instead; the pull request says which it chose and why.

### Will the route clash with the `/{id:post}` routes?

No. The new route is declared with a literal `/export.csv` path ahead of the `/{id:post}` routes in the same controller, and the existing ones carry `requirements: ['id' => Requirement::POSITIVE_INT]`, so `export.csv` can never match as an id. The test requests `/en/admin/post/export.csv` under the `/{_locale}` prefix from `config/routes.yaml` and asserts a CSV, not a 404.

### Does the export include every post or only mine?

Only the signed-in admin's posts, which is what the admin index already shows: `index` calls `findBy(['author' => $user])` and the export reuses that query. The test signs in as `jane_admin`, so every row it reads belongs to that account. If you want a site-wide export, say so in the issue and the agent drops the author filter and says so in the pull request.

### What about the other 30 translation catalogues?

The agent adds the key to `messages+intl-icu.en.xlf` and lists the other catalogues in the pull request rather than inventing translations. The `Lint` workflow's `lint:xliff` and `lint:translations` steps check that what was added is well formed. Translators fill in the rest in their usual flow.

### How do I know the tests ran?

The trajectory at `GET /o/{org}/sessions/{id}/messages` keeps every `vendor/bin/phpunit` run with its output, and the pull request triggers the repository's `Tests` workflow on PHP 8.4 and 8.5. If the suite cannot pass, the agent says so in its final message, which the webhook carries as `result`, instead of reporting a pull request.
