GitHub Copilot for PHP
PHP has the widest gap in this cluster between what the language is now and what its public corpus looks like.
Modern PHP has parameter and return types, readonly properties, enums, named arguments, first-class callables, attributes and constructor property promotion. Written well, it is a perfectly reasonable typed language with a very good static analyser available for free.
The code in public repositories is not, on average, that. It spans PHP 4 through
PHP 8, and an enormous quantity of it was written before prepared statements were
routine, before htmlspecialchars was habitual, and before anyone typed a
parameter. That corpus is what shapes suggestions.
So this lesson has a different centre of gravity from the others. The language-specific work in PHP is not managing a type system or an ownership model. It is dragging the suggestion forward twenty years, and doing so systematically enough that you do not have to catch each instance by eye.
PHP quick referenceVerified August 21, 2026
- Type system
- Dynamically typed with optional parameter, return and property types; `declare(strict_types=1)` changes how those types behave.
- Package management
- Composer (Packagist)
- Manifest Copilot should see
- composer.json
- Testing
- PHPUnit, Pest
- Formatting
- PHP-CS-Fixer or PHP_CodeSniffer (PSR-12)
- Linting
- PHP_CodeSniffer
- Static analysis
- PHPStan or Psalm
- Common frameworks
- Laravel, Symfony
- Typical Copilot work
- Controllers, Eloquent and Doctrine queries, form validation, PHPUnit tests, migrations
- First thing to review
- Two decades of insecure PHP in the training data: string-built SQL and unescaped output
Fastest honest checkvendor/bin/phpstan analyse && vendor/bin/phpunit
Key takeaways
declare(strict_types=1)at the top of every file changes what generated code does. Without it, PHP coerces, and a string where an int belonged passes silently.- PHPStan at a high level is the single highest-value tool here. It gives PHP most of what a compiler gives the static languages in this cluster.
- The two security patterns to grep for on every review are SQL built by concatenation and output written without escaping. Both are abundant in the training data.
- Loose comparison (
==) has surprising semantics. Generated code uses it;===is almost always what was meant. - The fastest honest check is
vendor/bin/phpstan analyse && vendor/bin/phpunit.
Types, strict mode and what they change
PHP’s type declarations are checked at runtime, not compile time — but they are checked, and that is enough to be useful.
<?php
declare(strict_types=1);
final class OrderTotal
{
public function __construct(
private readonly PriceFormatter $formatter,
) {
}
/** @param list<OrderLine> $lines */
public function total(array $lines): string
{
$sum = array_reduce(
$lines,
static fn (int $carry, OrderLine $line): int => $carry + $line->amountInCents(),
0,
);
return $this->formatter->format($sum);
}
}Four things there that generated PHP omits by default and that are worth asking for explicitly:
declare(strict_types=1). Without it,total("5")coerces the string and proceeds. With it, that is aTypeErrorat the boundary where the mistake was.- Constructor property promotion and
readonly. Shorter than the traditional form and immutable by construction. - The
@param list<OrderLine>annotation. PHP’s own type system cannot express “array of OrderLine”; PHPStan’s can, and the annotation is what lets it check the loop body. - Integer cents rather than float money. PHP floats are binary floating point with the same rounding behaviour as everywhere else.
Practical project: a validated endpoint
Practical example
A framework-free endpoint with prepared statements and escaped output
Show the two security patterns that matter most in PHP, in the form a review should insist on.
- Status
- Example implementation
- Not executed here
- This code was written against the documentation cited at the end of the lesson but was not run while writing it. Treat the commands as the ones to run, not as output that has been observed.
- Requires
- PHP 8.2 or later with PDO, and a database to connect to
Files
copilot-php-demo/ ├── composer.json ├── src/ │ ├── OrderRepository.php │ └── OrderController.php └── tests/ └── OrderRepositoryTest.php
The prompt
PHP 8.2, declare(strict_types=1), PDO.
Write an OrderRepository with a method recentForCustomer(int $customerId, int $days): array returning paid orders placed in the last N days.
Use a prepared statement with named placeholders. Never interpolate a value into SQL.
Set PDO::ATTR_ERRMODE to exception and PDO::ATTR_EMULATE_PREPARES to false.
Add PHPStan annotations describing the returned array shape.
Implementation
<?php
declare(strict_types=1);
final class OrderRepository
{
public function __construct(private readonly PDO $pdo)
{
}
/** @return list<array{order_id: int, placed_at: string, total_cents: int}> */
public function recentForCustomer(int $customerId, int $days): array
{
$sql = <<<'SQL'
SELECT o.order_id, o.placed_at, SUM(oi.quantity * oi.unit_price_cents) AS total_cents
FROM orders o
JOIN order_items oi ON oi.order_id = o.order_id
WHERE o.customer_id = :customer_id
AND o.status = 'paid'
AND o.placed_at >= (NOW() - INTERVAL :days DAY)
GROUP BY o.order_id, o.placed_at
ORDER BY o.placed_at DESC
SQL;
$statement = $this->pdo->prepare($sql);
$statement->execute([
'customer_id' => $customerId,
'days' => $days,
]);
/** @var list<array{order_id: int, placed_at: string, total_cents: int}> */
return $statement->fetchAll(PDO::FETCH_ASSOC);
}
}Two things worth naming.
PDO::ATTR_EMULATE_PREPARES => false matters more than it looks. With
emulation on — which is the default for MySQL — PDO interpolates the values
client-side rather than sending a genuinely prepared statement. The escaping is
still applied, so it is not an injection hole by itself, but it means the
protection depends on PDO’s escaping being correct for the connection’s character
set rather than on the database never seeing the value as SQL. Turning it off
removes that dependency entirely.
The heredoc with a quoted identifier (<<<'SQL') prevents variable
interpolation in the SQL string. That is not cosmetic: it makes it structurally
impossible for a later edit to slip a $variable into the query. Generated PHP
uses double-quoted strings, where exactly that is possible.
The output side
<?php
declare(strict_types=1);
foreach ($orders as $order) {
printf(
'<tr><td>%s</td><td>%s</td></tr>',
htmlspecialchars((string) $order['order_id'], ENT_QUOTES, 'UTF-8'),
htmlspecialchars($order['placed_at'], ENT_QUOTES, 'UTF-8'),
);
}ENT_QUOTES and an explicit charset are both required for this to be correct in
an attribute context. Generated code frequently calls htmlspecialchars with no
flags, which does not escape single quotes — enough to break out of a
single-quoted HTML attribute. In a templating engine, use its escaping syntax and
be sure you know which of its output constructs escapes and which does not.
Arrays: the one data structure, and what that costs
PHP’s array is an ordered hash map that doubles as a list, a dictionary, a tuple and a record. Almost every generated PHP function takes one and returns one, and almost none of them tell you what is in it.
That is the PHP version of the problem
Python has with untyped parameters, and it is
worse, because array is a real type declaration that satisfies the runtime while
saying nothing at all.
Three things help, in ascending order of rigour:
PHPStan array shape annotations. @param list<OrderLine>,
@return array{id: int, name: string}, @param array<string, int>. These are
comments as far as PHP is concerned and load-bearing types as far as PHPStan is
concerned. Ask for them explicitly; generated code writes @param array $lines,
which is worth nothing.
Value objects instead of associative arrays. A readonly class with typed
promoted properties replaces array{id: int, total: int} with something the
runtime checks, the IDE completes and a typo cannot silently produce. Generated
PHP reaches for the array because the corpus does.
Enums instead of string constants. PHP 8.1 enums turn a status field that
could hold any string into one that can hold four values, checked at the
boundary. A suggestion using 'paid' and 'refunded' as bare strings is
reproducing a pattern that predates the feature by fifteen years.
Two array behaviours worth knowing because generated code trips on them:
array_filter preserves keys, so filtering a list produces an array with gaps
that no longer JSON-encodes as an array; and + on two arrays is a union that
keeps the left side’s keys rather than a concatenation. array_values and
array_merge are the respective fixes.
Why the corpus problem is sharper here
It is worth being specific about why PHP needs more correction than the other dynamic languages in this cluster, because the reason is structural rather than a judgement about the language.
PHP’s install base never broke compatibility hard enough to retire old code. A tutorial written in 2009 still mostly runs. That is a genuine virtue for operators and a problem for a model trained on what exists: the old tutorial is still online, still indexed, still in repositories, and still being copied.
The language’s audience has always included a large population of occasional programmers. Much of the public corpus is not professional application code; it is themes, plugins, snippets and forum answers. That material is disproportionately written under time pressure, disproportionately about output and database access — the two security-critical operations — and disproportionately wrong about both.
Security practice changed after most of the corpus was written. Prepared statements, contextual output escaping, modern password hashing and CSRF tokens are all now routine and were all once rare.
None of this makes PHP a bad language to use with Copilot. It makes the review target specific and predictable, which is genuinely useful: you know before you read the diff which two things to look for, and both are greppable. Compare that with C++, where the dangerous thing is a lifetime relationship that no grep will find.
Debugging and refactoring
For debugging, PHP’s stack traces are informative and its error messages have
improved considerably; pasting a fatal error plus the surrounding function into
chat works well. Two PHP-specific notes: a blank page usually means a fatal error
with display disabled, so the log is the place to look rather than the browser;
and var_dump output pasted into chat is genuinely useful context because it
carries types, which is exactly what is otherwise missing.
Xdebug is worth having configured before you start accepting substantial suggestions. Stepping through generated code you did not write is the fastest way to discover that an array key you assumed exists does not.
For refactoring, the same rule as every dynamic language applies: safety equals test coverage. PHP has one useful addition, though — PHPStan with a baseline lets you refactor incrementally with a real check behind you. Raise the level on the files you are touching, let the analyser enumerate what breaks, and you have something much closer to the compiler-verified refactors available in Java than PHP’s reputation suggests.
Composer and dependencies
Composer with a committed composer.lock gives PHP a solid dependency story:
exact versions, verified installs, and composer install reproducing them.
The PHP-specific review points:
- Read the vendor and package name. Packagist names are
vendor/package, and a plausible wrong vendor is easy to produce. - Check
composer.jsonbefore accepting an import. A suggestion importing a namespace you do not have will fail at autoload time, which in a web request means a 500 rather than a build error. composer auditchecks your lock file against known advisories. Worth running in CI.- Watch the PHP version constraint. A package requiring a newer PHP than your production runtime installs fine locally and fails on deploy.
Frameworks, briefly
Laravel and Symfony dominate, and both are well represented in public code, so suggestions in either are generally idiomatic. Two cautions.
Version drift is severe. Laravel and Symfony both make breaking changes across majors, and public code spans many of them. Name the major version in the prompt.
The framework’s own conventions carry more weight than the language’s. A Laravel codebase and a Symfony codebase share a language and almost nothing else about how a request is handled, where validation lives, or what a service looks like. Naming the framework in the prompt is therefore not a detail — it decides the answer. Keeping one existing controller and one existing test open settles the remaining questions about your project’s conventions far more effectively than describing them does.
The framework’s escaping and binding rules are the security boundary. In
Laravel, Eloquent and the query builder parameterise — but DB::raw and
whereRaw do not, and a suggestion that reaches for them with an interpolated
value has stepped outside the protection. Blade escapes with {{ }} and
deliberately does not with its raw output syntax. Knowing which construct is which
is the whole game, and it is exactly the distinction a model blurs.
PHP-specific risks
SQL injection. The headline risk, for corpus reasons. See below.
Cross-site scripting. The second headline risk, for the same reasons.
Loose comparison. == performs type juggling with genuinely surprising
results. "abc" == 0 was true before PHP 8 and is not now, which is an
improvement — but a suggestion written against older semantics may rely on
behaviour that changed. Use ===, and use in_array with its third parameter
set to true.
Unsafe file handling. include or require with a path built from input is
remote code execution. file_get_contents on a user-supplied URL is server-side
request forgery. Uploaded files saved with a client-supplied name and extension
are a classic path to an executable file in a web-served directory.
Outdated APIs. mysql_* functions (removed), ereg (removed), each
(removed), create_function (removed). These still appear in the corpus and a
suggestion containing one simply fatals.
Dependency hallucination. Covered above.
Weak input validation. filter_var with the right filter, or a validation
library, rather than a hand-rolled regular expression.
Testing, static analysis and formatting
PHPUnit is the default target and Copilot writes it competently. Ask for data providers rather than repeated test methods, and name Pest explicitly if that is what you use — its syntax is entirely different.
PHPStan is the tool that changes the most. Running it at a high level turns a large class of PHP mistakes — a method that does not exist, an array key that is never set, a nullable value dereferenced — into build failures. For reviewing generated code this is close to what a compiler does for Java, and it is available on any codebase incrementally via its baseline feature. Psalm occupies the same role with a different emphasis.
PHP-CS-Fixer or PHP_CodeSniffer against PSR-12 keeps generated code arriving in the project’s style rather than the model’s.
Review workflow
- Run PHPStan and PHPUnitPHPStan at a high level is doing most of the mechanical review here.
- Grep every SQL string for a variableHuman judgementA value interpolated into SQL is injection. There is no benign version.
- Check every output is escaped, in contextHuman judgementHTML body, attribute, JavaScript and URL contexts need different escaping.
- Check for == where === was meantHuman judgement
- Confirm strict_types is declared and types are presentHuman judgement
- Read any new Composer package name and vendorHuman judgement
Steps marked Human judgement are the ones that do not get delegated — they are where you decide whether what Copilot produced is actually right.
Best practices
- Put
declare(strict_types=1)in the prompt and in every file. - Ask for PHP 8 features by name: promotion, readonly, enums, typed properties.
- Run PHPStan at the highest level the codebase tolerates, with a baseline for legacy code.
- Insist on prepared statements with placeholders, and on
EMULATE_PREPARESbeing off. - Escape at output, in the context of the output.
- Use
password_hash, never a general-purpose digest, for passwords.
Common mistakes
- Accepting a query that interpolates a value because “the value is an integer.”
- Calling
htmlspecialcharswithoutENT_QUOTESand a charset. - Letting
==through because the values “are obviously the same type.” - Trusting a framework’s raw output or raw query helper to escape anything.
- Skipping static analysis because the code runs.
Where to go next
GitHub Copilot for SQL covers the query side in depth, including parameterisation and the destructive-statement review this lesson only touches. GitHub Copilot for JavaScript covers the front end that most PHP applications ship alongside.
Sources
Every version-sensitive claim on this page was checked against first-party documentation. Only sources actually used are listed.
Your progress
Saved in this browser only. No account, no server, and nothing leaves your device. Clearing site data resets it.
Was this lesson helpful?
Your answer is stored in this browser and is not sent anywhere.