Modern PHP: Versions, Standards and Testing
Keeping PHP code maintainable: what changed in PHP 8, the PHP-FIG standards and coding style, automated tests with PHPUnit, static analysis, and frameworks.
What changed in PHP 8 and later
Much of the PHP code and many of the tutorials you find online date from the PHP 5 era. PHP 7 (2015) roughly doubled the speed of PHP 5.6 and added scalar type declarations. PHP 8.0 (2020) and the yearly releases since then changed the everyday language more than any release before: code written for 8.x looks noticeably different from older code, and some old behavior is simply gone.
Highlights per version
| Version | Released | Highlights |
|---|---|---|
| 8.0 | Nov 2020 |
Named arguments, union types, attributes, constructor
property promotion, match, the nullsafe
operator ?->, the JIT compiler; saner
string-to-number comparison; many warnings became
TypeError and ValueError
|
| 8.1 | Nov 2021 |
Enums, readonly properties, fibers, first-class callable
syntax strlen(...), the never
return type
|
| 8.2 | Dec 2022 |
Readonly classes, DNF types, true,
false and null as standalone
types; dynamic properties deprecated
|
| 8.3 | Nov 2023 |
Typed class constants, #[\Override],
json_validate(), php -l on several
files at once
|
| 8.4 | Nov 2024 |
Property hooks, asymmetric visibility (public private(set)), #[\Deprecated],
array_find() and friends, a new HTML5 DOM API,
new Foo()->bar()
without extra parentheses; bcrypt default cost 12
|
| 8.5 | Nov 2025 |
The pipe operator |>, a URI extension,
"clone with", #[\NoDiscard],
array_first() and array_last();
OPcache always compiled in
|
PHP 8.6 is scheduled for 19 November 2026 (it was at the release candidate stage in October 2026). No date has been set for a PHP 9. Each release also deprecates features that a later major version may remove, so reading the migration guide on php.net before an upgrade is part of the routine described in upgrading PHP.
Modern PHP in practice
A few lines show the style. Each feature is explained on its own page (classes and objects, functions, control flow); this example runs on PHP 8.2 and later:
<?php
declare(strict_types=1);
enum Tariff: string
{
case Fixed = 'fixed';
case Dynamic = 'dynamic';
}
final readonly class MeterReading
{
public function __construct(
public string $meterId,
public int $kwh,
public Tariff $tariff = Tariff::Fixed,
) {}
}
function price_per_kwh(Tariff $tariff): float
{
return match ($tariff) {
Tariff::Fixed => 0.28,
Tariff::Dynamic => 0.24,
};
}
$reading = new MeterReading(kwh: 312, meterId: 'KE-4471', tariff: Tariff::from('dynamic'));
$cost = $reading->kwh * price_per_kwh($reading->tariff);
printf("%s: %d kWh on %s = EUR %.2f\n", $reading->meterId, $reading->kwh, $reading->tariff->value, $cost);
$customer = null;
echo $customer?->address?->city ?? 'no address', "\n";
$lengths = array_map(strlen(...), ['KE-4471', 'KE-12']);
echo implode(', ', $lengths), "\n";
KE-4471: 312 kWh on dynamic = EUR 74.88 no address 7, 5
The enum allows only two tariffs, the readonly class cannot be
changed after construction, match compares strictly and
fails loudly on a missing case, and the named arguments can come in
any order. In PHP 5 style, the same code would use string constants,
a mutable array and a switch, and each of those
mistakes would pass silently.
Syntax from PHP 8.3, 8.4 and 8.5
The newest features only parse on the version that introduced them. The block below needs PHP 8.5 as a whole (it is not run here, because the examples on this site run on PHP 8.2):
<?php
final class Meter
{
// PHP 8.3: typed class constants
public const string PREFIX = 'KE-';
// PHP 8.4: anyone may read $kwh, only the class itself may change it
public private(set) int $kwh = 0;
// PHP 8.4: a property hook validates every assignment
public string $id {
set(string $value) {
if (!str_starts_with($value, self::PREFIX)) {
throw new ValueError("Invalid meter ID: $value");
}
$this->id = $value;
}
}
}
// PHP 8.5: the pipe operator passes a value through a chain of callables
$slug = ' Den Haag Outage '
|> trim(...)
|> strtolower(...)
|> (fn (string $text) => str_replace(' ', '-', $text));
// $slug is now 'den-haag-outage'
Supported versions
Each PHP branch gets two years of active support (bug and security fixes) and then two years of security fixes only. The situation as of 2 October 2026, from php.net:
| Branch | Active support until | Security fixes until | Status (October 2026) |
|---|---|---|---|
| 8.5 | 31 Dec 2027 | 31 Dec 2029 | Active support |
| 8.4 | 31 Dec 2026 | 31 Dec 2028 | Active support |
| 8.3 | 31 Dec 2025 | 31 Dec 2027 | Security fixes only |
| 8.2 | 31 Dec 2024 | 31 Dec 2026 | Security fixes only, end of life in 2026 |
| 8.1 and older | — | ended (8.1: 31 Dec 2025) | End of life |
For new work, use 8.4 or 8.5. The examples on this site run on PHP 8.2 because that is what the machine that checks them has; they use nothing that newer versions changed. What end of life means for a server you run is covered in deploying PHP.
PHP-FIG and the PSRs
In 2009, developers of several frameworks started the PHP-FIG (PHP Framework Interoperability Group): a group of project representatives who agree on shared interfaces and conventions, so that libraries from different projects work together. Its standards are called PSRs (PHP Standards Recommendations), each identified by a number. A PSR is a recommendation, not part of the language; PHP itself does not enforce any of them.
Most PSRs define an interface: if your code expects "a PSR-3 logger", any logger that implements that interface can be plugged in, whichever project wrote it. The ones you meet most often:
| PSR | Subject | What it gives you |
|---|---|---|
| PSR-1 | Basic coding standard | Naming and file basics (classes in StudlyCaps, methods in camelCase) |
| PSR-3 | Logger interface | One logging API (Monolog implements it) |
| PSR-4 | Autoloading | Namespace-to-folder mapping used by Composer (see Composer and packages) |
| PSR-7, 15, 17, 18 | HTTP | Request and response objects, middleware, factories and an HTTP client interface |
| PSR-11 | Container | A common interface for dependency injection containers |
| PSR-20 | Clock | A replaceable "now", which makes time-dependent code testable |
Not every number is in use. As of 2026, PSR-0 (the old autoloading standard) and PSR-2 (the old coding style) are deprecated, several proposals were abandoned, and a few are still drafts; the current list is on php-fig.org.
Coding style: PER Coding Style
For layout, the PHP-FIG now maintains a "PHP Evolving Recommendation" instead of a fixed PSR. PER Coding Style is the shared style guide for PHP code: indentation, braces, spacing, line length, and how newer syntax such as enums and attributes is written. Its current version, 3.1, extends and replaces PSR-12, which in turn had replaced PSR-2. Unlike a PSR, a PER can be updated when the language gains new syntax.
The main rules, visible in this class:
<?php
declare(strict_types=1);
namespace Kestrel\Billing;
use InvalidArgumentException;
final class InvoiceLine
{
public function __construct(
private readonly string $description,
private readonly int $kwh,
private readonly float $pricePerKwh,
) {
if ($kwh < 0) {
throw new InvalidArgumentException('Usage cannot be negative');
}
}
public function total(): float
{
return round($this->kwh * $this->pricePerKwh, 2);
}
}
-
Four spaces per indentation level, no tabs; Unix line endings;
files end without a closing
?>tag. -
Opening braces of classes and methods on their own line; for
if, loops and closures on the same line. -
Visibility declared on every property, method and constant;
declare,namespaceanduseblocks in a fixed order, separated by blank lines. - A trailing comma after the last item of a multi-line parameter or argument list, so adding a line changes only one line in the diff.
Nobody applies these rules by hand. PHP_CodeSniffer reports style
violations and PHP-CS-Fixer rewrites the code. PHP-CS-Fixer ships
rule sets for PER Coding Style (@PER-CS);
PHP_CodeSniffer ships PSR-12, and PER support comes from third-party
standards. Check each tool's documentation for the current names.
Run the fixer in the editor or before each commit, and let the
pipeline only check. The same reasoning, for JavaScript, is in
code formatters.
Automated tests with PHPUnit
A unit test calls a small piece of code with known input and checks the result. Run after every change, tests catch the regression you would otherwise find in production. The idea fits in a few lines; a test framework adds the bookkeeping:
<?php
declare(strict_types=1);
// The code under test
function energy_cost(int $kwh, float $pricePerKwh, float $standingCharge): float
{
if ($kwh < 0) {
throw new InvalidArgumentException('Usage cannot be negative');
}
return round($kwh * $pricePerKwh + $standingCharge, 2);
}
// A tiny test harness: in a real project, PHPUnit does this part
$results = [];
$check = function (string $name, bool $passed) use (&$results): void {
$results[] = $passed;
echo ($passed ? 'PASS ' : 'FAIL '), $name, "\n";
};
$check('typical month', energy_cost(250, 0.28, 6.50) === 76.5);
$check('zero usage pays only the standing charge', energy_cost(0, 0.28, 6.50) === 6.5);
try {
energy_cost(-5, 0.28, 6.50);
$check('negative usage is rejected', false);
} catch (InvalidArgumentException) {
$check('negative usage is rejected', true);
}
printf("%d of %d checks passed\n", count(array_filter($results)), count($results));
PASS typical month PASS zero usage pays only the standing charge PASS negative usage is rejected 3 of 3 checks passed
PHPUnit
PHPUnit, written by Sebastian
Bergmann, is the standard testing framework for PHP. Tests are
methods of a class that extends TestCase; methods named
test… are found and run automatically, and assertion
methods such as assertSame() report a clear difference
when a check fails. You install it per project as a development
dependency with Composer:
composer require --dev phpunit/phpunit
vendor/bin/phpunit tests
<?php
declare(strict_types=1);
namespace Kestrel\Tests;
use InvalidArgumentException;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use function Kestrel\Billing\energy_cost;
final class EnergyCostTest extends TestCase
{
public function testTypicalMonth(): void
{
$this->assertSame(76.5, energy_cost(250, 0.28, 6.50));
}
public function testNegativeUsageIsRejected(): void
{
$this->expectException(InvalidArgumentException::class);
energy_cost(-5, 0.28, 6.50);
}
/** @return array<string, array{int, float}> */
public static function usageProvider(): array
{
return [
'zero usage' => [0, 6.5],
'one kWh' => [1, 6.78],
'typical month' => [250, 76.5],
];
}
#[DataProvider('usageProvider')]
public function testCostForUsage(int $kwh, float $expected): void
{
$this->assertSame($expected, energy_cost($kwh, 0.28, 6.50));
}
}
PHPUnit 13.… by Sebastian Bergmann and contributors. Runtime: PHP 8.5.… ..... 5 / 5 (100%) Time: 00:00.015, Memory: 10.00 MB OK (5 tests, 5 assertions)
The data provider runs one test method with several sets of input,
and each set counts as a test. PHPUnit 10 and later configure tests
with attributes such as #[DataProvider]; older code
uses doc-comment annotations such as @dataProvider,
which recent versions no longer read.
What to test
- Pure logic first: prices, validation, parsing, date calculations. Functions that take values and return values are the easiest and most valuable to test.
- Edges: zero, negative numbers, empty strings, missing keys, the last day of a month (choosing them systematically: boundary value analysis).
- Bugs: when you fix one, first write a test that fails because of it. It stays as a guard against the bug returning.
- Integration tests for the parts that talk to a database or an API, often against an SQLite database in memory or a test container; slower, so fewer (see Integration and Contract Testing).
Pest is a popular alternative test runner, built on top of PHPUnit, with a shorter function-based syntax. How tests fit in a pipeline, and what code coverage does and does not tell you, is on testing and quality in pipelines.
Static analysis
Static analysis means
checking code by reading it, without running it. For a language that
checks types at runtime, as PHP does, it finds a whole class of bugs
before any user meets them: calling a method on something that may
be null, passing a string where an int is
expected, using a variable that might not be defined, or calling a
function that does not exist.
The simplest check: php -l
PHP itself can check syntax without running a file. It is fast enough for every save and every commit, but it finds only syntax errors:
cat > tariff.php <<'PHP'
<?php
function price_per_kwh(string $tariff): float
{
return $tariff === 'dynamic' ? 0.24 : 0.28;
}
PHP
php -l tariff.php
No syntax errors detected in tariff.php
With a missing semicolon, the same command reports
Parse error: syntax error, unexpected token "}", expecting ";" in
tariff.php on line 5
and exits with a non-zero status, which makes a pipeline step fail.
PHPStan
This script runs fine for the first customer and crashes for the
second, because find_customer() may return
null:
<?php
declare(strict_types=1);
final class Customer
{
public function __construct(public string $name) {}
public function firstName(): string
{
return explode(' ', $this->name)[0];
}
}
/** @param array<string, Customer> $customers */
function find_customer(array $customers, string $email): ?Customer
{
return $customers[$email] ?? null;
}
$customers = ['amara@example.com' => new Customer('Amara Okafor')];
echo find_customer($customers, 'amara@example.com')->firstName(), "\n";
echo find_customer($customers, 'mei@example.com')->firstName(), "\n";
Amara Fatal error: Uncaught Error: Call to a member function firstName() on null in example.php:23
PHPStan is a static analysis tool for
PHP that reads the type declarations, the return types and
doc-comment types such as
array<string, Customer>, and reports every place
where the types do not fit. It works in levels from 0 (only
basic checks such as unknown classes and functions) to 10 (also
strict about values of unknown type); level 8 adds checks for calls
on nullable types, which finds the bug above without running the
code:
composer require --dev phpstan/phpstan
vendor/bin/phpstan analyze --level=8 src
------ ------------------------------------------------------ Line src/customers.php ------ ------------------------------------------------------ 22 Cannot call method firstName() on Customer|null. 23 Cannot call method firstName() on Customer|null. ------ ------------------------------------------------------ [ERROR] Found 2 errors
It reports line 22 too: that customer exists today, but nothing in
the types guarantees it. The fix is to handle the
null case, for example with ?-> and a
default, or an explicit check and an error message.
Start an existing project at a low level and raise it step by step;
a baseline file records the existing errors so the pipeline
only fails on new ones. Psalm is a comparable tool, and Rector uses
the same kind of analysis to rewrite code automatically, for example
to newer syntax during an upgrade. These tools need the types to be
declared, which is one more reason to write
type declarations and use
strict_types.
Debugging with Xdebug
var_dump() and logging go a long way, but for a bug in
a long request you want a
debugger: stop at a
breakpoint, look at every variable, and step through the code line by line.
For PHP that is Xdebug, an extension that connects to your editor
(VS Code, PhpStorm and others). As of 2026, Xdebug 3.5 supports PHP
8.0 to 8.5. It also measures
code coverage
for PHPUnit and can profile slow code.
zend_extension = xdebug
; develop: better error output; debug: step debugging; coverage: for PHPUnit
xdebug.mode = develop,debug
; Start a debug session only when the browser or CLI asks for one
xdebug.start_with_request = trigger
; The port your editor listens on (9003 is the default in Xdebug 3)
xdebug.client_port = 9003
Frameworks and CMSs
Most new PHP applications are built on a framework, and most PHP websites run on a content management system (CMS). Both give you the parts every web application needs: routing URLs to code, templates that escape output, CSRF protection, sessions, validation, database access and migrations, queues and tests. That removes a lot of the security work described in PHP security basics, as long as you use the framework's tools instead of working around them.
| Project | Current (October 2026) | Notes |
|---|---|---|
| Laravel | 13 (March 2026), PHP 8.3–8.5 | Full-stack framework with its own ORM (Eloquent) and templates (Blade); security fixes for 13 until March 2028 |
| Symfony | 8.1 (PHP 8.4+); long-term support 7.4 (PHP 8.2+) | Reusable components, also used inside Laravel and Drupal; 7.4 gets security fixes until November 2029 |
| WordPress | 7.1 (August 2026) | CMS behind about 40% of all websites; needs PHP 7.4+, and its hosting handbook recommends 8.4+ |
| Drupal | 11.4 | CMS built on Symfony components |
The version numbers move fast; the release pages of each project have the current state. For maintenance work, the important question is the same as for PHP itself: is the version you run still getting security fixes?
Putting it together
A maintainable PHP project runs the same checks everywhere: in the editor, before a commit and in the continuous integration pipeline. Composer scripts give each check a short name, so developers and the pipeline run exactly the same commands:
{
"require": {
"php": ">=8.4"
},
"require-dev": {
"phpunit/phpunit": "^13.0",
"phpstan/phpstan": "^2.0"
},
"scripts": {
"lint": "find src tests -name '*.php' -print0 | xargs -0 -n1 php -l",
"analyze": "phpstan analyze --level=8 src tests",
"test": "phpunit tests",
"check": ["@lint", "@analyze", "@test"]
}
}
composer check
The "php": ">=8.4" requirement makes Composer refuse
to install on an older PHP, and composer audit checks
the dependencies for known vulnerabilities. In a pipeline, run the
checks on every PHP version you support, and add the next version as
soon as it appears, so an upgrade holds no surprises.
Further study
php.net's manual and its migration guides (one per version, listing new features, changes and deprecations) are the primary reference. For a formal credential, Zend (by Perforce) offers the Zend Certified PHP Engineer exam, which covers topics through PHP 8.4. It is optional: in job applications, a portfolio and a public Git history usually count for more. Third-party sources list a price of about $195; check zend.com for the current price and format.