# Add Health Checks to a Symfony App

Yes. An agent adds live and ready routes outside your Symfony app's locale prefix, checks Doctrine in the ready route, tests both and opens a pull request.

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

## What the agent does

The agent reads how routing and security are laid out before it adds a route. In a Symfony app shaped like symfony/demo, `config/routes.yaml` imports the attribute routes from `src/Controller` through the `routing.controllers` resource under a `/{_locale}` prefix, so `BlogController`'s `/blog` becomes `/en/blog/`, and `config/packages/security.yaml` puts one lazy `main` firewall over every URL with a `dev` firewall that switches security off for `_profiler`, `_wdt`, `assets` and `build`. A health endpoint must live outside the locale prefix, because a load balancer will not send `Accept-Language`, and must answer without a session or a login. The agent adds a `HealthController` whose routes are declared with `#[Route('/health/live')]` and `#[Route('/health/ready')]` and imported in `routes.yaml` as a separate resource without the prefix. `RedirectToPreferredLocaleSubscriber` in `src/EventSubscriber` only acts on `/`, and `security.yaml` has no `access_control` list, so nothing in the request pipeline redirects a probe at those paths.

The live route returns `200` with a small JSON body and touches nothing. The ready route runs `SELECT 1` through the Doctrine DBAL `Connection` that `config/packages/doctrine.yaml` configures from `DATABASE_URL`, which in `.env` points at `data/database.sqlite` and in `.env.test` at `data/database_test.sqlite`, with a short timeout, and answers `200` or `503` with a `checks` object naming the database. The controller is a plain service, autowired through the `App\` resource in `config/services.yaml`, so it needs no service definition; `PHP-CS-Fixer` rules in `.php-cs-fixer.dist.php` and the level 6 PHPStan configuration in `phpstan.dist.neon`, with the Symfony and Doctrine extensions, both apply to it.

Tests follow the pattern already in `tests/Controller`. `DefaultControllerTest` extends `WebTestCase`, lists public URLs in a data provider and asserts `assertResponseIsSuccessful`, and secure URLs redirect to `/en/login`; `BlogControllerTest` uses `loginUser` and relies on the DAMA Doctrine test bundle, enabled in `config/packages/dama_doctrine_test_bundle.yaml`, to roll back database writes. The agent writes a `HealthControllerTest` that requests both routes without a locale and without logging in, asserts the JSON body of each, and covers the `503` branch by replacing the connection in the test container with one whose `executeQuery` throws. `phpunit.dist.xml` sets `failOnDeprecation`, `failOnNotice` and `failOnWarning`, so a deprecated call in the new controller fails the suite rather than printing a notice.

Then it runs what the two workflows run: `composer install`, `vendor/bin/phpunit` as `tests.yaml` does on PHP 8.4 and 8.5, and the `lint.yaml` steps that matter for a new route, `bin/console lint:container --no-debug`, `bin/console lint:yaml config --parse-tags` and `bin/console doctrine:schema:validate --skip-sync`, plus `vendor/bin/php-cs-fixer check --diff` and `vendor/bin/phpstan`. The pull request holds the controller, the routing import, the test, and a line in `README.md` naming the two paths.

## Start it with one API call

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

```json
{
  "agent": "claude",
  "input": "Add health checks to this Symfony application. Read config/routes.yaml, config/packages/security.yaml, config/packages/doctrine.yaml, config/services.yaml, src/EventSubscriber/RedirectToPreferredLocaleSubscriber.php and tests/Controller/DefaultControllerTest.php first. Add GET /health/live, which answers 200 with a JSON body and no dependencies, and GET /health/ready, which runs SELECT 1 through the Doctrine DBAL connection with a timeout and answers 200 or 503 with a checks object. Register both outside the /{_locale} prefix so they never redirect, and make sure they are reachable without a session or login. Write PHPUnit tests on WebTestCase for both routes and both outcomes of the ready check, in the style of the existing controller tests. Run composer install, vendor/bin/phpunit, vendor/bin/php-cs-fixer check --diff, vendor/bin/phpstan, bin/console lint:container --no-debug and bin/console lint:yaml config --parse-tags, and fix what fails. Add one line to README.md naming the two paths. Open a pull request that lists the files and quotes the phpunit summary. Do not change any other route.",
  "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 a new branch with four files: `src/Controller/HealthController.php`, the import added to `config/routes.yaml`, `tests/Controller/HealthControllerTest.php` and a line in `README.md`. The description names the two paths, says why they sit outside the locale prefix, and quotes the `phpunit` summary and the `php-cs-fixer` and `phpstan` results from the sandbox.

The session read names the repository and the pull request URL, and the transcript keeps each command with its output, including the first `phpunit` run if a test failed before the fix. A reviewer checks that `/health/ready` really is exempt from the locale redirect and the login redirect, that the `SELECT 1` runs with a timeout rather than hanging on a stalled database, and that no response leaks the DSN from `DATABASE_URL`.

Nothing else moves: `composer.lock`, `importmap.php` and the existing controllers are unchanged, and the workflows are not edited.

## Run it on every Linear issue labelled agent

Create an automation in the Project with this prompt and a `linear` trigger on `linear.issue.labeled`, with the label your team uses to hand work off in `config.labels` and, if only some teams should fire it, their keys in `config.teams`. Labelling the issue that asks for health checks starts one run with that issue as context; the trigger adds the issue after the prompt and replaces nothing in it. Changes made by an app integration never fire the trigger.

From your own server, the same run starts with `POST /o/{org}/sessions` and this body, plus the issue URL as a second `resources` entry (`https://linear.app/<workspace>/issue/<ID>`), which Rush reads through your Linear connection and places in the prompt. The pull request URL comes back on the session and in the `run.completed` webhook when the key has a `webhook_url`.

## Why run it on Rush?

A health route is small, but proving it is right needs PHP 8.4 with `intl` and `pdo_sqlite`, a `composer install`, the test database at `data/database_test.sqlite`, and five tools run in the right order. The sandbox has all of that for the length of the run and parks with its disk when the turn ends, so a follow-up such as adding a cache check to `/health/ready` picks up where the first turn left off.

The pull request carries the proof: the test file, the quoted `phpunit` summary and a transcript showing the lint commands and their output. A reviewer judging whether a probe can ever redirect to `/en/login` sees the test that requests it without a session, not a sentence saying it was checked.

## Frequently asked questions

### Why two routes and not one /health?

A liveness probe should never fail because the database is slow, or an orchestrator would restart a healthy process. `/health/live` answers from memory; `/health/ready` is the one a load balancer uses to decide whether to send traffic, and it is the one that runs `SELECT 1`.

### Will the routes be behind the firewall or the locale redirect?

Neither. They are imported in `config/routes.yaml` without the `/{_locale}` prefix, and the `main` firewall is lazy, so an anonymous request that never reads the session is served without a redirect. The test requests both routes with no login and asserts a `200`.

### Does the ready check open a new database connection?

No. It uses the DBAL `Connection` Doctrine already configures from `DATABASE_URL`, with a per-call timeout, so the probe sees the same database the application sees, SQLite in the demo and PostgreSQL or MySQL if you change the URL.

### What about the HTTP cache and ESI the app turns on?

`config/packages/framework.yaml` enables `esi` and `fragments`, and `BlogController::index` sets `#[Cache(smaxage: 10)]`. The health responses set no cache attribute and send `Cache-Control: no-store`, so a proxy never serves a stale `200` for a database that has gone away.
