Skip to content

PHP development patterns for AI coding agents are the settings, prompts and gates that make Claude Code, Codex and Cursor ship Laravel 13 and Symfony code accepted on evidence: Laravel Boost for framework context, tests written before the code, Pint, PHPStan and PHPUnit as the definition of done, strict Eloquent against N+1 queries, and characterization tests before any legacy rewrite.

This page is for PHP developers who already let an agent write most of a feature, and for tech leads who want the same gates enforced in CI for the whole team. The situation it fixes: the agent’s pull request is green, but its composer.json pins PHPUnit 13 on PHP 8.3, half its tests use @test annotations that PHPUnit 13 never runs, the product list lazy-loads a category per row, and the “modernized” legacy repository still interpolates $id into SQL. The recipes below make the agent run into those problems on every turn, so you review test results and gate output instead of reading every line.

  • A version table with the PHP constraint each current package really needs.
  • Laravel Boost wired into Claude Code, Codex and Cursor, with its two sharp edges named.
  • An AGENTS.md for a Laravel app and a prompt that drafts one from your repository.
  • A tests-first loop for a thin-controller Laravel endpoint.
  • A Claude Code Stop hook and a GitHub Actions job that refuse “done” until Pint, PHPStan and PHPUnit pass.
  • Eloquent strict mode plus a query-count test, so N+1 queries fail tests instead of production.
  • A legacy mysql_* modernization recipe that pins behaviour first and checks for surviving SQL injection.
  • A PHPUnit 13 test that runs without deprecations, and mutation testing with Infection to show the tests can fail.

Which PHP, Laravel and Symfony versions do these recipes assume?

Section titled “Which PHP, Laravel and Symfony versions do these recipes assume?”

Agents mix APIs from Laravel 8 to 13 and from PHPUnit 9 to 13, because their training data does. Tell the agent the versions and let composer.lock pin them. These were the latest stable releases on Packagist in late September 2026:

PackageLatest releasePHP it requires
laravel/framework13.33.0 (2026-09-22); 13.0.0 shipped 2026-03-17^8.3
phpunit/phpunit13.3.5 (2026-09-25)>=8.4.1
symfony/symfony8.1.7 (2026-09-15); 7.4.19 on the long-term-support branch>=8.4.1 (8.1), >=8.2 (7.4)
phpstan/phpstan · larastan/larastan2.2.16 · 3.12.2^7.4|^8.0 · ^8.2
laravel/pint1.32.1^8.3.0
laravel/boost2.10.0 (2026-09-23)^8.2
infection/infection0.35.4 (2026-09-02)^8.3

The row that bites: Laravel 13 runs on PHP 8.3, but PHPUnit 13 needs PHP 8.4.1 or later. The fresh Laravel 13 skeleton therefore pins "phpunit/phpunit": "^12.5.12". If the agent “upgrades” to PHPUnit 13 while composer.json still says "php": "^8.3", Composer cannot resolve the dependencies on a PHP 8.3 machine. Pick one: stay on PHPUnit 12.5 for PHP 8.3, or require "php": "^8.4" and move to PHPUnit 13.

How do you give the agent Laravel context with Laravel Boost?

Section titled “How do you give the agent Laravel context with Laravel Boost?”

Laravel Boost is Laravel’s own package for AI agents. It writes framework guidelines and skills into your repository and runs a local MCP server. A new Laravel 13 skeleton already ships an AGENTS.md and a CLAUDE.md that tell the agent to install Boost before doing anything else, so an agent opening a fresh app asks for it anyway. Install it from the application root:

Terminal window
composer require laravel/boost --dev
php artisan boost:install

boost:install asks which agents you use and which parts to install (--guidelines, --skills and --mcp select them non-interactively). The defaults in the Boost 2.10.0 source, each overridable in config/boost.php:

AgentMCP server registered inGuidelines written toSkills written to
Claude Code.mcp.jsonAGENTS.md.claude/skills
Codex.codex/config.tomlAGENTS.md.agents/skills
Cursor.cursor/mcp.jsonAGENTS.md.cursor/skills

The MCP server (php artisan boost:mcp) gives the agent tools that read the database schema, run read-only SQL, search documentation for the exact package versions in your lock file, read the last exception and the application log, and execute PHP through Tinker. The schema tool is the one that changes results most: generated migrations, casts and factories stop guessing column names. Run php artisan boost:update after upgrading Laravel or Boost to refresh the guidelines and skills.

Two things the Boost docs do not put in front of you:

  • Boost only starts in a local environment or with APP_DEBUG=true. On a staging or production-like host, where APP_ENV is not local and APP_DEBUG=false, the MCP server does not register its tools, so an agent running there has no schema access. That is the right behaviour; do not turn debug on to “fix” it.
  • The SQL tool is read-only; Tinker is not. Boost’s query tool rejects anything but SELECT, SHOW, EXPLAIN, DESCRIBE and WITH … SELECT, and runs it in a transaction it rolls back. The Tinker tool runs arbitrary PHP in your application, including Product::query()->delete(). Point .env at a disposable local database, never a copy of production with customer data.

On Symfony there is no Boost equivalent. Write the AGENTS.md below by hand and connect a read-only database server from database MCP servers so the agent reads the real schema.

Boost’s guidelines cover Laravel conventions. Your file adds what only your codebase knows: the commands the agent must run, in the order it must run them, and the few rules a generic model gets wrong here. Leave out anything Pint or PHPStan already enforces. Put it in the file Boost writes to, above or below Boost’s generated block.

AGENTS.md
# Shop (Laravel 13, PHP 8.4, MySQL 8, PHPUnit 13)
## Commands (run from the repo root)
- One test: `php artisan test --filter=ProductIndexTest`
- Style check: `vendor/bin/pint --test` (fix with `vendor/bin/pint --dirty`)
- Static analysis: `vendor/bin/phpstan analyse` (Larastan, level 8)
- Full gate: `vendor/bin/pint --test && vendor/bin/phpstan analyse && php artisan test`
## Definition of done
The full gate passes. Report the command you ran and its last lines.
## Rules
- Create classes with `php artisan make:*`, never by hand; every model gets a migration and a factory.
- Controllers only validate (Form Request), call one service method and return an API Resource.
- Tests are the specification. If a test fails, fix app/, not tests/. Ask before changing an assertion.
- Test attributes (`#[Test]`, `#[DataProvider]`), never doc-comment annotations.
- Every query that feeds a collection eager-loads with `with()`; strict mode throws on lazy loading.
- Raw SQL only through bound parameters; never interpolate a variable into a query string.
- Money is integer cents (`*_cents`), never float.

Each tool loads the file differently:

Create a CLAUDE.md whose first line imports the shared file, then add any Claude-only lines below it:

CLAUDE.md
@AGENTS.md

The skeleton’s CLAUDE.md holds only the Boost bootstrap text, so replace it after boost:install. Claude Code 2.1.277 and later also read AGENTS.md directly when a project has no CLAUDE.md (first-party sessions, both release channels on 2026-10-12); the import works on every version and channel.

Shared agent rules explains how to keep one source of truth when a team uses all three tools.

How do you build a thin-controller Laravel endpoint the agent cannot fake?

Section titled “How do you build a thin-controller Laravel endpoint the agent cannot fake?”

Asking for “a Product API” gets you a 200-line controller with query building, validation and response shaping inline, plus tests written afterwards to match whatever the code does. Reverse the order. Write the acceptance criteria, have the agent turn them into failing feature tests, review only the tests, and then let it implement against tests it may not edit. Acceptance criteria covers writing the criteria themselves.

Read the tests, not the implementation that follows. Then:

The shape to accept is a controller that only delegates:

app/Http/Controllers/Api/V1/ProductController.php
final class ProductController extends Controller
{
public function __construct(private readonly ProductService $products) {}
public function index(IndexProductRequest $request): AnonymousResourceCollection
{
return ProductResource::collection(
$this->products->paginate(
perPage: $request->integer('per_page', 15),
search: $request->string('search')->toString(),
),
);
}
}

RefreshDatabase runs your migrations before the tests, so a model generated without its migration fails here rather than in production.

How do you stop the agent from declaring done before the gate passes?

Section titled “How do you stop the agent from declaring done before the gate passes?”

An instruction in AGENTS.md is advice; a hook or a CI check is a gate. Claude Code gets a local gate from the Stop hook below. Codex has hooks with a Stop event too (codex-cli 0.157.1, with persisted trust required), and Cursor has Hooks, but the setups below give those two a prompt and a rule, so for them the CI job after the tabs is the gate.

A Stop hook runs when Claude finishes responding. Exit code 2 blocks the stop and shows the hook’s stderr to Claude, which keeps working:

.claude/hooks/php-gate.sh
#!/usr/bin/env bash
# Refuse "done" until Pint, PHPStan and PHPUnit pass.
input=$(cat)
# Already continuing because of this hook: stop and report instead of looping.
[ "$(echo "$input" | jq -r '.stop_hook_active')" = "true" ] && exit 0
cd "$CLAUDE_PROJECT_DIR" || exit 0
# Skip only when the branch has no code, test or schema changes against its base,
# committed, uncommitted or untracked: committing during the turn does not hide work.
dirs="app src tests database config routes"
base=$(git merge-base HEAD origin/HEAD 2>/dev/null || git merge-base HEAD origin/main 2>/dev/null || git rev-parse HEAD)
if git diff --quiet "$base" -- $dirs && [ -z "$(git ls-files --others --exclude-standard -- $dirs)" ]; then exit 0; fi
if ! out=$(vendor/bin/pint --test 2>&1); then
echo "Pint found style violations. Run vendor/bin/pint --dirty:" >&2
echo "$out" | tail -20 >&2; exit 2
fi
if ! out=$(vendor/bin/phpstan analyse --no-progress --error-format=raw 2>&1); then
echo "PHPStan failed. Fix the code; never add ignoreErrors or @phpstan-ignore:" >&2
echo "$out" | head -30 >&2; exit 2
fi
if ! out=$(php artisan test 2>&1); then
echo "Tests failed. Fix app/, never tests/:" >&2
echo "$out" | tail -40 >&2; exit 2
fi
exit 0

The script needs jq; on Symfony replace php artisan test with vendor/bin/phpunit. The skip compares the branch with its base (origin/HEAD, else origin/main), so work the agent commits during the turn is still checked. The cost: on a branch that already has PHP changes, the gate runs on every turn, including pure questions. If neither remote ref exists, it falls back to uncommitted changes only. Because it exits 0 when stop_hook_active is true, a failing gate forces one retry, and then Claude stops and reports instead of looping on a test that cannot pass. Register it in .claude/settings.json and make it executable (chmod +x):

.claude/settings.json
{
"hooks": {
"Stop": [
{ "hooks": [ { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/php-gate.sh", "timeout": 600 } ] }
]
}
}

Hooks automation covers retry counting and the other events.

Whatever runs locally, CI decides. This job runs with a read-only token and fails when the pull request edits an existing test without a reviewer’s label:

.github/workflows/php-gates.yml
name: php-gates
on:
pull_request:
types: [opened, synchronize, reopened, labeled, unlabeled]
permissions:
contents: read
jobs:
gates:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
fetch-depth: 2 # the PR merge commit and its base parent
- name: Existing tests unchanged
if: ${{ !contains(github.event.pull_request.labels.*.name, 'test-change-approved') }}
run: |
changed=$(git diff --name-only --diff-filter=MDR HEAD^1 HEAD -- tests/)
if [ -n "$changed" ]; then
echo "Existing tests modified, moved or deleted; a reviewer must add the test-change-approved label:"
echo "$changed"; exit 1
fi
- uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
coverage: none
- run: composer install --no-interaction --no-progress --prefer-dist
- run: composer audit
- run: vendor/bin/pint --test
- run: vendor/bin/phpstan analyse --no-progress
- run: cp .env.example .env && php artisan key:generate
- run: php artisan test

New test files pass the “Existing tests unchanged” step; edits, renames and deletions under tests/ fail it until a person reviews them and adds the test-change-approved label, which starts a fresh run. The step is only a gate if the pull request cannot remove it: GitHub runs the workflow file from the pull request itself, so put /.github/ under CODEOWNERS and require that review in branch protection or a ruleset. Protecting the oracle goes further, with deny rules that stop the agent from editing tests in the first place.

How do you catch N+1 queries in agent-written Eloquent?

Section titled “How do you catch N+1 queries in agent-written Eloquent?”

A typical regression in agent-written Laravel is a resource or Blade loop that lazy-loads a relationship per row: fast with five rows in the demo, a timeout with 500 in production. Turn it into an exception outside production:

app/Providers/AppServiceProvider.php
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
// Throws on lazy loading, on assigning non-fillable attributes and on reading missing attributes.
Model::shouldBeStrict(! $this->app->isProduction());
}

With strict mode on, any test that touches a lazy-loaded relationship throws LazyLoadingViolationException, and the Stop hook and CI job above turn that into a failed gate. Strict mode does not catch a per-row query that is not a relationship, such as a count() inside an accessor, so pin the query count of each list endpoint as well:

How do you add caching without serving stale data?

Section titled “How do you add caching without serving stale data?”

The naive caching prompt scatters Cache::remember() across the codebase with no invalidation, and the product page shows last week’s price. Make invalidation, and the test that proves it, part of the request:

Cache tags only work on stores that support them, such as Redis and Memcached; the file and database stores throw. Run the test against the store you use in production, not the array store that phpunit.xml sets by default.

How do you modernize legacy mysql_* code with an agent?

Section titled “How do you modernize legacy mysql_* code with an agent?”

The mysql_* extension was removed in PHP 7.0, so code that still calls it runs only on PHP 5.x. The syntax conversion is mechanical, so agents handle it quickly; the risk is elsewhere. The agent can “modernize” the syntax while keeping "... WHERE id = $id" inside a PDO call, or silently change what the function returns for an edge case your callers depend on. So pin today’s behaviour first with characterization tests, and lock them before the rewrite. The old code cannot run on the PHP 8.4 toolchain the rest of this page uses, so record its behaviour where it still runs: call the function for each case in a PHP 5.6 container (the official php:5.6-cli image plus docker-php-ext-install mysql, pointed at a disposable copy of the database) and save the return values as a JSON fixture. The tests then run on PHP 8.4 and assert that the new code returns what the fixture recorded.

Run from the repo root so Claude Code can search every caller, not only the open file:

Terminal window
claude "Modernize get_user() in legacy/users.php. First record its current behaviour: write scripts/record-get-user.php, which calls get_user() for an existing id, a missing id, id 0 and a non-numeric string and writes the return values to tests/Characterization/fixtures/get_user.json; run it in a php:5.6-cli container with the mysql extension against a disposable copy of the test database, and show me the fixture. Then write tests/Characterization/GetUserTest.php, which runs on PHP 8.4 and asserts that the new code returns data equivalent to those recorded values (same fields, same result for a missing id). Then create App\\Repository\\UserRepository::findById(int \$id): ?User using PDO prepared statements with bound parameters, wrap PDOException in RepositoryException, update every caller repo-wide, and make the characterization tests pass against the new code without editing them. Finish with vendor/bin/phpstan analyse and the full test suite."

The target shape has a bound parameter and a typed return:

app/Repository/UserRepository.php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\User;
use PDO;
use PDOException;
final class UserRepository
{
public function __construct(private readonly PDO $connection) {}
public function findById(int $id): ?User
{
try {
$statement = $this->connection->prepare(
'SELECT id, email, name, created_at FROM users WHERE id = :id'
);
$statement->execute(['id' => $id]);
$row = $statement->fetch(PDO::FETCH_ASSOC);
return $row === false ? null : User::fromArray($row);
} catch (PDOException $e) {
throw new RepositoryException("Failed to fetch user {$id}", 0, $e);
}
}
}

Before you merge, run a first-pass search for SQL that still builds a query from a variable. This grep flags query-building calls (PDO, mysqli and the Laravel raw-SQL methods) whose argument list contains a variable ($). A concatenation with a constant or a function call is not flagged:

Terminal window
grep -rnE '(query|prepare|exec|select|statement|whereRaw|selectRaw|orderByRaw)\s*\([^)]*\$' app/ src/ legacy/

A hit is a line to fix or justify; an empty result is not proof. The grep cannot follow a query string built on an earlier line, a heredoc, or a value passed in from another function. The real gate is a data-flow check in CI: Psalm’s taint analysis (vendor/bin/psalm --taint-analysis) traces request input into SQL sinks such as PDO::exec(), or a Semgrep rule does the same for your query helpers. Together with the characterization tests above, that is the evidence a reviewer accepts. For the wider strategy (strangler seams, sequencing a large rewrite), see legacy modernization.

How do you know the agent’s PHPUnit tests would catch a bug?

Section titled “How do you know the agent’s PHPUnit tests would catch a bug?”

Agents write tests that pass. Whether they fail when the code is wrong is a separate question, and two PHPUnit changes make agent tests quietly weaker than they look:

  • Annotations no longer run. PHPUnit 12 removed doc-comment metadata. A method named annotation_only() with only a /** @test */ above it is not a test in PHPUnit 13: the runner skips it without an error, so the count simply drops.
  • Prophecy is not bundled. PHPUnit 10 and later ship no Prophecy integration; ProphecyTrait comes from phpspec/prophecy-phpunit. Without that package the test class does not load. PHPUnit’s own createMock() and createStub() need nothing extra.

Ask for the shape below. Order here is a plain domain value object with an (int $id, OrderStatus $status) constructor, not an Eloquent model, whose constructor takes an attributes array. With that class, it passes on PHPUnit 13.3.5 with zero deprecations. Note expects() before with() on find: PHPUnit 13 deprecates with() on a mock without expects(), and PHPUnit 14 will reject it.

tests/Unit/OrderServiceTest.php
declare(strict_types=1);
namespace Tests\Unit;
use App\Domain\Order;
use App\Enums\OrderStatus;
use App\Exceptions\InvalidOrderStateException;
use App\Repositories\OrderRepository;
use App\Services\{NotificationService, OrderService};
use PHPUnit\Framework\Attributes\{DataProvider, Test};
use PHPUnit\Framework\TestCase;
final class OrderServiceTest extends TestCase
{
#[Test]
public function it_completes_a_processing_order_and_notifies_once(): void
{
$order = new Order(123, OrderStatus::Processing);
$orders = $this->createMock(OrderRepository::class);
$orders->expects($this->once())->method('find')->with(123)->willReturn($order);
$orders->expects($this->once())->method('save')->with($order);
$notifications = $this->createMock(NotificationService::class);
$notifications->expects($this->once())->method('sendOrderCompleted')->with($order);
$result = (new OrderService($orders, $notifications))->completeOrder(123);
$this->assertSame(OrderStatus::Completed, $result->status);
}
#[Test]
#[DataProvider('nonCompletableStatuses')]
public function it_refuses_to_complete_from(OrderStatus $status): void
{
$orders = $this->createStub(OrderRepository::class);
$orders->method('find')->willReturn(new Order(123, $status));
$notifications = $this->createMock(NotificationService::class);
$notifications->expects($this->never())->method('sendOrderCompleted');
$this->expectException(InvalidOrderStateException::class);
(new OrderService($orders, $notifications))->completeOrder(123);
}
public static function nonCompletableStatuses(): array
{
return [
'completed' => [OrderStatus::Completed],
'cancelled' => [OrderStatus::Cancelled],
'refunded' => [OrderStatus::Refunded],
];
}
}

Mutation testing answers the question directly. Infection changes your code (flips a condition, removes a call) and reruns the tests; a mutant that survives is a bug your tests would miss. It needs a coverage driver (PCOV or Xdebug). Run it on the lines the agent changed:

Terminal window
vendor/bin/infection --git-diff-filter=AM --git-diff-base=origin/main --min-msi=80 --threads=max

A tech lead signs off on the test changes and on the mutation score threshold, not on every line of the implementation. See oracle strength for choosing that threshold.

The recipes carry over; the primitives change. Symfony 8.1 needs PHP 8.4.1, and 7.4 is the long-term-support branch for teams that cannot follow six-month minors. Generate code with php bin/console make:* (from symfony/maker-bundle) instead of writing it by hand, and generate migrations from the entity diff with php bin/console doctrine:migrations:diff rather than letting the agent write SQL. Doctrine’s lazy loading does not throw the way strict Eloquent does, so a query-count test is your only N+1 gate. Assert it in a functional test through the profiler’s Doctrine collector:

tests/Controller/ProductIndexTest.php
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
final class ProductIndexTest extends WebTestCase
{
public function testListQueryCountStaysFlat(): void
{
$client = static::createClient();
// Seed 40+ products here with your fixtures or factories.
$client->enableProfiler();
$client->request('GET', '/api/products');
self::assertResponseIsSuccessful();
$profile = $client->getProfile();
self::assertNotFalse($profile, 'The profiler is not enabled in the test environment.');
self::assertLessThanOrEqual(3, $profile->getCollector('db')->getQueryCount());
}
}

getProfile() returns false unless the profiler is enabled in the test environment; the symfony/web-profiler-bundle recipe sets framework.profiler.collect: false there, and enableProfiler() switches collection on for the next request only. Pick the bound (3 here) by measuring the endpoint with a handful of rows, then seed many more and keep the assertion unchanged. Tests run with vendor/bin/phpunit, so swap that into the Stop hook and CI job.

Did this guide help you?

One tap, no sign-in.

not at all a lot