GitHub Copilot for PHP

GitHub Copilot Programming LanguagesAcademy lesson 35Cluster 3 · Lesson 10 of 13Intermediate11 min readVersion-sensitive
Published
Updated
Last technically verified
GitHub Copilot for PHPGitHub Copilot Programming Languages10Intermediate/github-copilot/languages/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.

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 a TypeError at 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

copilot-php-demo/ ├── composer.json ├── src/ │ ├── OrderRepository.php │ └── OrderController.php └── tests/ └── OrderRepositoryTest.php

The prompt

Copilot promptGenerate the repositoryCopilot Chat, with composer.json open

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.json before 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 audit checks 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

Accepting a PHP suggestion
  1. Run PHPStan and PHPUnitPHPStan at a high level is doing most of the mechanical review here.
  2. Grep every SQL string for a variableHuman judgementA value interpolated into SQL is injection. There is no benign version.
  3. Check every output is escaped, in contextHuman judgementHTML body, attribute, JavaScript and URL contexts need different escaping.
  4. Check for == where === was meantHuman judgement
  5. Confirm strict_types is declared and types are presentHuman judgement
  6. 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_PREPARES being 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 htmlspecialchars without ENT_QUOTES and 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.

Primary sources