Files

705 lines
20 KiB
Markdown

# PHP Code Review Guide
> PHP 8.x code review guide covering the type system, modern language features, OOP modeling, PDO data access, security, error handling, Composer dependencies, performance, and testing.
## Table of Contents
- [Quick Review Checklist](#quick-review-checklist)
- [Type System & Modern PHP](#type-system--modern-php)
- [Object Modeling](#object-modeling)
- [Input, Output & Security](#input-output--security)
- [Database Access](#database-access)
- [Error Handling](#error-handling)
- [Composer & Dependencies](#composer--dependencies)
- [Performance & Resource Management](#performance--resource-management)
- [Testing & Static Analysis](#testing--static-analysis)
- [Review Checklist](#review-checklist)
- [References](#references)
---
## Quick Review Checklist
### Must-check
- [ ] New files enable `declare(strict_types=1);`
- [ ] Public APIs have parameter, return, and property types
- [ ] User input is validated; output is escaped per context
- [ ] SQL uses parameterized queries or ORM binding
- [ ] Passwords use `password_hash()` / `password_verify()`
- [ ] File uploads validate MIME, size, extension, and storage path
- [ ] `composer.lock` is committed; dependency ranges are reasonable
- [ ] PHPUnit/Pest tests and PHPStan/Psalm static analysis are present
### Common issues
- [ ] Loose comparison `==` / `!=` causing type-juggling vulnerabilities
- [ ] `md5()` / `sha1()` used to store passwords
- [ ] Concatenating SQL, HTML, shell commands, or file paths
- [ ] Using `@` to suppress errors
- [ ] `unserialize()` on untrusted data
- [ ] `$_GET` / `$_POST` / `$_FILES` flowing straight into business logic
- [ ] PHP 8.2+ dynamic properties trigger a deprecation; PHP 9 may turn it into an error
---
## Type System & Modern PHP
### strict_types and explicit types
```php
<?php
// ❌ weak boundary: passing "42" gets silently coerced
function findUser($id) {
return User::find($id);
}
// ✅ enable strict_types at the top of the file; type the public API
declare(strict_types=1);
function findUser(int $id): ?User
{
return User::find($id);
}
```
Don't leave type checking entirely to runtime input validation. Type declarations express an internal contract; input validation expresses how much to trust the boundary. You need both.
### Avoid loose comparisons
```php
<?php
// ❌ strings like "0e12345" can be treated as 0 under loose comparison
if ($providedHash == $storedHash) {
grantAccess();
}
// ✅ strict comparison; use hash_equals() for secrets or tokens
if (hash_equals($storedHash, $providedHash)) {
grantAccess();
}
// ✅ match uses identity checks, so fewer type-juggling surprises than switch
$status = match ($code) {
200 => 'ok',
404 => 'not_found',
default => 'unknown',
};
```
Pay attention to `==`, `!=`, and `in_array($x, $list)` (loose by default) in auth, payment, state machine, and permission logic. Use `===`, `!==`, and `in_array($x, $list, true)` where it matters.
### Union / intersection / nullable types
```php
<?php
// ❌ mixed or untyped makes callers guess the return shape
function loadConfig($source) {
return parseConfig($source);
}
// ✅ express the real contract with types
function loadConfig(string|PathInfo $source): Config
{
return parseConfig($source);
}
// ✅ make null explicit when it's a real business state
function currentUser(): ?User
{
return Auth::user();
}
```
`mixed` can show up at the boundary or while migrating legacy code, but in core business services it usually signals missing modeling.
### The nullsafe operator shouldn't hide missing state
```php
<?php
// ❌ chained nullsafe blurs the reason for failure
$country = $order?->customer?->profile?->country;
// ✅ branch explicitly on critical business state
if ($order === null) {
throw new OrderNotFound();
}
$customer = $order->customer();
if ($customer === null) {
throw new MissingCustomer($order->id);
}
$country = $customer->profile()?->country;
```
Distinguish "optional display field" from "business invariant that must exist." The former is a good fit for `?->`; the latter should fail loudly.
---
## Object Modeling
### Use readonly properties and value objects
```php
<?php
// ❌ public mutable fields let callers change state at will
class Money
{
public $amount;
public $currency;
}
// ✅ express an immutable value object with types and readonly
final readonly class Money
{
public function __construct(
public int $amount,
public string $currency,
) {
if ($amount < 0) {
throw new InvalidArgumentException('Amount must be non-negative');
}
}
}
```
For DTOs, config, and domain value objects, check first whether a `readonly class` or readonly properties can remove hidden side effects.
### Enums instead of string states
```php
<?php
// ❌ string states are easy to typo and can't enumerate the legal set
if ($order->status === 'paied') {
ship($order);
}
// ✅ an enum surfaces illegal states earlier
enum OrderStatus: string
{
case Pending = 'pending';
case Paid = 'paid';
case Cancelled = 'cancelled';
}
if ($order->status === OrderStatus::Paid) {
ship($order);
}
```
When reviewing state machines, permissions, or type fields, look for "magic string values." If the value set is stable, suggest an enum; if it comes from an external system, convert it to an internal enum before it enters the business layer.
### Don't rely on dynamic properties
```php
<?php
// ❌ PHP 8.2+ triggers a deprecation when creating a dynamic property
$user = new User();
$user->emali = 'a@example.com'; // a typo also silently creates a property
// ✅ declare properties or use a dedicated data structure
final class User
{
public function __construct(
public string $email,
) {}
}
```
`#[AllowDynamicProperties]` should be an exception for legacy compatibility, not the default for new code. Watch for serialization, ORM hydration, and test doubles that secretly rely on dynamic properties.
### Don't do heavy I/O in constructors
```php
<?php
// ❌ quietly connecting to the DB on construction makes testing and error handling hard
final class ReportService
{
private PDO $pdo;
public function __construct()
{
$this->pdo = new PDO($_ENV['DSN']);
}
}
// ✅ inject dependencies from the outside
final class ReportService
{
public function __construct(
private PDO $pdo,
) {}
}
```
A constructor should establish the object's invariants — not send HTTP requests, open connections, read large files, or run complex queries.
---
## Input, Output & Security
### Validate input at the boundary
```php
<?php
// ❌ superglobals flow straight into business logic
$user = $service->create($_POST['email'], $_POST['age']);
// ✅ validate and coerce types at the boundary first
$email = filter_input(INPUT_POST, 'email', FILTER_VALIDATE_EMAIL);
$age = filter_input(INPUT_POST, 'age', FILTER_VALIDATE_INT, [
'options' => ['min_range' => 0, 'max_range' => 130],
]);
if ($email === false || $email === null || $age === false || $age === null) {
throw new InvalidInput();
}
$user = $service->create($email, $age);
```
`filter_input()` only handles a slice of basic validation. Complex rules, cross-field constraints, and business constraints still need a dedicated validator or request DTO.
### Escape output per context
```php
<?php
// ❌ user input goes straight into HTML
echo "<h1>Hello {$_GET['name']}</h1>";
// ✅ use htmlspecialchars in an HTML text context
$name = (string) ($_GET['name'] ?? '');
echo '<h1>Hello ' . htmlspecialchars($name, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</h1>';
```
Different contexts need different escaping: HTML text, HTML attributes, URLs, JavaScript strings, and CSS are all different. When a template engine's default escaping is turned off, treat it as a security risk.
### Passwords and randomness
```php
<?php
// ❌ md5/sha1 must not be used for password storage
$hash = md5($password);
// ✅ use PHP's built-in password API
$hash = password_hash($password, PASSWORD_DEFAULT);
if (!password_verify($password, $hash)) {
throw new InvalidCredentials();
}
// ✅ use a CSPRNG for tokens
$token = bin2hex(random_bytes(32));
$code = random_int(100000, 999999);
```
Don't hand-roll salts, round migration, or password comparison. Use `password_needs_rehash()` when you need to upgrade the cost factor.
### Deserialization and object injection
```php
<?php
// ❌ untrusted input into unserialize can trigger object injection
$payload = unserialize($_COOKIE['state']);
// ✅ prefer JSON for external data, and validate its schema/shape
$payload = json_decode($_COOKIE['state'] ?? '{}', true, flags: JSON_THROW_ON_ERROR);
```
If you must process historical serialized data, at least restrict `allowed_classes` and make sure the relevant classes' magic methods can't produce dangerous side effects.
### File uploads and paths
```php
<?php
// ❌ building the path from the raw filename
$target = __DIR__ . '/uploads/' . $_FILES['avatar']['name'];
move_uploaded_file($_FILES['avatar']['tmp_name'], $target);
// ✅ generate a server-side filename, check the upload error and MIME
$file = $_FILES['avatar'];
if ($file['error'] !== UPLOAD_ERR_OK) {
throw new UploadFailed();
}
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($file['tmp_name']);
if (!in_array($mime, ['image/png', 'image/jpeg'], true)) {
throw new InvalidFileType();
}
$target = __DIR__ . '/uploads/' . bin2hex(random_bytes(16)) . '.jpg';
move_uploaded_file($file['tmp_name'], $target);
```
When reviewing upload features, check size limits, MIME detection, extensions, a non-executable storage directory, path traversal, overwrite protection, and any virus-scan or async-processing requirements.
---
## Database Access
### Use parameterized queries
```php
<?php
// ❌ concatenated SQL is an injection risk
$sql = "SELECT * FROM users WHERE email = '" . $_GET['email'] . "'";
$user = $pdo->query($sql)->fetch();
// ✅ PDO prepared statement + bound value
$stmt = $pdo->prepare('SELECT id, email FROM users WHERE email = :email');
$stmt->execute(['email' => $email]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
```
Parameters can only bind values — not table names, column names, or sort direction. Dynamic identifiers must go through a whitelist mapping.
```php
<?php
// ✅ whitelist the dynamic sort column
$columns = [
'created' => 'created_at',
'email' => 'email',
];
$column = $columns[$_GET['sort'] ?? 'created'] ?? $columns['created'];
$stmt = $pdo->query("SELECT id, email FROM users ORDER BY {$column} DESC");
```
### Wrap multi-step writes in transactions
```php
<?php
// ❌ multi-step writes with no transaction leave half-finished state on failure
$orderId = $orders->create($cart);
$inventory->reserve($cart);
$payments->charge($orderId);
// ✅ explicit transaction boundary
$pdo->beginTransaction();
try {
$orderId = $orders->create($cart);
$inventory->reserve($cart);
$payments->recordIntent($orderId);
$pdo->commit();
} catch (Throwable $e) {
$pdo->rollBack();
throw $e;
}
```
Don't casually put external, non-rollbackable side effects (an actual charge, an email, a message dispatch) inside a database transaction. Common patterns are an outbox, an idempotency key, or triggering after the transaction commits.
### Avoid N+1 queries
```php
<?php
// ❌ querying inside a loop
foreach ($orders as $order) {
$customer = $customerRepo->find($order->customerId);
render($order, $customer);
}
// ✅ batch-load, then map
$customerIds = array_unique(array_map(fn ($o) => $o->customerId, $orders));
$customers = $customerRepo->findByIds($customerIds);
foreach ($orders as $order) {
render($order, $customers[$order->customerId] ?? null);
}
```
In ORMs like Laravel/Doctrine, check eager loading, join fetch, selected columns, pagination, and indexes.
---
## Error Handling
### Catch specific exceptions, keep context
```php
<?php
// ❌ swallowing the exception leaves callers unable to know it failed
try {
$mailer->send($message);
} catch (Exception $e) {
}
// ✅ catch a specific exception, keep context, and rethrow
try {
$mailer->send($message);
} catch (TransportException $e) {
throw new NotificationFailed($userId, previous: $e);
}
```
Empty `catch` blocks, `error_log()`-and-continue without surfacing the error, and turning every exception into `RuntimeException('failed')` in production code all deserve a question.
### Don't suppress errors with @
```php
<?php
// ❌ hides the real error and makes debugging hard
$content = @file_get_contents($path);
// ✅ handle failure explicitly
$content = file_get_contents($path);
if ($content === false) {
throw new RuntimeException("Unable to read file: {$path}");
}
```
`@` is common around file, network, array access, and legacy library calls. Push for an explicit branch, or convert third-party errors into project exceptions.
### Don't leak sensitive data in logs
```php
<?php
// ❌ writing tokens, passwords, or the full request body to the log
$logger->error('Login failed', ['request' => $_POST]);
// ✅ log non-sensitive context that still helps locate the problem
$logger->warning('Login failed', [
'email_hash' => hash('sha256', strtolower($email)),
'ip' => $requestIp,
]);
```
Check logs, exception messages, the debug toolbar, error pages, and failed-queue records. Sensitive data includes passwords, tokens, sessions, PII, payment data, and full cookies.
---
## Composer & Dependencies
### Lock reproducible dependencies
```json
{
"require": {
"php": "^8.2",
"monolog/monolog": "^3.0"
},
"require-dev": {
"phpunit/phpunit": "^11.0",
"phpstan/phpstan": "^1.10"
}
}
```
When reviewing `composer.json` / `composer.lock`, watch for:
- Application repos commit `composer.lock`; library repos usually don't
- `require-dev` shouldn't make it into the production image
- The PHP platform version matches the CI version
- Autoload rules aren't too broad (don't load test or script directories)
- `scripts` commands don't depend on a developer's local secret config
### Dependency security and maintenance
```bash
composer audit
composer outdated --direct
composer validate --strict
```
When adding a package, look at its maintenance status — download count isn't the only signal. What matters is its security history, release cadence, minimal dependency footprint, and whether it duplicates the standard library or a framework built-in.
---
## Performance & Resource Management
### Stream large datasets with generators or pagination
```php
<?php
// ❌ loading every record at once
$rows = $repo->all();
foreach ($rows as $row) {
exportRow($row);
}
// ✅ paginate or use a generator to avoid a memory spike
foreach ($repo->cursor() as $row) {
exportRow($row);
}
```
A PHP request lifecycle is short, but CLI jobs, queue workers, and export tasks run for a long time. For that kind of code, watch memory growth, unclosed resources, and global-state pollution especially closely.
### Avoid expensive work inside loops
```php
<?php
// ❌ re-parsing config or opening a connection on every iteration
foreach ($items as $item) {
$client = new ApiClient($_ENV['API_KEY']);
$client->send($item);
}
// ✅ create reusable dependencies outside the loop
$client = new ApiClient($_ENV['API_KEY']);
foreach ($items as $item) {
$client->send($item);
}
```
Watch for database queries, HTTP requests, regex compilation, large array copies, accumulating `array_merge()` appends, and repeatedly reading env vars or config files inside loops.
### Release or scope resources
```php
<?php
// ✅ close file handles after use
$handle = fopen($path, 'rb');
if ($handle === false) {
throw new RuntimeException('Unable to open file');
}
try {
while (($line = fgets($handle)) !== false) {
process($line);
}
} finally {
fclose($handle);
}
```
PDO connections are usually managed by the container, but file handles, curl handles, temp files, locks, and cached objects in queue workers still need an explicit lifecycle.
---
## Testing & Static Analysis
### Test behavior, not implementation details
```php
<?php
// ❌ asserting an internal method call makes refactoring expensive
$mailer->expects($this->once())->method('buildTemplate');
// ✅ assert observable results
$service->sendWelcomeEmail($user);
$this->assertTrue($mailbox->hasMessageFor($user->email));
```
For business services, controllers, and queue jobs, prefer covering observable behavior: inputs/outputs, database state, published events, and dispatched messages.
### Static analysis and formatting
```bash
vendor/bin/phpunit
vendor/bin/phpstan analyse
vendor/bin/psalm
vendor/bin/php-cs-fixer fix --dry-run --diff
vendor/bin/rector process --dry-run
```
When reviewing a PR, check whether the new code lowers the PHPStan/Psalm level, leans heavily on baseline ignores, or uses `@phpstan-ignore-next-line` to paper over a real type problem.
### Isolate test data
```php
<?php
// ❌ the test depends on real time and external services
$service->expireOldSessions();
// ✅ inject a clock and a fake gateway
$clock->setNow(new DateTimeImmutable('2026-01-01T00:00:00Z'));
$service->expireOldSessions();
```
Watch for database transaction rollback, fixture cleanup, randomness, time, queues, caches, and external APIs. Slow PHP tests are usually not a language problem — it's that the boundaries aren't isolated.
---
## Review Checklist
### Types & modeling
- [ ] `declare(strict_types=1);` at the top of the file
- [ ] Parameters, return values, and properties have explicit types
- [ ] `===` / `!==` used; collection lookups use strict mode
- [ ] Stable state sets use an enum, not magic strings
- [ ] New code doesn't rely on dynamic properties
- [ ] Value objects are readonly or otherwise immutable
### Security
- [ ] Input is validated and type-coerced at the boundary
- [ ] Output is escaped per HTML/URL/JS/CSS context
- [ ] SQL uses prepared statements or ORM binding
- [ ] Dynamic table/column/sort names go through a whitelist
- [ ] Passwords use `password_hash()` / `password_verify()`
- [ ] Tokens, codes, and filenames use `random_bytes()` / `random_int()`
- [ ] Untrusted input never reaches `unserialize()`
- [ ] File uploads check the error code, size, MIME, extension, and storage directory
- [ ] No injection or leakage risk in shell commands, path building, or log output
### Data & transactions
- [ ] Multi-step writes have a transaction or compensation mechanism
- [ ] External side effects are designed to be idempotent
- [ ] N+1 queries avoided
- [ ] Pagination, indexes, and selected columns are reasonable
- [ ] Database errors aren't swallowed
### Maintainability
- [ ] Constructors don't do heavy I/O
- [ ] Dependency injection is clear; no hidden global state
- [ ] No `@` error suppression
- [ ] Exceptions preserve context and `previous`
- [ ] Composer dependency ranges, autoload, and scripts are reasonable
- [ ] Application repos commit `composer.lock`
### Testing & tooling
- [ ] PHPUnit/Pest cover the critical and failure paths
- [ ] PHPStan/Psalm config doesn't lower strictness
- [ ] New ignores/baselines are explained
- [ ] Formatting tools and CI commands are reproducible
- [ ] Tests isolate time, randomness, the database, queues, and external APIs
---
## References
- [PHP Manual: Type declarations](https://www.php.net/manual/en/language.types.declarations.php)
- [PHP Manual: match](https://www.php.net/match)
- [PHP Manual: Enumerations](https://www.php.net/manual/en/language.enumerations.overview.php)
- [PHP Manual: Properties](https://www.php.net/manual/en/language.oop5.properties.php)
- [PHP Manual: PDO](https://www.php.net/manual/en/class.pdo.php)
- [PHP Manual: password_hash](https://www.php.net/manual/en/function.password-hash.php)
- [PHP Manual: random_bytes](https://www.php.net/manual/en/function.random-bytes.php)
- [Composer documentation](https://getcomposer.org/doc/)
- [PHPUnit documentation](https://docs.phpunit.de/)
- [PHPStan documentation](https://phpstan.org/user-guide/getting-started)
- [Psalm documentation](https://psalm.dev/docs/)