Skip to content

PHP Development Patterns

You inherited a Laravel monolith. The controllers are 400 lines deep, half the data access is raw mysql_* calls a contractor left behind in 2017, there are zero tests, and the PM wants a new REST API endpoint by Friday. Pointing an AI agent at this without guardrails gets you Eloquent N+1 queries, fat controllers that pass code review by luck, and migrations that never get written for the entities it generates.

This guide shows how to drive Cursor, Claude Code, and Codex through real Laravel and Symfony work so the output is production-shaped, not demo-shaped: thin controllers, a service layer, runnable tests, and a modernization path for the legacy parts.

  • A repeatable prompt for generating a thin-controller + service-layer Laravel REST endpoint (no business logic in the controller)
  • A prompt for modernizing legacy mysql_* / global-function PHP into typed, PSR-4, PDO-backed repositories
  • A caching-layer prompt that adds Redis with explicit invalidation, not blind Cache::remember everywhere
  • A PHPUnit prompt that produces tests which actually run against a vanilla install (no missing Prophecy dependency)
  • The failure modes that bite AI-generated PHP, and how to catch them before they ship

The setup is identical across all three tools — only the rules-file name differs. Pin a current stack and give the agent a rules file so it stops reaching for Laravel 11-era patterns.

  1. Pin a current composer.json. Laravel 13 (released March 2026) requires PHP 8.3+, and PHPUnit 13 is the current major. Don’t let the agent scaffold against EOL versions.

    composer.json
    {
    "require": {
    "php": "^8.3",
    "laravel/framework": "^13.0",
    "laravel/sanctum": "^4.0"
    },
    "require-dev": {
    "phpunit/phpunit": "^13.0",
    "mockery/mockery": "^1.6",
    "phpstan/phpstan": "^2.0",
    "laravel/pint": "^1.18"
    }
    }
  2. Give the agent a rules file. Cursor reads project rules from .cursor/rules/*.mdc, Claude Code reads CLAUDE.md, and Codex reads AGENTS.md. The content is the same — only the path changes. (The legacy single-file .cursorrules still works in Cursor but is superseded by .cursor/rules/.)

    - Target PHP 8.3+; use readonly properties, enums, and constructor promotion
    - Follow PSR-12; run `vendor/bin/pint` before considering a task done
    - Controllers stay thin: validation in Form Requests, logic in services
    - Type-declare every parameter and return; no untyped arrays for domain data
    - Eager-load relationships in queries; never trigger N+1 in a loop
    - Every generated model needs a matching migration in the same change
    - Write a PHPUnit test for every public service method

The most common mistake is asking for “a Product API” and getting a 200-line controller with query-building, validation, and response shaping all inline. Anchor the prompt on the architecture you want.

Here is the shape the prompt should produce. Note the controller does nothing but delegate:

app/Http/Controllers/Api/V1/ProductController.php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreProductRequest;
use App\Http\Resources\ProductResource;
use App\Services\ProductService;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class ProductController extends Controller
{
public function __construct(
private readonly ProductService $products
) {}
public function index(): AnonymousResourceCollection
{
return ProductResource::collection(
$this->products->paginate(
perPage: request()->integer('per_page', 15),
search: request()->string('search')->toString(),
)
);
}
public function store(StoreProductRequest $request): ProductResource
{
$product = $this->products->create($request->validated());
return ProductResource::make($product);
}
}

The service is where the agent should put the transaction boundary and event dispatch — keep it honest by checking that create() wraps writes in DB::transaction and that the repository, not Eloquent, is the data-access seam:

app/Services/ProductService.php
namespace App\Services;
use App\Events\ProductCreated;
use App\Models\Product;
use App\Repositories\ProductRepository;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
class ProductService
{
public function __construct(
private readonly ProductRepository $repository
) {}
public function create(array $data): Product
{
return DB::transaction(function () use ($data) {
$product = $this->repository->create($data);
event(new ProductCreated($product));
return $product;
});
}
public function paginate(int $perPage, string $search): LengthAwarePaginator
{
return $this->repository->paginate($perPage, $search);
}
}

The single most common production regression from AI-generated Eloquent is an N+1 query — a @foreach in a Blade view or an API resource that lazy-loads a relationship per row. Make the agent prove it didn’t.

This is where AI assistance pays for itself: mechanically converting mysql_* calls and global functions into typed, injectable repositories. The risk is the agent “modernizing” the syntax while silently preserving a SQL injection hole (string-interpolated $id straight into the query).

Open the legacy file, select the function, and use Agent mode so it can read the surrounding call sites and create the new class plus a test in one pass.

Prompt: Modernize the selected get_user() function into App\Repository\UserRepository::findById(int $id): ?User. Use PDO with a prepared statement and bound parameters — no string interpolation into SQL. Return a typed User or null. Wrap PDO failures in a RepositoryException. Then update every call site in this file and add a PHPUnit test that mocks the PDO connection.

Use Cursor’s checkpoint feature before accepting — legacy refactors are exactly the case where you want a one-click revert if a call site breaks.

The target shape — note the bound parameter and the 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]);
$data = $statement->fetch(PDO::FETCH_ASSOC);
return $data === false ? null : User::fromArray($data);
} catch (PDOException $e) {
throw new RepositoryException("Failed to fetch user {$id}", 0, $e);
}
}
}

The naive AI caching prompt produces Cache::remember() calls scattered across the codebase with no invalidation strategy — and stale data in production three days later. Ask for the invalidation path up front.

app/Service/CachedProductService.php
namespace App\Service;
use App\Entity\Product;
use App\Repository\ProductRepository;
use Psr\Cache\CacheItemPoolInterface;
final class CachedProductService
{
private const CACHE_TTL = 3600;
private const PREFIX = 'product:';
public function __construct(
private readonly ProductRepository $repository,
private readonly CacheItemPoolInterface $cache,
) {}
public function findById(int $id): ?Product
{
$item = $this->cache->getItem(self::PREFIX . $id);
if ($item->isHit()) {
return $item->get();
}
$product = $this->repository->find($id);
if ($product !== null) {
$item->set($product)->expiresAfter(self::CACHE_TTL);
$this->cache->save($item);
}
return $product;
}
public function invalidate(int $id): void
{
$this->cache->deleteItem(self::PREFIX . $id);
}
}

If you’re on Symfony rather than Laravel, the same architecture maps to constructor-injected services and Doctrine repositories. Pin the stack just as deliberately: Symfony 8.1 is the current release (requires PHP 8.4+), and Symfony 7.4 LTS (supported into 2029) is the conservative choice for enterprise apps that can’t chase six-month minors. Doctrine entities use PHP 8 attributes for mapping, and repositories extend ServiceEntityRepository. Drive it with the same “thin controller, logic in a service, repository as the data seam” prompt — only the framework primitives change.

This is the section most AI-generated PHP gets subtly wrong. Agents love Prophecy\PhpUnit\ProphecyTrait — but Prophecy’s built-in PHPUnit integration was removed in PHPUnit 10 and now lives in the separate phpspec/prophecy-phpunit package. If the agent generates a ProphecyTrait test without adding that dependency, the test won’t even load against a current PHPUnit. For a “modern PHP” lesson, prefer PHPUnit’s native createMock() — it runs against a vanilla install with zero extra packages.

tests/Unit/Service/OrderServiceTest.php
declare(strict_types=1);
namespace Tests\Unit\Service;
use App\Entity\Order;
use App\Enum\OrderStatus;
use App\Exception\InvalidOrderStateException;
use App\Repository\OrderRepository;
use App\Service\NotificationService;
use App\Service\OrderService;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
class OrderServiceTest extends TestCase
{
#[Test]
public function it_completes_a_processing_order(): void
{
$order = (new Order())->setId(123)->setStatus(OrderStatus::Processing);
$repository = $this->createMock(OrderRepository::class);
$repository->method('find')->with(123)->willReturn($order);
$repository->expects($this->once())->method('save')->with($order);
$notifications = $this->createMock(NotificationService::class);
$notifications->expects($this->once())->method('sendOrderCompleted')->with($order);
$service = new OrderService($repository, $notifications);
$this->assertTrue($service->completeOrder(123));
$this->assertSame(OrderStatus::Completed, $order->getStatus());
}
#[Test]
#[DataProvider('invalidStatuses')]
public function it_rejects_completion_from_invalid_status(OrderStatus $status): void
{
$order = (new Order())->setId(123)->setStatus($status);
$repository = $this->createMock(OrderRepository::class);
$repository->method('find')->with(123)->willReturn($order);
$service = new OrderService($repository, $this->createMock(NotificationService::class));
$this->expectException(InvalidOrderStateException::class);
$service->completeOrder(123);
}
public static function invalidStatuses(): array
{
return [
'already completed' => [OrderStatus::Completed],
'cancelled' => [OrderStatus::Cancelled],
'refunded' => [OrderStatus::Refunded],
];
}
}

Two integrations meaningfully speed up PHP work across all three tools:

  • Postgres / MySQL MCP server — lets the agent read your real schema instead of guessing column names. Generated migrations and Eloquent casts stop drifting from the actual database. The Postgres server is on npm as @modelcontextprotocol/server-postgres; for MySQL, mongodb-mcp-server-style community servers exist but verify before trusting one.
  • GitHub MCP server (@modelcontextprotocol/server-github) — pairs perfectly with the Codex worktree modernization flow: the agent opens the PR, you review the legacy diff in the GitHub UI.

A lightweight alternative to a full MCP server is an Agent Skill for a focused, repeatable task (e.g. a “convert annotations to PHPUnit attributes” skill). Install skills with the universal CLI from vercel-labs/skills: npx skills add <owner>/<repo>. Reach for a skill when you want single-purpose augmentation; reach for an MCP server when you need a persistent connection like live database access.