Cursor Rules for PHP: 6 Rules That Stop AI From Writing Legacy PHP in 2026
Cursor writes PHP fast. The problem? It writes PHP like it's 2012 — mysql_* functions, no type declarations, $result variables everywhere, raw $_GET in SQL queries, god classes with 800 lines, and echo statements instead of proper templating.
Modern PHP (8.2+) is a different language. It has union types, enums, readonly properties, named arguments, fibers, and match expressions. But Cursor doesn't know that unless you tell it.
You can fix this by adding targeted rules to your .cursorrules or .cursor/rules/*.mdc files. Here are 6 rules I use on every PHP project, with bad vs. good examples showing exactly what changes.
Rule 1: Always Use Strict Types and Type Declarations
Every PHP file must start with declare(strict_types=1).
All function parameters and return types must have type declarations.
Use union types and nullable types instead of mixed or no type.
Never use @param for types that can be expressed in the signature.
Without strict types, PHP silently coerces "42abc" into 42. This hides bugs until production.
Without this rule, Cursor generates untyped code:
// ❌ Bad: no types, no strict mode
function calculateDiscount($price, $percentage) {
$discount = $price * ($percentage / 100);
return $price - $discount;
}
function findUser($id) {
$user = User::find($id);
return $user;
}
Pass a string where a float belongs and PHP shrugs. Your invoice shows $NaN.
With this rule, Cursor writes typed PHP:
<?php
declare(strict_types=1);
// ✅ Good: strict types, explicit signatures
function calculateDiscount(float $price, float $percentage): float
{
$discount = $price * ($percentage / 100);
return $price - $discount;
}
function findUser(int $id): ?User
{
return User::find($id);
}
A TypeError fires immediately if you pass the wrong type. Bugs surface in development, not in production.
Rule 2: Never Use Raw Superglobals — Validate All Input
Never access $_GET, $_POST, $_REQUEST, $_COOKIE directly in business logic.
Always validate and sanitize input using filter_input() or a request object.
Never interpolate user input into SQL, HTML, or shell commands.
Use parameterized queries for all database operations.
Raw superglobal access is the #1 source of SQL injection and XSS in PHP applications.
Without this rule, Cursor writes vulnerable code:
// ❌ Bad: raw superglobals, SQL injection, XSS
function searchProducts() {
$query = $_GET['q'];
$results = DB::select("SELECT * FROM products WHERE name LIKE '%{$query}%'");
echo "<h1>Results for {$query}</h1>";
foreach ($results as $product) {
echo "<div>{$product->name} - \${$product->price}</div>";
}
}
Search for '; DROP TABLE products; -- and your database is gone.
With this rule, Cursor validates input:
// ✅ Good: validated input, parameterized query, escaped output
function searchProducts(Request $request): Response
{
$query = $request->validated(['q' => 'required|string|max:100'])['q'];
$results = Product::where('name', 'LIKE', '%' . $query . '%')
->limit(50)
->get();
return view('products.search', [
'query' => $query,
'results' => $results,
]);
}
Input is validated, the query is parameterized, and output is escaped by the templating engine.
Rule 3: Use Enums and Value Objects Instead of Magic Strings
Never use raw strings for status, type, or category values.
Use backed enums (PHP 8.1+) for fixed sets of values.
Use value objects for domain concepts like money, email, or coordinates.
Never compare statuses with string literals.
Magic strings drift silently. Someone types "actve" instead of "active" and no error fires.
Without this rule:
// ❌ Bad: magic strings everywhere
function processOrder($order) {
if ($order->status === 'pending') {
$order->status = 'processing';
// ... process payment
$order->status = 'completed';
} elseif ($order->status === 'canceled') {
// refund
}
}
Typo in "canceled" vs "cancelled"? No error. The order just never gets refunded.
With this rule:
// ✅ Good: backed enum for order status
enum OrderStatus: string
{
case Pending = 'pending';
case Processing = 'processing';
case Completed = 'completed';
case Canceled = 'canceled';
public function canTransitionTo(self $next): bool
{
return match ($this) {
self::Pending => in_array($next, [self::Processing, self::Canceled]),
self::Processing => in_array($next, [self::Completed, self::Canceled]),
default => false,
};
}
}
function processOrder(Order $order): void
{
if ($order->status === OrderStatus::Pending) {
$order->transitionTo(OrderStatus::Processing);
$this->paymentGateway->charge($order);
$order->transitionTo(OrderStatus::Completed);
}
}
Typos become syntax errors. Invalid transitions throw exceptions. The compiler protects you.
Rule 4: One Class, One Responsibility — Max 200 Lines
Each class must have a single, clear responsibility.
Maximum 200 lines per class file.
If a class has more than 5 public methods, it probably does too much.
Extract services, repositories, and value objects instead of growing god classes.
Cursor loves building 500-line controller classes that handle validation, business logic, database queries, email sending, and PDF generation.
Without this rule:
// ❌ Bad: controller doing everything (truncated at 40 lines, real output is 400+)
class OrderController extends Controller
{
public function store(Request $request)
{
// 50 lines of validation...
// 30 lines of inventory check...
// 40 lines of payment processing...
// 20 lines of email sending...
// 15 lines of PDF invoice generation...
// 25 lines of webhook notifications...
return response()->json(['order' => $order]);
}
}
With this rule:
// ✅ Good: thin controller, extracted services
class OrderController extends Controller
{
public function __construct(
private readonly OrderService $orders,
private readonly PaymentGateway $payments,
) {}
public function store(CreateOrderRequest $request): JsonResponse
{
$order = $this->orders->create($request->validated());
$this->payments->charge($order);
return response()->json(['order' => new OrderResource($order)]);
}
}
The controller is 15 lines. Each service is testable independently. You can swap the payment gateway without touching order logic.
Rule 5: Use Constructor Promotion and Readonly Properties
Use constructor promotion (PHP 8.0+) for all DTOs and service classes.
Use readonly properties (PHP 8.1+) for immutable data.
Never use public properties without readonly.
Prefer readonly classes (PHP 8.2+) for value objects and DTOs.
Old-style PHP constructors are 80% boilerplate that obscures the actual data structure.
Without this rule:
// ❌ Bad: verbose boilerplate constructor
class CustomerProfile
{
private string $name;
private string $email;
private int $age;
private ?string $phone;
public function __construct(string $name, string $email, int $age, ?string $phone = null)
{
$this->name = $name;
$this->email = $email;
$this->age = $age;
$this->phone = $phone;
}
public function getName(): string { return $this->name; }
public function getEmail(): string { return $this->email; }
public function getAge(): int { return $this->age; }
public function getPhone(): ?string { return $this->phone; }
}
30 lines for what is essentially a data container.
With this rule:
// ✅ Good: readonly class with constructor promotion
readonly class CustomerProfile
{
public function __construct(
public string $name,
public string $email,
public int $age,
public ?string $phone = null,
) {}
}
8 lines. Same behavior. Properties are immutable by default, no getters needed.
Rule 6: Always Use Named Arguments for Ambiguous Calls
Use named arguments when a function has more than 2 parameters.
Always use named arguments for boolean parameters.
Always use named arguments when the meaning isn't obvious from the value.
Never pass positional booleans.
Positional booleans are code land mines. What does true, false, true mean?
Without this rule:
// ❌ Bad: positional booleans and mystery parameters
$user = createUser('john@test.com', 'John', true, false, 30, true);
$report = generateReport('2024-01', '2024-12', true, false, 'pdf', true);
setcookie('session', $token, time() + 3600, '/', '', true, true);
Nobody knows what those booleans mean without reading the function signature.
With this rule:
// ✅ Good: named arguments make intent clear
$user = createUser(
email: 'john@test.com',
name: 'John',
isAdmin: true,
isVerified: false,
age: 30,
sendWelcomeEmail: true,
);
$report = generateReport(
startMonth: '2024-01',
endMonth: '2024-12',
includeCharts: true,
sendEmail: false,
format: 'pdf',
compress: true,
);
Every argument is self-documenting. Code review takes seconds instead of minutes.
Copy-Paste Ready: All 6 Rules
Drop this into your .cursorrules or .cursor/rules/php.mdc:
# PHP Rules (8.2+)
## Types
- Every file must use declare(strict_types=1)
- All parameters and return types must have type declarations
- Use union types and nullable types, never mixed
## Input Validation
- Never access $_GET/$_POST/$_COOKIE directly in business logic
- Always validate input through request objects or filter_input()
- Use parameterized queries for all database operations
## Enums & Value Objects
- Use backed enums for fixed sets of values (status, type, category)
- Use value objects for domain concepts (money, email, coordinates)
- Never compare with string literals
## Class Design
- One responsibility per class, max 200 lines
- Extract services and repositories from controllers
- Max 5 public methods per class
## Modern Syntax
- Use constructor promotion for all DTOs and services
- Use readonly properties and readonly classes for immutable data
- Never use public properties without readonly
## Named Arguments
- Use named arguments for 3+ parameters
- Always use named arguments for boolean parameters
- No positional booleans ever
The ROI: 6 Rules, Hours Saved Every Week
Each of these rules prevents 15-30 minutes of debugging per incident. If you hit 3 type coercion bugs, 2 SQL injection reviews, and 1 god-class refactor per week, that's 4+ hours saved. Over a month, that's more than two full workdays.
At $27 for the complete rules pack, it pays for itself before lunch on day one.
Want 50+ Production-Tested Rules?
These 6 rules are a starting point. My Cursor Rules Pack v2 includes 50+ rules covering PHP, Laravel, TypeScript, React, Next.js, Python, and more — organized by framework and priority so Cursor applies them consistently.
Stop fighting legacy-style AI output. Give Cursor the rules it needs to write modern PHP.
Top comments (0)