Architect, build, test, refactor, and maintain modern PHP 8.0-8.4 applications, libraries, and APIs using strict typing, modern language features, PSR standards, Composer dependency management, PDO database access, PHPUnit/Pest testing, PHPStan/Psalm static analysis, and secure coding practices.
61
77%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
Passed
No findings from the security scan
Fix and improve this skill with Tessl
tessl review fix ./skills/development-skills/php-development/SKILL.mdModern PHP (PHP 8.0 through 8.4) is a strictly typed, compiled-to-bytecode, object-oriented language with functional capabilities, native enumerations, pattern matching, cooperative multitasking (fibers), property hooks, and comprehensive static analysis tooling. This skill covers architectural principles, language features, tooling standards, security hardening, and testing workflows required to deliver reliable PHP applications.
For detailed pattern implementations, code cheatsheets, and production templates, refer to references/modern-php-patterns.md.
php -v).composer --version).ext-mbstring, ext-json, ext-pdo, ext-openssl, ext-filter, ext-tokenizer.phpunit/phpunit (v10/v11) or pestphp/pest (v3).phpstan/phpstan (v1.12+) or vimeo/psalm (v5+).friendsofphp/php-cs-fixer or squizlabs/php_codesniffer.Verify environment:
php -v
composer --version
php -m | grep -E "mbstring|json|pdo|opcache"Initialize the project with strict typing defaults, security constraints, and PSR-4 autoloading:
composer init --no-interaction \
--name="vendor-name/package-name" \
--type="project" \
--require="php:^8.3 || ^8.4"Configure composer.json with strict platform pinning and autoload paths:
{
"name": "app/service",
"type": "project",
"require": {
"php": "~8.3.0 || ~8.4.0",
"psr/http-message": "^2.0",
"psr/log": "^3.0"
},
"require-dev": {
"phpstan/phpstan": "^1.12",
"phpunit/phpunit": "^11.3",
"pestphp/pest": "^3.0",
"friendsofphp/php-cs-fixer": "^3.64"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"App\\Tests\\": "tests/"
}
},
"config": {
"sort-packages": true,
"optimize-autoloader": true,
"platform": {
"php": "8.3.0"
}
},
"scripts": {
"test": "pest",
"analyse": "phpstan analyse -c phpstan.neon",
"format": "php-cs-fixer fix --dry-run --diff",
"format:fix": "php-cs-fixer fix",
"check": ["@format", "@analyse", "@test"]
}
}Regenerate autoloader:
composer dump-autoloadPlace declare(strict_types=1); as the first statement in every single PHP file. Use the complete PHP 8 type system:
<?php
declare(strict_types=1);
namespace App\Domain\Model;
// 1. Union Types (PHP 8.0)
function calculateTax(int|float $amount, float $rate): float
{
return (float) ($amount * $rate);
}
// 2. Intersection Types (PHP 8.1)
function countItems(\Countable&\Traversable $collection): int
{
return count($collection);
}
// 3. Disjunctive Normal Form (DNF) Types (PHP 8.2)
function exportCollection((\Countable&\Iterator)|null $items): int
{
return $items !== null ? count($items) : 0;
}
// 4. Standalone literal types & never return type (PHP 8.1 / 8.2)
function alwaysFails(): never
{
throw new \RuntimeException('Operation halted');
}
function verifyFlag(bool $condition): true
{
if (!$condition) {
throw new \InvalidArgumentException('Condition must be true');
}
return true;
}PHP 8.4 asymmetric visibility and property hooks:
<?php
declare(strict_types=1);
namespace App\Domain\Model;
final class Invoice
{
// Asymmetric visibility: public read, private write (PHP 8.4)
public private(set) string $id;
// Property hooks: native get/set logic (PHP 8.4)
public float $rawTotal = 0.0;
public float $finalTotal {
get => $this->rawTotal * 1.20; // Includes 20% VAT
set (float $value) {
$this->rawTotal = $value / 1.20;
}
}
public function __construct(string $id, float $rawTotal)
{
$this->id = $id;
$this->rawTotal = $rawTotal;
}
}readonly class (PHP 8.2+) for immutable Data Transfer Objects (DTOs) and Value Objects.<?php
declare(strict_types=1);
namespace App\Domain\Order;
interface HasLabelInterface
{
public function label(): string;
}
// Backed Enum with interface implementation
enum OrderStatus: string implements HasLabelInterface
{
case Draft = 'draft';
case Pending = 'pending';
case Completed = 'completed';
case Cancelled = 'cancelled';
public function label(): string
{
return match ($this) {
self::Draft => 'Draft Order',
self::Pending => 'Pending Payment',
self::Completed => 'Fulfilled',
self::Cancelled => 'Cancelled',
};
}
public function canCancel(): bool
{
return match ($this) {
self::Draft, self::Pending => true,
self::Completed, self::Cancelled => false,
};
}
}
// Immutable Readonly DTO with constructor property promotion
readonly class CreateOrderCommand
{
public function __construct(
public string $customerId,
public array $items,
public OrderStatus $initialStatus = OrderStatus::Draft,
) {}
}Proper trait usage:
<?php
declare(strict_types=1);
namespace App\Domain\Shared;
// Traits MUST only contain reusable stateless behavioral methods
trait FormatsTimestampsTrait
{
public function formatIso(\DateTimeInterface $dateTime): string
{
return $dateTime->format(\DateTimeInterface::ATOM);
}
}Replace error-prone switch statements and dynamic string callbacks:
<?php
declare(strict_types=1);
namespace App\Application\Pricing;
use App\Domain\Order\OrderStatus;
final readonly class DiscountCalculator
{
public function calculate(OrderStatus $status, float $subtotal, int $loyaltyPoints = 0): float
{
// Match expression: returns value, strict comparison (===), throws UnhandledMatchError if missing
$multiplier = match ($status) {
OrderStatus::Draft, OrderStatus::Pending => 1.0,
OrderStatus::Completed => 0.95,
OrderStatus::Cancelled => 0.0,
};
return $subtotal * $multiplier;
}
public function filterOrders(array $orders): array
{
// First-class callable syntax (PHP 8.1+)
$validator = $this->isValid(...);
return array_filter($orders, $validator);
}
private function isValid(array $order): bool
{
return !empty($order['id']);
}
}
// Invocation with Named Arguments (PHP 8.0+)
$calc = new DiscountCalculator();
$total = $calc->calculate(
status: OrderStatus::Completed,
subtotal: 150.00,
loyaltyPoints: 10
);Replace dockblock @annotation tags with native #[Attribute] syntax:
<?php
declare(strict_types=1);
namespace App\Infrastructure\Routing;
use Attribute;
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)]
final readonly class Route
{
public function __construct(
public string $path,
public string $method = 'GET',
public int $priority = 0,
) {}
}Applying and reading attributes via Reflection:
<?php
declare(strict_types=1);
namespace App\Infrastructure\Routing;
use ReflectionClass;
#[Route(path: '/api/v1/orders', method: 'POST')]
final class OrderController
{
#[\Override] // PHP 8.3 native override check
public function __toString(): string
{
return self::class;
}
}
// Reflection inspection
$reflector = new ReflectionClass(OrderController::class);
$attributes = $reflector->getAttributes(Route::class);
foreach ($attributes as $attribute) {
/** @var Route $routeInstance */
$routeInstance = $attribute->newInstance();
// Register $routeInstance->path and $routeInstance->method
}Use Fiber (PHP 8.1+) for cooperative multitasking without blocking the thread:
<?php
declare(strict_types=1);
namespace App\Infrastructure\Async;
use Fiber;
final class TaskRunner
{
/**
* @param array<callable(): mixed> $tasks
* @return array<int, mixed>
*/
public function runParallel(array $tasks): array
{
$fibers = [];
$results = [];
foreach ($tasks as $i => $task) {
$fibers[$i] = new Fiber($task);
$results[$i] = $fibers[$i]->start();
}
while (!empty($fibers)) {
foreach ($fibers as $i => $fiber) {
if ($fiber->isTerminated()) {
$results[$i] = $fiber->getReturn();
unset($fibers[$i]);
continue;
}
if ($fiber->isSuspended()) {
$fiber->resume();
}
}
}
return $results;
}
}Never concatenate SQL. Disable emulated prepared statements, enable strict exception mode, and wrap multi-step changes in transactions:
<?php
declare(strict_types=1);
namespace App\Infrastructure\Persistence;
use PDO;
use PDOException;
final readonly class DatabaseConnection
{
public static function connect(string $dsn, string $user, #[\SensitiveParameter] string $pass): PDO
{
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false, // Native prepared statements
PDO::ATTR_STRINGIFY_FETCHES => false,
];
return new PDO($dsn, $user, $pass, $options);
}
}
final readonly class OrderDatabaseService
{
public function __construct(private PDO $pdo) {}
public function createOrderWithTransaction(string $userId, array $items): int
{
$this->pdo->beginTransaction();
try {
// Prepared statement with named parameters
$orderStmt = $this->pdo->prepare(
'INSERT INTO orders (user_id, status, created_at) VALUES (:user_id, :status, NOW())'
);
$orderStmt->execute([
'user_id' => $userId,
'status' => 'pending',
]);
$orderId = (int) $this->pdo->lastInsertId();
$itemStmt = $this->pdo->prepare(
'INSERT INTO order_items (order_id, product_id, quantity, price) VALUES (:order_id, :product_id, :qty, :price)'
);
foreach ($items as $item) {
$itemStmt->execute([
'order_id' => $orderId,
'product_id' => $item['product_id'],
'qty' => $item['quantity'],
'price' => $item['price'],
]);
}
$this->pdo->commit();
return $orderId;
} catch (PDOException $e) {
$this->pdo->rollBack();
throw $e;
}
}
}$email = filter_var($inputEmail, FILTER_VALIDATE_EMAIL);
if ($email === false) {
throw new \InvalidArgumentException('Invalid email address format.');
}
$age = filter_var($inputAge, FILTER_VALIDATE_INT, [
'options' => ['min_range' => 18, 'max_range' => 120]
]);// Hash using modern Argon2id algorithm (or PASSWORD_BCRYPT)
$hash = password_hash($plainPassword, PASSWORD_ARGON2ID, [
'memory_cost' => 65536,
'time_cost' => 4,
'threads' => 1,
]);
// Verification
if (!password_verify($plainPassword, $hash)) {
throw new \DomainException('Invalid credentials.');
}
// Automatic rehashing if cost parameters changed
if (password_needs_rehash($hash, PASSWORD_ARGON2ID)) {
$newHash = password_hash($plainPassword, PASSWORD_ARGON2ID);
// Persist $newHash
}Always escape template output using htmlspecialchars with ENT_QUOTES | ENT_SUBSTITUTE and UTF-8:
function escapeHtml(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}Generate cryptographically secure tokens using random_bytes() and verify with hash_equals():
$token = bin2hex(random_bytes(32));
if (!hash_equals($sessionToken, $submittedToken)) {
throw new \DomainException('CSRF validation failed.');
}Convert PHP warnings/notices into ErrorException and manage a structured domain exception hierarchy:
<?php
declare(strict_types=1);
namespace App\Infrastructure\Error;
use ErrorException;
use Throwable;
use Psr\Log\LoggerInterface;
final class ErrorHandler
{
public static function register(LoggerInterface $logger): void
{
error_reporting(E_ALL);
// Convert notices/warnings to exceptions
set_error_handler(
static function (int $severity, string $message, string $file, int $line): bool {
if (!(error_reporting() & $severity)) {
return false;
}
throw new ErrorException($message, 0, $severity, $file, $line);
}
);
// Global uncaught exception logger
set_exception_handler(
static function (Throwable $e) use ($logger): void {
$logger->critical('Uncaught exception: ' . $e->getMessage(), [
'exception' => $e::class,
'file' => $e->getFile(),
'line' => $e->getLine(),
'trace' => $e->getTraceAsString(),
]);
if (php_sapi_name() !== 'cli') {
http_response_code(500);
echo json_encode(['error' => 'An unexpected internal error occurred.']);
}
exit(1);
}
);
}
}Test class using PHP 8 attributes:
<?php
declare(strict_types=1);
namespace App\Tests\Unit;
use App\Domain\Order\OrderStatus;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\DataProvider;
final class OrderStatusTest extends TestCase
{
#[Test]
public function it_identifies_cancellable_states(): void
{
$this->assertTrue(OrderStatus::Draft->canCancel());
$this->assertTrue(OrderStatus::Pending->canCancel());
$this->assertFalse(OrderStatus::Completed->canCancel());
$this->assertFalse(OrderStatus::Cancelled->canCancel());
}
#[Test]
#[DataProvider('statusLabelProvider')]
public function it_returns_correct_label(OrderStatus $status, string $expectedLabel): void
{
$this->assertSame($expectedLabel, $status->label());
}
/**
* @return array<string, array{0: OrderStatus, 1: string}>
*/
public static function statusLabelProvider(): array
{
return [
'draft' => [OrderStatus::Draft, 'Draft Order'],
'pending' => [OrderStatus::Pending, 'Pending Payment'],
'completed' => [OrderStatus::Completed, 'Fulfilled'],
'cancelled' => [OrderStatus::Cancelled, 'Cancelled'],
];
}
}Declarative test suite (tests/Unit/OrderStatusTest.php):
<?php
declare(strict_types=1);
use App\Domain\Order\OrderStatus;
describe('OrderStatus', function (): void {
it('determines cancellation capability correctly', function (OrderStatus $status, bool $expected): void {
expect($status->canCancel())->toBe($expected);
})->with([
[OrderStatus::Draft, true],
[OrderStatus::Pending, true],
[OrderStatus::Completed, false],
[OrderStatus::Cancelled, false],
]);
});Run test suite:
./vendor/bin/phpunit
# or with Pest
./vendor/bin/pestConfigure phpstan.neon at level 8 or 9 (highest strictness):
parameters:
level: 8
paths:
- src
- tests
checkMissingIterableValueType: true
checkGenericClassInNonGenericObjectType: true
treatPhpDocTypesAsCertain: falseRun static analysis:
./vendor/bin/phpstan analyse
./vendor/bin/psalm --show-info=falseFormat code according to PSR-12 / PER Coding Style 2.0 with PHP-CS-Fixer (.php-cs-fixer.dist.php):
<?php
$finder = PhpCsFixer\Finder::create()
->in([__DIR__ . '/src', __DIR__ . '/tests']);
return (new PhpCsFixer\Config())
->setRiskyAllowed(true)
->setRules([
'@PER-CS2.0' => true,
'strict_param' => true,
'declare_strict_types' => true,
'no_unused_imports' => true,
'ordered_imports' => ['sort_algorithm' => 'alpha'],
'single_quote' => true,
])
->setFinder($finder);Fix and verify style:
./vendor/bin/php-cs-fixer fixIn production, configure php.ini to compile bytecode into shared memory:
; Production OPcache configuration
opcache.enable=1
opcache.enable_cli=0
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0
opcache.save_comments=1
; PHP 8 JIT (Tracing JIT)
opcache.jit=tracing
opcache.jit_buffer_size=100M
; OPcache Preloading
opcache.preload=/var/www/app/preload.php
opcache.preload_user=www-dataCreate preload.php to prime the autoloader and core classes before requests arrive:
<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
$files = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator(__DIR__ . '/src')
);
foreach ($files as $file) {
if ($file->isFile() && $file->getExtension() === 'php') {
opcache_compile_file($file->getRealPath());
}
}Production Composer dump:
composer dump-autoload --no-dev --classmap-authoritativedeclare(strict_types=1); to all files.readonly class for DTOs and Value Objects. Use asymmetric visibility (public private(set)) when mutation is encapsulated.#[\SensitiveParameter].PDO::ATTR_EMULATE_PREPARES to ensure true parameterized database execution.\Exception or \Error.composer dump-autoload -o -a in deployment pipelines to bypass runtime file lookups.declare(strict_types=1);: Without this header, PHP silently coerces types across file boundaries even if type hints are present.\Exception: Catches standard exceptions but misses runtime engine errors like TypeError or DivisionByZeroError. Catch \Throwable at the application boundary.==): Always use === or match() expressions to avoid unexpected truthy coercions.unserialize(): Never pass untrusted user input to unserialize(). Use json_decode() or safe binary serializers.PDO::ATTR_EMULATE_PREPARES => true allows potential SQL injection edge cases in certain multi-byte character sets.To verify that the modern PHP codebase is properly configured and functional:
Verify PHP Syntax and Linting:
find src tests -name "*.php" -exec php -l {} \; | grep -v "No syntax errors detected" || echo "All files pass syntax lint"Verify Dependencies and Security Audit:
composer validate --strict
composer auditVerify Autoloading Performance:
composer dump-autoload --optimize --classmap-authoritative --dry-runVerify Static Analysis Quality Gate:
./vendor/bin/phpstan analyse -c phpstan.neon --no-progressVerify Automated Test Suite:
./vendor/bin/phpunit --testdox
# or with Pest
./vendor/bin/pestVerify Code Style Compliance:
./vendor/bin/php-cs-fixer fix --dry-run --diff87555db
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.