# 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 '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 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 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 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 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 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 Hello {$_GET['name']}"; // ✅ use htmlspecialchars in an HTML text context $name = (string) ($_GET['name'] ?? ''); echo '

Hello ' . htmlspecialchars($name, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '

'; ``` 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 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 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 '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 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 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 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 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 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 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 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 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/)