PHP Development Patterns
PLPHP 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.
What these PHP recipes give you
Section titled “What these PHP recipes give you”- 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.mdfor 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
Stophook 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:
| Package | Latest release | PHP it requires |
|---|---|---|
laravel/framework | 13.33.0 (2026-09-22); 13.0.0 shipped 2026-03-17 | ^8.3 |
phpunit/phpunit | 13.3.5 (2026-09-25) | >=8.4.1 |
symfony/symfony | 8.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/larastan | 2.2.16 · 3.12.2 | ^7.4|^8.0 · ^8.2 |
laravel/pint | 1.32.1 | ^8.3.0 |
laravel/boost | 2.10.0 (2026-09-23) | ^8.2 |
infection/infection | 0.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:
composer require laravel/boost --devphp artisan boost:installboost: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:
| Agent | MCP server registered in | Guidelines written to | Skills written to |
|---|---|---|---|
| Claude Code | .mcp.json | AGENTS.md | .claude/skills |
| Codex | .codex/config.toml | AGENTS.md | .agents/skills |
| Cursor | .cursor/mcp.json | AGENTS.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, whereAPP_ENVis notlocalandAPP_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,DESCRIBEandWITH … SELECT, and runs it in a transaction it rolls back. The Tinker tool runs arbitrary PHP in your application, includingProduct::query()->delete(). Point.envat 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.
What goes in AGENTS.md for a Laravel app?
Section titled “What goes in AGENTS.md for a Laravel app?”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.
# 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 doneThe 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:
@AGENTS.mdThe 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.
Codex reads AGENTS.md before it starts work. Since codex-cli 0.150.0, a project Codex does not trust does not supply its AGENTS.md, so trust the repository when Codex asks.
Add an always-applied project rule (the format is on project rules in Cursor) that repeats the Commands and Rules sections above, and commit it. Then ask the agent to quote the Definition of done back to you, which proves the rule loaded.
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:
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:
#!/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 0cd "$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; fiif ! 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 2fiif ! 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 2fiif ! out=$(php artisan test 2>&1); then echo "Tests failed. Fix app/, never tests/:" >&2 echo "$out" | tail -40 >&2; exit 2fiexit 0The 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):
{ "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.
composer install needs the network, and the workspace-write sandbox has none unless you enable it. Install dependencies first, outside the agent, then run the loop:
composer installcodex exec --sandbox workspace-write "Make every test in tests/Feature/ProductIndexTest.php pass without modifying tests/. Finish only when vendor/bin/pint --test, vendor/bin/phpstan analyse and php artisan test all pass; print the last lines of each."The skeleton’s phpunit.xml runs tests on in-memory SQLite, which works inside the sandbox. If your tests need a MySQL server the sandbox cannot reach, run those in CI. When the task is adding a package, enable network for that run only with -c sandbox_workspace_write.network_access=true.
Paste the implementation prompt into the agent, in Plan Mode first if the change touches models, controllers and migrations together. The Definition of done in your project rule tells the agent to run the full gate before it reports back, but a rule is still advice. The mechanical gate for Cursor is the CI job below, plus Bugbot if your team uses it for review.
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:
name: php-gateson: pull_request: types: [opened, synchronize, reopened, labeled, unlabeled]permissions: contents: readjobs: 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 testNew 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:
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:
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."Run it non-interactively in the workspace sandbox so the change stays inside the repository until you review the diff:
codex exec --sandbox workspace-write "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 with PDO prepared statements and bound parameters, wrap PDOException in RepositoryException, update every caller, and make the characterization tests pass without editing them. Finish with vendor/bin/phpstan analyse and the full test suite."Open legacy/users.php, switch the agent to Plan Mode and paste the Claude Code prompt from the first tab. Review the plan’s list of callers before it writes code: a missed caller in a file the agent did not open is the usual failure. Then let it execute and run the suite.
The target shape has a bound parameter and a typed return:
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:
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;
ProphecyTraitcomes fromphpspec/prophecy-phpunit. Without that package the test class does not load. PHPUnit’s owncreateMock()andcreateStub()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.
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:
vendor/bin/infection --git-diff-filter=AM --git-diff-base=origin/main --min-msi=80 --threads=maxA 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.
What changes on Symfony?
Section titled “What changes on Symfony?”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:
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.
What breaks when agents write PHP?
Section titled “What breaks when agents write PHP?”Where to go next with PHP and agents
Section titled “Where to go next with PHP and agents”Did this guide help you?
One tap, no sign-in.
What will you do with it?
How did it read?
What got in the way? Pick any that apply
Your role?
Thanks. I read every answer. Michał Jaskólski
Did this guide help?
One tap each. Close whenever you like.
no sign-in