PHP Interview Questions and Answers

Last updated:

Check out 45 of the most common PHP interview questions, then take an AI-powered practice interview

LaravelWordPressSymfonyMySQLCodeIgniter
45+
Questions
18
Basic
18
Intermediate
9
Advanced
Q1

Why does 0 == 'foo' return false in PHP 8 when it returned true in PHP 7?

BasicType Juggling

Answer

PHP 8.0 shipped the 'Saner string to number comparisons' RFC, which reversed the direction of the loose comparison between an integer and a non-numeric string. In PHP 7 and earlier, comparing an int with a string cast the string to a number, so 'foo' became 0 and 0 == 'foo' was true. From PHP 8.0, if the string is not numeric, PHP casts the integer to a string instead and does a string comparison, so '0' == 'foo' is false.

If the string is numeric ('10', ' 10', '1e2'), PHP still compares numerically, so 100 == '1e2' remains true in both versions. This change silently fixed a whole class of authentication bugs. The famous one is magic hash comparison: md5 outputs that look like '0e462097431906509019562988736854' were treated as the number zero in scientific notation, so two different passwords whose md5 both started with 0e compared equal under ==.

Interviewers use this question to check three things: that you know identity comparison with === never does type juggling, that you know loose comparison rules changed in 8.0 rather than assuming PHP is still the language from a 2015 tutorial, and that you reach for hash_equals() for anything secret-related because it is timing safe. In an upgrade interview, expect a follow-up on where this can break old code: in_array() and switch() both use loose comparison by default, so passing strict=true to in_array or moving to match() is the safe migration.

<?php
// PHP 7.x         PHP 8.x
var_dump(0 == 'foo');    // true            false
var_dump(0 == '');       // true            false
var_dump('abc' == 0);    // true            false
var_dump('1' == '01');   // true            true   (both numeric)
var_dump(100 == '1e2');  // true            true   (both numeric)
var_dump(null == false); // true            true   (unchanged)

// The auth bypass this closed: md5('240610708') is a 'magic hash'
$hash = '0e462097431906509019562988736854';
if ($hash == '0') { /* PHP 7: true. PHP 8: false */ }

// switch and in_array still use loose comparison
var_dump(in_array(0, ['a', 'b']));        // false on PHP 8
var_dump(in_array('1', [1, 2], true));    // false: strict mode

// For secrets, always constant time
var_dump(hash_equals($expectedToken, $givenToken));

Key Points

  • PHP 8.0 casts the int to string when the string is non-numeric
  • === never juggles types and is the default you should reach for
  • Magic hash 0e... comparisons were the classic auth bypass this killed
  • in_array() and switch() still use == unless you pass strict=true
  • hash_equals() for tokens and signatures, never == or ===
💡 Pro Tip: In an upgrade audit, grep for in_array( without a third argument. That is where PHP 7 to 8 comparison changes usually bite.
Q2

What exactly does declare(strict_types=1) change, and why is it per-file?

BasicType System

Answer

By default PHP runs in coercive typing mode: if a function declares int $a and you pass the string '5', PHP quietly converts it. With declare(strict_types=1) at the top of a file, scalar type declarations in that file are enforced exactly, and a mismatch throws a TypeError instead of coercing. The one exception PHP keeps is int to float widening, because that is lossless.

The critical detail interviewers probe is that strict_types is a property of the calling file, not the declaring file. If strict.php calls a function defined in loose.php, strict mode applies, because the call site decides. That is why adding declare(strict_types=1) to a single new class in a legacy codebase changes almost nothing: it only tightens calls made from inside that file.

The declare must be the very first statement in the file, before namespace and use, and it cannot be wrapped in a block or applied to an include. Return types obey the same rule. In practice every serious PHP codebase in 2026 puts strict_types on every file and enforces it with a PHP-CS-Fixer rule or a PHPStan check, because coercive mode hides bugs: a route parameter arriving as the string '12abc' becomes 12 with only a warning under coercion, which is exactly the kind of silent data corruption that turns into a support ticket a week later. Note that strict_types does not apply to internal function calls made by the engine itself, and it does not validate array shapes or object properties, that is what PHPStan and typed properties are for.

<?php
declare(strict_types=1); // must be the FIRST statement in this file

function applyDiscount(int $paise, float $rate): int
{
    return (int) round($paise * (1 - $rate));
}

applyDiscount(10000, 0.1);    // 9000
applyDiscount(10000, 1);      // 9000: int -> float widening is allowed
applyDiscount('10000', 0.1);  // TypeError under strict_types
                              // 9000 silently under coercive mode

// The CALLER's mode wins.
// legacy.php has no declare, so this coerces even though
// applyDiscount lives in a strict file:
//   applyDiscount('10000', 0.1); // -> 9000, no error

// Typed properties are enforced regardless of strict_types
final class Invoice
{
    public int $amountPaise;
}
$i = new Invoice();
echo $i->amountPaise; // Error: must not be accessed before initialization

Key Points

  • Strict mode throws TypeError instead of coercing scalars
  • int to float widening is still permitted
  • The calling file's declare decides, not the declaring file
  • Must be the first statement, before namespace and use
  • Typed properties throw on uninitialised access even without it
Q3

How does a PHP array actually work, and when does array_is_list() matter?

BasicArrays

Answer

A PHP array is a single data structure that behaves as both a list and a hash map: an ordered hash table where keys are ints or strings and insertion order is preserved. Internally the engine keeps two shapes. A packed array has sequential integer keys starting at zero and stores values in a plain C array, which is fast and compact.

As soon as you add a string key, a gap, or a negative key, the array converts to a hashed layout with a bucket table, which costs more memory per element. Recent PHP versions have made packed arrays cheaper still, but the conversion is one way for that array. array_is_list(), added in PHP 8.1, returns true only for the packed shape: keys 0..n-1 with no gaps. This matters most at serialisation boundaries. json_encode() emits a JSON array only when the PHP array is a list; the moment you unset() an element in the middle, the same variable serialises as a JSON object with numeric string keys, and a JavaScript client that expected an array breaks.

The fix is array_values() before encoding, or JSON_FORCE_OBJECT if the object shape is genuinely what you want. The other classic trap is the union operator versus array_merge(): + keeps the value from the left operand on key collision and never renumbers, while array_merge() lets the right operand win for string keys but renumbers integer keys from zero. Getting these two confused is one of the most common causes of mysterious config-merge bugs.

<?php
$a = ['x', 'y', 'z'];
unset($a[1]);

var_dump(array_is_list($a));          // false: keys are 0 and 2
echo json_encode($a);                 // {"0":"x","2":"z"}  not an array
echo json_encode(array_values($a));   // ["x","z"]

// + keeps the LEFT operand on collision, never renumbers
var_dump([5 => 'a'] + [5 => 'b', 9 => 'c']);   // [5=>'a', 9=>'c']

// array_merge lets the RIGHT win, but renumbers integer keys
var_dump(array_merge([5 => 'a'], [5 => 'b'])); // [0=>'a', 1=>'b']

// Config merging: + is what you usually want for defaults
$defaults = ['perPage' => 20, 'sort' => 'id'];
$input    = ['perPage' => 50];
var_dump($input + $defaults);         // ['perPage'=>50, 'sort'=>'id']

// PHP 8.4 array helpers, no more foreach-and-break
$jobs = [['id' => 1, 'city' => 'Pune'], ['id' => 2, 'city' => 'Noida']];
$hit  = array_find($jobs, fn(array $j): bool => $j['city'] === 'Noida');
var_dump(array_any($jobs, fn($j) => $j['id'] > 1)); // true

Key Points

  • One structure, two internal layouts: packed list and hashed map
  • array_is_list() checks for keys 0..n-1 with no gaps
  • unset() in the middle turns a JSON array into a JSON object
  • + keeps the left value and preserves keys; array_merge renumbers ints
  • PHP 8.4 added array_find, array_any and array_all
💡 Pro Tip: Before any json_encode() of a filtered array, call array_values(). array_filter() preserves keys, and that is the single most common cause of an API returning an object where the client expects an array.
Q4

Explain the difference between ??, ?: and isset(), and when each one is wrong.

BasicOperators

Answer

The null coalescing operator ?? returns the left operand unless it is null or the key or variable does not exist. It suppresses the undefined index warning, which is why it is safe on $_GET and nested array reads. The short ternary ?: returns the left operand unless it is falsy, and falsy in PHP includes 0, 0.0, the empty string, the string '0', the empty array and false. ?: does not suppress undefined index warnings, so $arr['missing'] ?: 'x' emits a warning and then returns 'x'.

That difference is where real bugs live: a retry count of 0 or a price of 0 gets silently replaced by your default when you use ?:. isset() returns false both for undefined variables and for variables holding null, so isset($x) and $x !== null are not the same when $x may be undefined. empty() is isset() plus a falsy test, so empty('0') is true, which surprises people validating form input where a user legitimately typed zero. array_key_exists() is the only one of the group that distinguishes 'key present with a null value' from 'key absent', which matters when you are patching a record and null means 'clear this field'. PHP 7.4 added the null coalescing assignment ??=, which assigns only if the target is null or unset, giving you a clean way to fill defaults into a config array without an if. In review, the rule I use is: ?? for existence, ?: only when you genuinely want falsy handling and you can defend the choice.

<?php
$config = ['retries' => 0, 'host' => null];

var_dump($config['retries'] ?? 3);   // 0        ?? only checks null
var_dump($config['retries'] ?: 3);   // 3        BUG: 0 is falsy
var_dump($config['host'] ?? 'local');// 'local'  value is null
var_dump($config['nope'] ?: 'x');    // 'x' + Warning: Undefined key

var_dump(isset($config['host']));            // false: value is null
var_dump(array_key_exists('host', $config)); // true:  key exists

var_dump(empty('0'));   // true  <- the classic form-validation bug
var_dump(empty(0.0));   // true
var_dump(empty([]));    // true

// ??= fills defaults without an if
$opts = ['timeout' => null];
$opts['timeout'] ??= 30;   // 30
$opts['retries'] ??= 3;    // 3

// Deep reads are safe with ?? and never warn
$city = $payload['user']['address']['city'] ?? 'unknown';

Key Points

  • ?? checks null or not-set; ?: checks falsy and still warns
  • 0, '0', '', [], 0.0 and false are all falsy: ?: eats them
  • isset() is false for null values; array_key_exists() is not
  • empty('0') is true, which breaks zero-valued form fields
  • ??= is the clean way to seed defaults into config arrays
Q5

What is the foreach-by-reference bug that leaves the last array element duplicated?

BasicReferences

Answer

When you write foreach ($rows as &$row), PHP binds $row as a reference to each element in turn. When the loop finishes, $row is still a live reference to the final element, and PHP does not clean it up because references in PHP are not scoped to the loop. If you then run a second, ordinary foreach over the same array using the same variable name, every iteration assigns the current value into $row, which writes through the reference into the last slot of the array.

The result is that the final element ends up holding the second-to-last value, and everyone loses an hour. The fix is a single line: unset($row) immediately after the by-reference loop, which breaks the binding without touching the array. This is one of the most reliably asked PHP screening questions because it tests whether you understand that PHP references are aliases in the symbol table, not pointers to memory, and because it shows up in real code whenever someone normalises rows in place.

There are two safer habits. First, prefer array_map() or building a new array when you can, since it avoids references entirely and reads better. Second, if you must modify in place, use the key form: foreach ($rows as $i => $row) { $rows[$i] = transform($row); }, which is only marginally slower thanks to copy-on-write and has no reference to leak. Also remember that by-reference iteration forces the array out of copy-on-write sharing, so it can spike memory on large arrays that were previously shared with a caller.

<?php
$rows = ['a', 'b', 'c'];

foreach ($rows as &$row) {
    $row = strtoupper($row);
}
// $row is STILL a reference to $rows[2]

foreach ($rows as $row) {
    // pass 1: $rows[2] = 'A'
    // pass 2: $rows[2] = 'B'
    // pass 3: $rows[2] = $rows[2], which is now 'B'
}

print_r($rows); // ['A', 'B', 'B']   not ['A', 'B', 'C']

// Fix 1: break the binding
foreach ($rows as &$row) { $row = strtoupper($row); }
unset($row);

// Fix 2: no references at all
$rows = array_map(strtoupper(...), $rows); // first-class callable, 8.1+

// Fix 3: write back by key
foreach ($rows as $i => $value) {
    $rows[$i] = strtoupper($value);
}

Key Points

  • &$row survives the loop as a reference to the last element
  • A later foreach with the same variable overwrites that element
  • unset($row) right after the loop is the one-line fix
  • array_map or write-back-by-key avoids the problem entirely
  • By-reference iteration also breaks copy-on-write sharing
💡 Pro Tip: Make unset() after any by-reference foreach a review rule. PHPStan and Psalm will not catch this for you; it is legal code that simply does the wrong thing.
Q6

How does PSR-4 autoloading work, and what breaks it after a deploy?

BasicComposer

Answer

PSR-4 maps a namespace prefix to a base directory. You declare the mapping in composer.json, Composer generates vendor/autoload.php, and when PHP hits an undefined class the registered spl_autoload_register callback converts the fully qualified class name into a path: the prefix is replaced by the base directory, remaining namespace separators become directory separators, and .php is appended. So with the prefix App mapped to src/, the class App\Billing\InvoiceService resolves to src/Billing/InvoiceService.php.

Three things break this in production. First, case: Linux filesystems are case sensitive while macOS is usually not, so a class named InvoiceService in a file called invoiceservice.php works on a developer laptop and fatals on the server with 'Class not found'. Second, a stale autoloader: Composer's default classmap is only partial, and if you add a class without running composer dump-autoload the file may still be found by the PSR-4 rule but any classmap-authoritative build will refuse to look at the filesystem at all.

Third, deploys that copy new source but keep an old vendor directory. On deploy you should run composer install --no-dev --optimize-autoloader, which builds a full classmap so every class is a single array lookup instead of a filesystem stat. Adding --classmap-authoritative goes further and disables filesystem fallback entirely, which is faster but means any class missing from the map is fatal, so it only suits codebases with no dynamically generated classes. The files autoload key is for procedural helper files that must be included on every request; keep that list short, since every entry is a require on every single request.

// composer.json
{
  "autoload": {
    "psr-4": { "App\\": "src/" },
    "files": ["src/helpers.php"]
  },
  "autoload-dev": {
    "psr-4": { "Tests\\": "tests/" }
  }
}

// src/Billing/InvoiceService.php
<?php
declare(strict_types=1);

namespace App\Billing;   // must match the directory exactly, case included

use App\Support\Money;

final class InvoiceService
{
    public function __construct(private readonly Money $money) {}
}

// Deploy step: full classmap, no filesystem stat per class
// composer install --no-dev --prefer-dist --optimize-autoloader
// composer dump-autoload --classmap-authoritative

Key Points

  • Namespace prefix maps to a directory; the rest becomes the path
  • Case-sensitive Linux filesystems are the top cause of 'Class not found'
  • --optimize-autoloader builds a full classmap for O(1) lookups
  • --classmap-authoritative disables filesystem fallback entirely
  • Every entry in the files key is required on every request
Q7

What is the difference between composer install and composer update, and what belongs in composer.lock?

BasicComposer

Answer

composer.json states your intent as version constraints; composer.lock records the exact resolved versions, including every transitive dependency, with commit hashes and a content hash of composer.json. composer install reads the lock file and installs precisely those versions, which is what makes a build reproducible across your laptop, CI and production. composer update ignores the pinned versions, re-resolves the constraints against Packagist, and rewrites the lock file. Running update on a deploy is a common and dangerous mistake: it means the code you tested in CI is not necessarily the code running in production, because a patch release could have landed in between. The lock file must be committed for applications.

For libraries it is usually gitignored, because the consuming application resolves versions itself and a library's lock file would only affect its own CI. Constraint syntax comes up constantly: ^7.8 allows anything below 8.0.0, ~7.8.0 allows anything below 7.9.0, 7.8.* is the same as ~7.8.0, and pinning an exact version is generally worse than a caret range because it blocks security patches. To bump one package without touching the rest, run composer update vendor/package --with-dependencies.

On deploy the standard command is composer install --no-dev --prefer-dist --optimize-autoloader --no-interaction, because --no-dev keeps PHPUnit and Xdebug helpers out of the production vendor tree and shrinks the image. Two commands worth knowing for a security-focused interview: composer audit checks installed versions against the Packagist advisory database and exits non-zero on a hit, and requiring roave/security-advisories as a dev dependency makes Composer itself refuse to install a known-vulnerable version.

# Reproducible: installs exactly what composer.lock pins
composer install --no-dev --prefer-dist --optimize-autoloader --no-interaction

# Re-resolves constraints and rewrites the lock. Never on a deploy.
composer update

# Bump one package and only what it needs
composer update guzzlehttp/guzzle --with-dependencies

# Constraint meanings
#   "^7.8"    >= 7.8.0  < 8.0.0     (recommended default)
#   "~7.8.0"  >= 7.8.0  < 7.9.0     (patch only)
#   "7.8.*"   same as ~7.8.0
#   "7.8.3"   exact pin, blocks security patches

# Fail CI on a known CVE in any installed package
composer audit --format=plain

# Refuse to even install a vulnerable version
composer require --dev roave/security-advisories:dev-latest

# Why did I get this version?
composer why-not symfony/console 7.2

Key Points

  • install reads the lock; update rewrites it
  • Commit composer.lock for applications, not for libraries
  • ^ allows minor bumps, ~ allows patch bumps only
  • --no-dev keeps test tooling out of the production image
  • composer audit plus roave/security-advisories for CVE gating
Q8

How do you prevent SQL injection with PDO, and what does PDO::ATTR_EMULATE_PREPARES change?

BasicDatabase

Answer

Prepared statements separate the SQL text from the data: the server parses the query once with placeholders, then you send values that can never be reinterpreted as SQL. In PDO you call prepare() with ? or :named placeholders and pass values through execute() or bindValue(). The subtlety interviewers dig into is emulation.

By default the MySQL PDO driver has ATTR_EMULATE_PREPARES set to true, which means PDO does not use the MySQL binary protocol at all; it quotes the values itself with the connection charset and sends one fully-formed SQL string. Emulation is still safe against injection provided the DSN sets charset=utf8mb4, because the escaping is charset aware, but it changes behaviour in ways that bite. With emulation on, every bound value goes to the server as a string, so a query like WHERE id = :id on an indexed BIGINT column can produce a different plan, and LIMIT :n fails outright unless you bind it with PDO::PARAM_INT.

With emulation off you get real server-side prepares, correct types, and protection from a second query being appended, but you also lose the ability to reuse a placeholder name twice in one statement and you pay an extra round trip per statement. My default production configuration is emulation off, ERRMODE_EXCEPTION so failures throw PDOException instead of returning false, FETCH_ASSOC, and STRINGIFY_FETCHES off so integer columns come back as PHP ints. The other thing to say out loud in an interview: you cannot bind identifiers. Table names, column names and ORDER BY directions must be validated against a whitelist array with in_array($value, $allowed, true), never interpolated from request input.

<?php
$pdo = new PDO(
    'mysql:host=127.0.0.1;dbname=jobs;charset=utf8mb4',
    $user,
    $pass,
    [
        PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        PDO::ATTR_EMULATE_PREPARES   => false, // real server-side prepares
        PDO::ATTR_STRINGIFY_FETCHES  => false, // INT columns stay int
    ]
);

$stmt = $pdo->prepare(
    'SELECT id, title FROM jobs WHERE city = :city AND active = :active LIMIT :lim'
);
$stmt->bindValue(':city', $city, PDO::PARAM_STR);
$stmt->bindValue(':active', 1, PDO::PARAM_INT);
$stmt->bindValue(':lim', $limit, PDO::PARAM_INT); // LIMIT breaks without this
$stmt->execute();
$rows = $stmt->fetchAll();

// Identifiers cannot be bound. Whitelist them.
$allowedSort = ['created_at', 'salary_max'];
$sort = in_array($_GET['sort'] ?? '', $allowedSort, true)
    ? $_GET['sort']
    : 'created_at';
$pdo->query("SELECT id FROM jobs ORDER BY {$sort} DESC LIMIT 20");

Key Points

  • Placeholders keep data out of the parsed SQL text
  • MySQL PDO emulates prepares by default; turn it off in production
  • Emulation sends every value as a string and breaks LIMIT binding
  • ERRMODE_EXCEPTION so a failed query throws instead of returning false
  • Identifiers and ORDER BY must come from a whitelist, never from input
💡 Pro Tip: Set charset=utf8mb4 in the DSN, not with a SET NAMES query. Only the DSN tells PDO which charset to use when it escapes values in emulated mode.
Q9

How should passwords be stored in PHP, and what is password_needs_rehash() for?

BasicSecurity

Answer

Use password_hash() with PASSWORD_DEFAULT and password_verify() to check. password_hash() generates a cryptographically secure salt for you, applies a deliberately slow key derivation function, and returns a self-describing string that embeds the algorithm identifier, the cost parameters and the salt. That self-describing format is the whole point: password_verify() reads the parameters back out of the stored hash, so you never store a salt column and you never have to know which algorithm an old row used. PASSWORD_DEFAULT currently maps to bcrypt, and the constant is deliberately allowed to change in future PHP releases, which is why your column must be VARCHAR(255) rather than CHAR(60).

If you want memory-hard hashing, PASSWORD_ARGON2ID takes memory_cost, time_cost and threads options. password_needs_rehash() is how you migrate transparently: on a successful login you still hold the plaintext for a moment, so you check whether the stored hash matches your current algorithm and cost, and if not you rehash and update the row. Over a few weeks of normal logins your entire user table upgrades with no password reset email. Two PHP-specific gotchas interviewers like.

First, bcrypt truncates the input at 72 bytes, so pre-hashing with md5 or sha1 hex output is dangerous because hex expands the length and can create collisions; if you need long passphrases, pre-hash with raw binary sha256 and base64 it. Second, never compare hashes with == or ===: use password_verify(), or hash_equals() for non-password secrets like API tokens and webhook signatures, because both are constant time.

<?php
// Registration
$hash = password_hash($plain, PASSWORD_DEFAULT); // bcrypt today
// or memory-hard:
$hash = password_hash($plain, PASSWORD_ARGON2ID, [
    'memory_cost' => 65536, // 64 MB
    'time_cost'   => 4,
    'threads'     => 2,
]);

// Login
if (!password_verify($plain, $row['password_hash'])) {
    throw new AuthenticationFailed();
}

// Transparent upgrade while you still hold the plaintext
if (password_needs_rehash($row['password_hash'], PASSWORD_DEFAULT)) {
    $pdo->prepare('UPDATE users SET password_hash = ? WHERE id = ?')
        ->execute([password_hash($plain, PASSWORD_DEFAULT), $row['id']]);
}

// API tokens and webhook signatures: constant-time compare
if (!hash_equals($expectedSignature, $headerSignature)) {
    http_response_code(401);
    exit;
}

// Column must be VARCHAR(255). PASSWORD_DEFAULT can change per release.

Key Points

  • password_hash embeds algorithm, cost and salt in the output string
  • PASSWORD_DEFAULT may change between PHP versions, so use VARCHAR(255)
  • password_needs_rehash upgrades users silently on next login
  • bcrypt truncates at 72 bytes; never pre-hash to hex
  • hash_equals for tokens and signatures, password_verify for passwords
Q10

What are backed enums in PHP 8.1, and when do you use from() versus tryFrom()?

BasicEnums

Answer

PHP 8.1 introduced native enums, which come in two flavours. A pure enum has cases with no scalar value and is useful for internal states. A backed enum declares a string or int backing type, so each case has a value that can be persisted to a database column, sent over an API, or read from a config file.

Enum cases are singleton objects, so === comparison works and identity is guaranteed; there is exactly one ApplicationStatus::Applied instance per request. Enums can implement interfaces, declare constants and define methods, which is what makes them so much better than class constants: you can put behaviour next to the state, for example a label() method or an isTerminal() check, and the compiler guarantees a match() over cases is exhaustive at runtime by throwing UnhandledMatchError if a case is missed. from() and tryFrom() exist only on backed enums. from() throws a ValueError when the input does not correspond to a case, tryFrom() returns null. The rule is straightforward: use from() when the value comes from a trusted source such as your own database column and a mismatch means data corruption you want to hear about loudly; use tryFrom() when the value comes from a request, a queue payload or a third-party webhook, and pair it with a null check that returns a 422. cases() returns every case in declaration order, which is handy for building dropdowns. The limitations interviewers check for: enums cannot have state, you cannot instantiate them, and you cannot use them as array keys, which is why SplObjectStorage or a match() is the usual workaround.

<?php
declare(strict_types=1);

enum ApplicationStatus: string
{
    case Applied     = 'applied';
    case Shortlisted = 'shortlisted';
    case Rejected    = 'rejected';

    public function isTerminal(): bool
    {
        return $this === self::Rejected;
    }

    public function label(): string
    {
        return match ($this) {   // exhaustive: adding a case breaks loudly
            self::Applied     => 'Applied',
            self::Shortlisted => 'Shortlisted',
            self::Rejected    => 'Not selected',
        };
    }
}

// Trusted source: a bad value means corrupt data, so fail hard
$status = ApplicationStatus::from($row['status']);      // ValueError if unknown

// Untrusted source: request body, webhook, queue message
$status = ApplicationStatus::tryFrom($request['status'])
    ?? throw new InvalidArgumentException('unknown status');

ApplicationStatus::cases();  // all cases, declaration order
echo json_encode(['status' => ApplicationStatus::Applied]);
// {"status":"applied"}  backed enums serialise to their value

Key Points

  • Pure enums have no value; backed enums carry a string or int
  • Cases are singletons, so === identity comparison is safe
  • from() throws ValueError, tryFrom() returns null
  • match() over cases throws UnhandledMatchError when one is missed
  • Backed enums json_encode to their scalar value automatically
💡 Pro Tip: Never use tryFrom() with a silent fallback to a default case. That turns a bad webhook payload into a wrong database row, which is far harder to debug than a 422.
Q11

How is match() different from switch(), and when does it bite?

BasicControl Flow

Answer

match() is an expression introduced in PHP 8.0, switch is a statement, and that difference drives everything else. match returns a value, so you can assign it directly, whereas switch executes statements and needs a variable plus break in each arm. match uses strict identity comparison (===), so match(1) will not fall into a '1' arm, while switch uses loose comparison and famously matches 0 against 'foo' on old PHP and still matches '1' against 1 today. match has no fallthrough, so a missing break can never leak into the next arm, which removes an entire category of bug. Most usefully, match is exhaustive: if no arm matches and there is no default, PHP throws UnhandledMatchError instead of silently doing nothing. That exhaustiveness is why match plus enums is the standard 2026 pattern; add a new enum case and every match that forgot it throws at runtime rather than quietly returning null.

Where match bites: each arm must be a single expression, so you cannot run three statements in an arm without extracting a function or calling a closure, and a match arm cannot contain a return statement of its own. Also, match arms can list multiple comma-separated conditions, and match(true) with boolean conditions in the arms is the idiomatic replacement for a long if/elseif chain, though overusing it hurts readability. Interviewers often ask whether you should always prefer match. The honest answer is yes for value selection and enum dispatch, and switch only when you genuinely need multiple statements per branch or intentional fallthrough.

<?php
// switch: statement, loose ==, needs break, silently does nothing on no match
switch ($httpCode) {
    case 200:
    case 201:
        $result = 'ok';
        break;
    default:
        $result = 'error';
}

// match: expression, strict ===, no fallthrough, throws when unmatched
$result = match ($httpCode) {
    200, 201, 204 => 'ok',
    429           => 'rate_limited',
    500, 502, 503 => 'retryable',
    default       => 'error',
};

// Strict comparison is the point
var_dump(match ('1') { 1 => 'int', '1' => 'string' }); // 'string'
// switch ('1') { case 1: } would have matched the int arm

// No default and no match: UnhandledMatchError, not a silent null
try {
    match (99) { 1 => 'a' };
} catch (\UnhandledMatchError $e) {
    echo $e->getMessage(); // Unhandled match case 99
}

// match(true) replaces long if/elseif chains
$band = match (true) {
    $lpa < 6  => 'junior',
    $lpa < 15 => 'mid',
    default   => 'senior',
};

Key Points

  • match is an expression that returns a value; switch is a statement
  • match uses ===; switch uses ==
  • No fallthrough and no break needed in match
  • UnhandledMatchError instead of silently skipping
  • One expression per arm; use match(true) for conditional chains
Q12

What do constructor property promotion and readonly give you, and where does readonly still fail?

BasicOOP

Answer

Constructor property promotion, from PHP 8.0, lets you declare and assign a property in the constructor signature by adding a visibility modifier to the parameter. A value object that used to need a property declaration, a constructor parameter and an assignment line for each field collapses to one line each. readonly, from PHP 8.1, marks a typed property that can be initialised exactly once from inside the declaring class scope and never written again; a second write throws Error: Cannot modify readonly property. Combined, they give you genuine immutable value objects with almost no ceremony, and PHP 8.2 added readonly classes so you can mark the class once instead of every property.

The failure modes are the interesting part. readonly is shallow: a readonly property holding an array cannot be reassigned, but a readonly property holding an object can still have that object's own properties mutated, so immutability does not propagate. readonly properties must have a type; you cannot mark an untyped or static property readonly. Cloning was the big pain point, because clone copies the property and the copy counted as already initialised, so wither methods (withStatus(), withAmount()) were impossible on readonly classes until PHP 8.3 allowed reinitialising a readonly property from inside __clone(). Serialisation is another sharp edge: readonly properties cannot be set by var_export style hydration, so ORMs and serializers need reflection or the newer lazy-object hooks. Finally, readonly does not make a property private; a public readonly property is still world-visible, which is exactly what PHP 8.4 asymmetric visibility (public private(set)) was designed to express more precisely.

<?php
declare(strict_types=1);

// PHP 8.2: whole class readonly
final readonly class Money
{
    public function __construct(
        public int $paise,
        public string $currency = 'INR',
    ) {}

    // PHP 8.3: readonly props may be reinitialised inside __clone
    public function withPaise(int $paise): self
    {
        $copy = clone $this;
        // legal from 8.3 onward, Error on 8.1/8.2
        return new self($paise, $this->currency);
    }
}

$m = new Money(paise: 250000);   // named argument
$m->paise = 1;                   // Error: Cannot modify readonly property

// readonly is SHALLOW
final class Cart
{
    public function __construct(public readonly \ArrayObject $items) {}
}
$c = new Cart(new \ArrayObject());
$c->items[] = 'job-1';   // allowed: the object itself is mutable
$c->items = new \ArrayObject(); // Error: cannot reassign

// PHP 8.4: public read, private write
final class Counter
{
    public private(set) int $hits = 0;
    public function hit(): void { $this->hits++; }
}

Key Points

  • Promotion collapses declare, parameter and assignment into one line
  • readonly allows exactly one write from inside the declaring class
  • readonly is shallow: objects held in the property stay mutable
  • PHP 8.3 allows reinitialising readonly properties inside __clone
  • PHP 8.4 private(set) expresses public-read, private-write directly
Q13

Explain PHP's Throwable hierarchy: why can you not catch a TypeError with catch (Exception $e)?

BasicError Handling

Answer

Since PHP 7 the root of the hierarchy is the Throwable interface, and two branches implement it: Exception and Error. Exception is for conditions your application raises and is expected to handle, with the familiar subclasses RuntimeException, LogicException, InvalidArgumentException, JsonException and PDOException. Error is for engine-level failures that used to be fatal errors before PHP 7: TypeError when a type declaration is violated, ValueError when an argument has the right type but an impossible value (added in 8.0), ArithmeticError and DivisionByZeroError, ArgumentCountError, UnhandledMatchError, and plain Error for things like calling a method on null.

Because Error does not extend Exception, catch (Exception $e) will not catch a TypeError, and that surprises people who assume a global try/catch in their front controller covers everything. If you want a genuine catch-all, catch (\Throwable $e). The practical guidance for production code is to catch Throwable only at the outermost boundary, log it with the full stack trace, and return a generic 500, while catching narrow types deeper in the stack where you can actually recover.

The other half of the answer is the non-exception path: warnings and notices still exist and do not throw, so a failing file_get_contents() returns false and emits a warning that your try/catch never sees. The standard fix is set_error_handler() converting E_WARNING and above into ErrorException, plus set_exception_handler() and register_shutdown_function() with error_get_last() to catch fatals such as memory exhaustion. finally always runs, including when the try block returns, which is why it is the right place for releasing locks and closing handles.

<?php
declare(strict_types=1);

function paise(int $rupees): int { return $rupees * 100; }

try {
    paise('abc');
} catch (\Exception $e) {
    echo 'never reached';        // TypeError does NOT extend Exception
} catch (\TypeError $e) {
    echo $e->getMessage();
}

// Catch-all only at the boundary
try {
    $app->handle($request);
} catch (\Throwable $e) {
    $logger->error($e->getMessage(), ['exception' => $e]);
    http_response_code(500);
}

// Warnings never throw. Promote them.
set_error_handler(static function (int $no, string $str, string $file, int $line): bool {
    if (!(error_reporting() & $no)) { return false; }
    throw new \ErrorException($str, 0, $no, $file, $line);
});

// Fatals (OOM, timeout) bypass everything above
register_shutdown_function(static function (): void {
    $e = error_get_last();
    if ($e !== null && ($e['type'] & (E_ERROR | E_PARSE))) {
        error_log('FATAL: ' . $e['message'] . ' at ' . $e['file'] . ':' . $e['line']);
    }
});

Key Points

  • Throwable is the root; Exception and Error are sibling branches
  • TypeError, ValueError, ArgumentCountError all extend Error
  • catch (\Throwable) is the only true catch-all
  • Warnings do not throw; promote them with set_error_handler
  • register_shutdown_function plus error_get_last catches fatals
Q14

Which parts of $_SERVER are attacker controlled, and how do you read a client IP safely?

BasicSecurity

Answer

$_GET, $_POST and $_COOKIE are entirely attacker controlled and everyone knows it. The trap is $_SERVER, which mixes values the web server sets from the real connection with values derived straight from request headers. Anything named HTTP_* is a request header renamed by PHP, so HTTP_USER_AGENT, HTTP_REFERER, HTTP_X_FORWARDED_FOR and HTTP_HOST are all forgeable. $_SERVER['HTTP_HOST'] in particular comes from the Host header, which is why host-header poisoning turns a naive password-reset link built from HTTP_HOST into a credential leak; use a configured canonical domain from your environment instead.

REQUEST_URI, QUERY_STRING and PATH_INFO are also request-derived. The values you can trust are the ones the server computes: REMOTE_ADDR is the peer address of the TCP connection and cannot be spoofed without controlling the route, SERVER_ADDR, SERVER_PORT and HTTPS come from your server configuration, and SCRIPT_FILENAME comes from the filesystem. Reading a client IP behind a load balancer is the classic follow-up.

X-Forwarded-For is a comma-separated chain that any client can prepend to, so taking the first entry lets anyone forge their IP and defeat your rate limiter. The correct approach is to trust the header only when REMOTE_ADDR is one of your known proxy or CDN ranges, then walk the chain from the right and take the first address that is not itself a trusted proxy. If you sit behind Cloudflare, prefer CF-Connecting-IP and restrict ingress to Cloudflare's published ranges. Also validate what you extract with filter_var($ip, FILTER_VALIDATE_IP) before it reaches a database or a log line.

<?php
declare(strict_types=1);

// Forgeable: every HTTP_* key is just a request header
$_SERVER['HTTP_HOST'];             // host-header poisoning
$_SERVER['HTTP_X_FORWARDED_FOR'];  // freely spoofable
$_SERVER['HTTP_REFERER'];          // never a security control

// Trustworthy: set by the server from the real connection
$_SERVER['REMOTE_ADDR'];
$_SERVER['SERVER_PORT'];

function clientIp(array $trustedProxies): string
{
    $remote = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';

    // Only believe XFF if the peer is one of our proxies
    if (!in_array($remote, $trustedProxies, true)) {
        return $remote;
    }

    $chain = array_map('trim', explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'] ?? ''));
    // Walk right to left, skipping our own hops
    foreach (array_reverse($chain) as $candidate) {
        if (in_array($candidate, $trustedProxies, true)) { continue; }
        if (filter_var($candidate, FILTER_VALIDATE_IP) !== false) { return $candidate; }
    }

    return $remote;
}

// Never build absolute URLs from HTTP_HOST
$resetUrl = rtrim(getenv('APP_URL') ?: '', '/') . '/reset?token=' . $token;

Key Points

  • Every $_SERVER key beginning with HTTP_ is a forgeable request header
  • REMOTE_ADDR is the real TCP peer and cannot be spoofed remotely
  • Trust X-Forwarded-For only when REMOTE_ADDR is a known proxy
  • Walk the XFF chain from the right, skipping your own hops
  • Build absolute URLs from configured APP_URL, never from HTTP_HOST
💡 Pro Tip: Rate limiters keyed on the first X-Forwarded-For entry are trivially bypassed. This is a favourite follow-up in security-minded PHP interviews.
Q15

How do PHP sessions actually work, and what stops session fixation?

BasicSessions

Answer

session_start() looks for a session identifier in the PHPSESSID cookie (or the URL if session.use_trans_sid is on, which it should not be), loads the matching record from the configured save handler into $_SESSION, and locks it. With the default files handler, that lock is an exclusive flock on the session file, which means a second concurrent request from the same user blocks until the first finishes. That is the single most common cause of a page that feels slow only when several AJAX calls fire together; the fix is session_write_close() as soon as you have finished writing session data, which releases the lock and lets the other requests through.

Session fixation is the attack where an attacker plants a known session id on a victim (via a link with the id, or an injected cookie), waits for the victim to log in, and then reuses the same id, which is now authenticated. The defence is one line: call session_regenerate_id(true) immediately after a successful authentication and after any privilege change, which issues a fresh id and deletes the old file. Beyond that, the cookie flags matter more than most candidates realise: session.cookie_httponly=1 keeps JavaScript away from the id, session.cookie_secure=1 restricts it to HTTPS, session.cookie_samesite=Lax or Strict blunts CSRF, and session.use_strict_mode=1 makes PHP reject a client-supplied id that it never issued, which by itself blocks the simplest fixation vector. For multi-server deployments the files handler does not work behind a load balancer without sticky sessions, so production PHP in India almost always uses the Redis save handler with session.save_path pointed at the Redis instance.

<?php
// php.ini or ini_set before session_start()
ini_set('session.use_strict_mode', '1'); // reject unknown client ids
ini_set('session.cookie_httponly', '1');
ini_set('session.cookie_secure', '1');
ini_set('session.cookie_samesite', 'Lax');
ini_set('session.gc_maxlifetime', '7200');

// Shared storage behind a load balancer
ini_set('session.save_handler', 'redis');
ini_set('session.save_path', 'tcp://127.0.0.1:6379?database=2');

session_start();

// After a successful login: new id, delete the old record
if (password_verify($plain, $row['password_hash'])) {
    session_regenerate_id(true);
    $_SESSION['user_id']  = $row['id'];
    $_SESSION['logged_at'] = time();
}

// Release the lock as soon as you stop writing, or concurrent
// AJAX requests from the same browser will serialise behind it
session_write_close();

// Logout: clear array, kill cookie, destroy storage
$_SESSION = [];
setcookie(session_name(), '', ['expires' => time() - 42000, 'path' => '/']);
session_destroy();

Key Points

  • session_start() takes an exclusive lock until the script ends
  • session_write_close() unblocks concurrent requests from the same user
  • session_regenerate_id(true) after login prevents fixation
  • use_strict_mode=1 rejects session ids PHP never issued
  • Use the Redis save handler behind a load balancer
Q16

Why is strpos() a bug magnet, and what did PHP 8 add to replace it?

BasicStrings

Answer

strpos() returns the integer offset of the needle, or false when it is absent. If the needle sits at the very start, the offset is 0, and 0 is falsy in PHP, so if (strpos($haystack, $needle)) is false both when the needle is missing and when it is at position zero. The correct form has always been strpos($haystack, $needle) !== false, and that trailing !== false is forgotten constantly.

PHP 8.0 added three functions that return real booleans and end the argument: str_contains(), str_starts_with() and str_ends_with(). They read better, they cannot be misused this way, and they are what an interviewer expects in 2026 code. The related trap is argument order.

Most PHP string functions take haystack first, needle second (strpos, str_contains, substr_count), but the array functions in_array() and array_search() take needle first, and preg_match() takes the pattern first. That inconsistency is a leftover from PHP's C-library origins and remains a favourite whiteboard slip. Two more things worth mentioning: substr() with a negative start counts from the end and never throws, so a wrong offset silently produces the wrong string rather than an error, and explode() with a limit of -1 drops trailing components, which is occasionally exactly what you want when parsing paths. Finally, for anything involving user-visible text you should be using the mb_* family, because strlen() counts bytes and will report 6 for a three-character Devanagari string, which breaks length validation on Indian-language input.

<?php
$url = 'https://goodspace.ai/jobs';

// The bug: position 0 is falsy
if (strpos($url, 'https')) {
    echo 'never runs';           // strpos returns 0
}
if (strpos($url, 'https') !== false) { echo 'correct but noisy'; }

// PHP 8.0: real booleans
var_dump(str_starts_with($url, 'https://')); // true
var_dump(str_contains($url, '/jobs'));       // true
var_dump(str_ends_with($url, '.ai'));        // false

// Argument order is not consistent across the stdlib
str_contains($haystack, $needle);  // haystack first
in_array($needle, $haystack, true); // needle first
preg_match('/^\\d+$/', $subject);   // pattern first

// Bytes versus characters
$name = 'साक्षम';
var_dump(strlen($name));     // byte count, large
var_dump(mb_strlen($name));  // character count
var_dump(mb_substr($name, 0, 3));

Key Points

  • strpos returns 0 for a match at the start, and 0 is falsy
  • PHP 8.0 added str_contains, str_starts_with, str_ends_with
  • Haystack-first for string functions, needle-first for in_array
  • strlen counts bytes; use mb_strlen for user-visible text
  • substr with a bad offset fails silently rather than throwing
Q17

What is late static binding, and how do self::, static:: and parent:: differ?

BasicOOP

Answer

self:: resolves at compile time to the class in which the code is literally written. parent:: resolves to that class's parent. static:: resolves at runtime to the class that was actually called, which is what late static binding means: the engine keeps track of the 'called class' through the call chain and static:: uses it. The classic demonstration is a static factory on a base class. If the base writes return new self(), every subclass factory returns a base instance, because self was frozen at compile time.

If it writes return new static(), each subclass returns an instance of itself, which is exactly what you want for an Active Record create() or a fluent query builder. get_called_class() is the function form, and since PHP 8.0 static is also usable as a return type, so you can declare public static function make(): static and have static analysers understand the covariance. Where this catches people: self:: inside a trait resolves to the class using the trait, not the trait itself, which is generally what you want but surprises people; and private methods are not subject to late static binding in the way public ones are, because a private method call resolves to the defining class's implementation even when invoked through static::. In a Laravel or Symfony codebase this shows up constantly, since new static() is how model hydration and fluent builders return the concrete subclass. Interviewers use this to test whether you understand PHP's two-phase resolution, and a good answer names the runtime versus compile-time distinction rather than reciting the syntax.

<?php
declare(strict_types=1);

abstract class Model
{
    public static function makeSelf(): self
    {
        return new self();   // frozen at compile time to Model
    }

    public static function make(): static
    {
        return new static(); // late static binding: the CALLED class
    }

    public function name(): string
    {
        return static::class; // runtime class name
    }
}

final class JobPost extends Model {}

// var_dump(Model::makeSelf());   // Error: cannot instantiate abstract Model
var_dump(JobPost::make() instanceof JobPost); // true
echo (new JobPost())->name();                 // JobPost

// parent:: chains to the immediate parent implementation
class Base   { public function label(): string { return 'base'; } }
class Child extends Base
{
    public function label(): string
    {
        return parent::label() . ':child';
    }
}
echo (new Child())->label(); // base:child

Key Points

  • self:: is compile time, static:: is runtime (the called class)
  • new static() is how base-class factories return the subclass
  • static is a valid return type since PHP 8.0
  • get_called_class() is the procedural equivalent
  • self:: inside a trait resolves to the using class
Q18

How do closures capture variables in PHP, and what changed with arrow functions and first-class callable syntax?

BasicClosures

Answer

PHP closures do not capture the surrounding scope automatically. A classic anonymous function sees only its own parameters unless you list variables in a use() clause, and use() captures by value at the moment the closure is created, not when it is called. That means changing the outer variable afterwards has no effect inside the closure unless you wrote use (&$x) to capture by reference.

Arrow functions, added in PHP 7.4, flip this: fn($x) => $x * $factor captures every outer variable it references automatically, always by value, and the body must be a single expression. Arrow functions cannot capture by reference and cannot contain statements, which keeps them honest for small callbacks in array_map and usort. Both forms carry the $this binding of the enclosing class by default, so a closure defined inside a method can call $this->something(); prefix it with static to prevent that binding, which matters because a closure holding $this keeps the whole object alive and can cause surprising memory retention in long-running workers.

Closure::bind() and bindTo() rebind $this and the scope, which is how testing helpers reach private members and how some DI containers build lazy proxies. PHP 8.1 added first-class callable syntax: strlen(...) and $service->handle(...) create a Closure from a function or method without the old 'string function name' or [$obj, 'method'] array form, so static analysers and IDEs can actually check the reference. In 2026 code, prefer the (...) form over string callables everywhere, since a typo in a string callable is only discovered at runtime.

<?php
declare(strict_types=1);

$multiplier = 3;

// use() captures BY VALUE at creation time
$byValue = function (int $n) use ($multiplier): int { return $n * $multiplier; };
$byRef   = function (int $n) use (&$multiplier): int { return $n * $multiplier; };

$multiplier = 10;
var_dump($byValue(2)); // 6   captured 3
var_dump($byRef(2));   // 20  reads the current value

// Arrow fn: auto-capture by value, single expression only
$arrow = fn (int $n): int => $n * $multiplier; // sees $multiplier = 10

// static closures do not bind $this and do not retain the object
$safe = static fn (int $n): int => $n + 1;

// PHP 8.1 first-class callable syntax
$lengths = array_map(strlen(...), ['pune', 'noida']);
$handler = $service->handle(...);      // Closure, IDE-checkable
$factory = JobPost::make(...);         // static method

// Rebinding scope, used by test helpers and lazy proxies
class Secretive { private string $token = 'abc'; }
$peek = Closure::bind(
    function () { return $this->token; },
    new Secretive(),
    Secretive::class
);
echo $peek(); // abc

Key Points

  • use() captures by value at creation; use (&$x) captures by reference
  • Arrow functions auto-capture by value and allow one expression
  • Closures bind $this unless declared static
  • A non-static closure holding $this keeps the object alive
  • PHP 8.1 (...) syntax makes callables analysable instead of strings
Q19

Walk through what happens between nginx receiving a request and PHP-FPM returning a response.

IntermediateRuntime Model

Answer

nginx matches the location block, sees a .php target, and proxies over FastCGI to the PHP-FPM pool socket with SCRIPT_FILENAME and the request environment. The FPM master hands the connection to an idle worker process. That worker starts a request: it creates a fresh executor globals state, populates the superglobals, and includes the entry script.

If OPcache has the compiled opcodes for that file cached and validation passes, no parsing or compilation happens at all; otherwise PHP lexes, parses and compiles the file into opcodes and stores them in shared memory. The Zend VM then executes the opcodes. Your code runs, output goes into the output buffer, and when the script ends PHP runs shutdown functions and destructors, flushes output back over FastCGI, and then destroys the entire request memory arena in one shot.

That last step is the shared-nothing model: no variable, no static property and no open connection survives into the next request in classic FPM mode, which is why PHP has no concept of a memory leak across requests and why you can restart safely at any point. The consequences are what interviews probe. There is no in-process cache between requests, so you need APCu, Redis or opcache for anything you want to persist.

Every request opens its own database connection unless you use persistent connections or an external pooler. Concurrency is bounded by pm.max_children, not by threads: if all workers are busy, nginx queues and eventually returns 502 or 504. And a slow upstream API blocks a whole worker for its entire duration, which is why request timeouts on outbound HTTP calls are a capacity concern, not just a UX one.

; /etc/php/8.4/fpm/pool.d/www.conf
[www]
listen = /run/php/php8.4-fpm.sock
pm = dynamic
pm.max_children = 40          ; hard concurrency ceiling for this pool
pm.start_servers = 8
pm.min_spare_servers = 6
pm.max_spare_servers = 12
pm.max_requests = 500         ; recycle workers to bound leaks in extensions
request_terminate_timeout = 30s
slowlog = /var/log/php-fpm-slow.log
request_slowlog_timeout = 5s  ; dumps a PHP backtrace for slow requests
catch_workers_output = yes

; Live status endpoint, then scrape listen queue and active processes
pm.status_path = /fpm-status

# nginx
# location ~ \.php$ {
#     fastcgi_pass unix:/run/php/php8.4-fpm.sock;
#     fastcgi_read_timeout 30s;   # must exceed request_terminate_timeout
#     include fastcgi_params;
# }

Key Points

  • One request per worker process, memory destroyed at the end
  • OPcache skips parse and compile when the opcodes are already cached
  • pm.max_children is the real concurrency ceiling, not threads
  • A slow outbound HTTP call occupies a worker for its whole duration
  • request_slowlog_timeout dumps PHP backtraces for slow requests
Q20

Which OPcache settings actually matter in production, and what does validate_timestamps=0 break?

IntermediatePerformance

Answer

OPcache stores compiled opcodes in shared memory so the parse and compile phase is skipped on every request after the first. The settings that matter are opcache.memory_consumption (start at 192 or 256 MB for a large framework app, and watch used_memory in opcache_get_status), opcache.max_accelerated_files (must exceed your real file count, since the default is far too small for a Composer project with thousands of classes and the value is rounded up to a prime), opcache.interned_strings_buffer (16 MB or more for framework-heavy apps), and opcache.validate_timestamps. With validate_timestamps=1, PHP stats each file every revalidate_freq seconds to see whether it changed on disk, which costs a syscall per file per interval but means a deploy takes effect on its own.

With validate_timestamps=0 you remove those stat calls entirely and get the best throughput, but PHP will never notice new code: after a deploy you are still running the previous release until you restart or reload PHP-FPM, or call opcache_reset(). The dangerous middle ground is deploying with rsync into a live directory while validate_timestamps=1, because files change one at a time and requests can execute a half-old, half-new codebase for a few seconds; the standard fix is atomic symlink swapping plus a graceful FPM reload. Two more things to raise: opcache_get_status() gives you cache_full, oom_restarts and hits versus misses, and any of those being non-zero in production is an incident waiting to happen; and OPcache caches opcodes, not results, so it does nothing for a slow query or a slow API call. If you need cached data rather than cached code, that is APCu or Redis.

; php.ini for production
opcache.enable=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=32
opcache.max_accelerated_files=30000   ; rounded up to a prime internally
opcache.validate_timestamps=0         ; requires a reload on every deploy
opcache.revalidate_freq=0
opcache.save_comments=1               ; attributes and annotations need these
opcache.enable_file_override=1
opcache.jit=tracing
opcache.jit_buffer_size=64M

<?php
// Health check: any of these being non-zero is a warning sign
$s = opcache_get_status(false);
var_dump(
    $s['opcache_statistics']['opcache_hit_rate'], // want > 99
    $s['memory_usage']['free_memory'],
    $s['opcache_statistics']['oom_restarts'],     // want 0
    $s['opcache_statistics']['num_cached_scripts'] // vs max_accelerated_files
);

// Deploy flow with validate_timestamps=0:
//   1. build new release directory
//   2. ln -sfn releases/new current
//   3. systemctl reload php8.4-fpm   (graceful, drains workers)

Key Points

  • max_accelerated_files must exceed the real file count of your app
  • validate_timestamps=0 is fastest but needs a reload on every deploy
  • save_comments=1 is required for attributes and doc-block metadata
  • Watch oom_restarts and hit rate via opcache_get_status()
  • OPcache caches code, not data: use APCu or Redis for results
💡 Pro Tip: If you rsync into a live document root with validate_timestamps=1, requests during the copy can mix old and new files. Swap an atomic symlink and reload FPM instead.
Q21

How does PHP manage memory, and why does the cycle collector exist alongside refcounting?

IntermediateMemory

Answer

PHP uses reference counting as its primary mechanism. Every value lives in a zval, and refcounted types (strings, arrays, objects, resources) carry a refcount. When a variable is assigned, the count goes up; when it goes out of scope or is unset, the count goes down, and at zero the memory is freed immediately.

This is deterministic and cheap, which is why destructors in PHP usually fire the moment the last reference disappears rather than at some unpredictable later point. Reference counting cannot free cycles: if object A holds B and B holds A, both counts stay at one even after your code drops every external reference, and the memory leaks for the rest of the request. That is what the cycle collector handles.

PHP keeps a root buffer of possible cycle roots, and when it fills (10,000 entries by default) it runs a mark-and-sweep pass over just those candidates. You can trigger it manually with gc_collect_cycles(), inspect it with gc_status(), and disable it with gc_disable(). For a normal web request none of this matters much, because the whole arena is thrown away at the end anyway. It matters enormously for long-running processes: queue workers, Swoole or RoadRunner servers, and CLI import scripts, where a parent-child object graph such as an ORM entity holding its relations and the relations holding the entity back will grow memory until the worker hits memory_limit and dies mid-job. memory_limit is per-process, not per-server, so pm.max_children multiplied by memory_limit is the worst-case memory footprint of a pool, and getting that arithmetic wrong is how PHP servers get OOM-killed. memory_get_peak_usage(true) reports what was actually requested from the OS.

<?php
declare(strict_types=1);

class Node
{
    public ?Node $parent = null;
    /** @var Node[] */
    public array $children = [];
    public string $payload;

    public function __construct(string $payload) { $this->payload = str_repeat($payload, 10000); }
    public function __destruct() { /* fires when refcount hits 0 */ }
}

gc_disable();
$before = memory_get_usage(true);

for ($i = 0; $i < 2000; $i++) {
    $parent = new Node('a');
    $child  = new Node('b');
    $parent->children[] = $child;
    $child->parent = $parent;   // cycle: neither refcount ever reaches 0
    unset($parent, $child);
}

printf("leaked: %.1f MB\n", (memory_get_usage(true) - $before) / 1048576);
var_dump(gc_status()['roots']);
gc_collect_cycles();            // reclaims the cycles
printf("peak: %.1f MB\n", memory_get_peak_usage(true) / 1048576);

// Capacity maths for a pool:
//   pm.max_children * memory_limit = worst-case RSS for the pool

Key Points

  • Refcounting frees memory deterministically at zero references
  • Cycles need the mark-and-sweep collector; the root buffer holds 10,000
  • gc_collect_cycles(), gc_status() and gc_disable() give you control
  • Cycles only really hurt long-running workers, not normal requests
  • memory_limit is per process: multiply by pm.max_children for the pool
Q22

How do generators let you process a 2 GB CSV without hitting memory_limit?

IntermediateGenerators

Answer

A generator is a function containing yield. Calling it does not run the body; it returns a Generator object implementing Iterator. Each time the consumer asks for the next value, the function body resumes from where it paused, produces one value, and suspends again.

Only one row is ever in memory, so the peak footprint of a streaming pipeline is a single record plus the file buffer, no matter how large the source is. That is the difference between file() or fetchAll(), which materialise the entire dataset into a PHP array, and fgetcsv() inside a generator, which does not. In a real import job you chain generators: one yields raw lines, one parses and validates, one batches into chunks of 500 for a multi-row INSERT.

Because each stage is lazy, the whole pipeline stays constant in memory and you can compose it like a Unix pipe. yield from delegates to another generator or array and preserves keys, which is how you flatten nested sources. Generators also communicate two ways: send() pushes a value into the paused generator (the basis of coroutine libraries), getReturn() retrieves a return value after the generator finishes, and throw() injects an exception at the suspension point. The gotchas are worth naming.

Generators are forward-only and single-use: you cannot count() them, you cannot rewind after starting, and iterator_to_array() defeats the entire purpose by loading everything back into memory. Keys are not automatically unique, so iterator_to_array($gen) without the second argument set to false will silently overwrite entries when keys repeat. And do not forget PDO: pass the query as an unbuffered statement or fetch row by row, because fetchAll() on a million-row result set puts the whole thing in PHP memory before your generator ever sees it.

<?php
declare(strict_types=1);

function readCsv(string $path): \Generator
{
    $fh = new \SplFileObject($path, 'r');
    $fh->setFlags(\SplFileObject::READ_CSV | \SplFileObject::SKIP_EMPTY);
    $header = null;
    foreach ($fh as $row) {
        if ($row === [null] || $row === false) { continue; }
        if ($header === null) { $header = $row; continue; }
        yield array_combine($header, $row);
    }
    return 'done';
}

function validated(iterable $rows): \Generator
{
    foreach ($rows as $i => $row) {
        if (!filter_var($row['email'] ?? '', FILTER_VALIDATE_EMAIL)) { continue; }
        yield $row;
    }
}

function batched(iterable $rows, int $size): \Generator
{
    $buf = [];
    foreach ($rows as $row) {
        $buf[] = $row;
        if (count($buf) === $size) { yield $buf; $buf = []; }
    }
    if ($buf !== []) { yield $buf; }
}

foreach (batched(validated(readCsv('/data/candidates.csv')), 500) as $chunk) {
    $repo->insertMany($chunk);   // peak memory stays flat
}

Key Points

  • Generators hold one item in memory instead of the whole dataset
  • Chain them like Unix pipes: read, validate, batch
  • yield from delegates and preserves keys
  • send(), getReturn() and throw() enable two-way communication
  • iterator_to_array() undoes the benefit; generators are single-use
💡 Pro Tip: Streaming from MySQL needs PDO::MYSQL_ATTR_USE_BUFFERED_QUERY set to false, otherwise the client library buffers the entire result set before your generator yields anything.
Q23

When do you reach for a trait over an interface or abstract class, and how do you resolve trait conflicts?

IntermediateOOP

Answer

An interface is a contract with no implementation, and a class can implement many. An abstract class is a partial implementation with state, and PHP allows only one parent. A trait is horizontal code reuse: a block of methods, properties and abstract method declarations that is copied into the using class at compile time, as if you had typed it there.

Because it is a copy, not an inheritance relationship, a trait creates no type. You cannot type-hint against a trait, which is why the correct pattern is almost always an interface for the contract plus a trait for the default implementation, exactly what Symfony and PSR packages do with things like LoggerAwareInterface and LoggerAwareTrait. Precedence is a favourite question: methods from the class itself win over trait methods, and trait methods win over inherited parent methods.

When two traits provide the same method name, PHP raises a fatal error at compile time unless you resolve it explicitly with insteadof to pick a winner and as to alias the loser under a different name, optionally changing its visibility at the same time. Traits can declare abstract methods to force the using class to supply something, they can hold static properties (each using class gets its own copy, which surprises people), and since PHP 8.2 they can declare constants. The main criticism to voice in an interview is that traits are invisible dependencies: a trait that calls $this->repository assumes the using class has that property, and nothing in the type system enforces it. Keep traits small, have them declare their requirements as abstract methods, and never use a trait to share state across unrelated classes.

<?php
declare(strict_types=1);

trait Timestamps
{
    private ?\DateTimeImmutable $updatedAt = null;

    abstract protected function clock(): \DateTimeImmutable; // stated requirement

    public function touch(): void { $this->updatedAt = $this->clock(); }
    public function describe(): string { return 'timestamped'; }
}

trait SoftDeletes
{
    public function describe(): string { return 'soft-deletable'; }
    public function delete(): void { /* ... */ }
}

final class JobPost
{
    use Timestamps, SoftDeletes {
        Timestamps::describe insteadof SoftDeletes;      // pick the winner
        SoftDeletes::describe as protected describeSoft; // alias + change visibility
    }

    protected function clock(): \DateTimeImmutable
    {
        return new \DateTimeImmutable('now', new \DateTimeZone('Asia/Kolkata'));
    }
}

// Precedence: class > trait > inherited parent
// A trait creates no type:
//   function f(Timestamps $t) {}  // Error: Timestamps is not a type

Key Points

  • Traits are compile-time copy-paste and create no type
  • Interface for the contract, trait for the shared implementation
  • Class method beats trait method beats inherited parent method
  • insteadof picks a winner; as aliases and can change visibility
  • Declare abstract methods in the trait to make requirements explicit
Q24

What do __get, __call and __invoke actually cost, and when is a magic method the wrong answer?

IntermediateMagic Methods

Answer

Magic methods are engine hooks. __get and __set fire only when a property is inaccessible or undefined, __isset and __unset back isset() and unset() on those properties, __call handles undefined instance methods and __callStatic the static equivalent, __invoke makes an object callable, __toString gives it a string form, and __clone runs after a shallow copy. They are how Laravel's Eloquent exposes database columns as properties and how mocking libraries fake arbitrary methods. The costs are real.

A normal property read is a hash lookup in the object's property table; a __get read is a full method call with argument handling, several times slower, and it happens on every access because nothing is cached unless you cache it yourself. More importantly, magic properties are invisible to everything: your IDE cannot autocomplete them, PHPStan and Psalm cannot check them without hand-written @property annotations, refactoring tools cannot rename them, and a typo becomes a runtime null instead of a compile-time error. __call has the same problem with a worse failure mode, since a mistyped method name silently reaches your fallback handler. The rule I would state in an interview: magic methods are appropriate when the property or method set genuinely is not knowable at author time, such as an ORM row, a config bag, or a proxy.

They are the wrong answer whenever the fields are known, in which case typed properties or a readonly value object give you speed and static checking for free. Note also that PHP 8.2 deprecated dynamic properties, so writing to an undeclared property on a normal class now emits a deprecation and will become an error; classes that legitimately need them must declare the AllowDynamicProperties attribute or implement __get and __set.

<?php
declare(strict_types=1);

/**
 * Annotations are the only way static analysis sees magic properties.
 * @property-read string $title
 * @method   static self findOrFail(int $id)
 */
final class Row implements \Stringable
{
    public function __construct(private array $attributes = []) {}

    public function __get(string $name): mixed
    {
        return $this->attributes[$name]
            ?? throw new \OutOfBoundsException("No attribute {$name}");
    }

    public function __isset(string $name): bool   // isset() ignores __get
    {
        return isset($this->attributes[$name]);
    }

    public function __call(string $m, array $args): mixed
    {
        if (str_starts_with($m, 'get')) {
            return $this->__get(lcfirst(substr($m, 3)));
        }
        throw new \BadMethodCallException($m);
    }

    public function __toString(): string { return json_encode($this->attributes, JSON_THROW_ON_ERROR); }
}

// PHP 8.2: writing an undeclared property is deprecated
#[\AllowDynamicProperties]
class LegacyBag {}

Key Points

  • __get fires only for inaccessible or undefined properties
  • isset() on a magic property needs __isset as well as __get
  • Several times slower than a real property, and never cached
  • Invisible to IDEs and PHPStan without @property annotations
  • PHP 8.2 deprecated dynamic properties; use AllowDynamicProperties or declare them
Q25

How does a PSR-11 container autowire dependencies, and what changes when the container is compiled?

IntermediateDependency Injection

Answer

PSR-11 itself is tiny: a ContainerInterface with get() and has(), plus NotFoundExceptionInterface and ContainerExceptionInterface. Everything interesting lives in the implementation. Autowiring works through Reflection: the container inspects the constructor signature of the class you asked for, reads the type declaration on each parameter, and resolves each one recursively.

Anything that is not a resolvable class type (a scalar such as an API key, a union type, or an interface with several implementations) cannot be inferred and needs an explicit binding, an alias, or a Symfony #[Autowire] attribute. The real difference between the two containers Indian teams use most is when that reflection happens. Symfony compiles the whole graph at build time into a single generated PHP class full of straight-line factory methods, so there is no reflection at runtime and an unresolvable argument fails during cache warmup instead of as a 500 at 2am.

Laravel resolves lazily on every request and caches nothing about the graph, which is friendlier to change but costs measurable time on a fat request. Production questions cluster around lifetimes. A singleton that caches request-scoped state (the current user, a tenant id) is harmless under PHP-FPM because the process forgets everything at the end of the request, and a serious bug under Octane, RoadRunner or Swoole where the container survives across requests.

Injecting the container itself and calling get() inside business code is service location: it hides dependencies, defeats static analysis, and forces tests to boot the full kernel. Circular dependencies throw a dedicated exception rather than recursing forever. When a dependency is expensive but rarely used, the answer is a lazy proxy: Symfony's lazy: true, or PHP 8.4 native lazy objects through ReflectionClass::newLazyProxy().

# config/services.yaml
services:
  _defaults:
    autowire: true        # resolve constructor args by type declaration
    autoconfigure: true   # tag services by the interfaces they implement
    public: false         # force injection, forbid $container->get()

  App\:
    resource: '../src/*'
    exclude: '../src/{Entity,Tests}'

  App\Billing\RazorpayClient:
    arguments:
      $keyId: '%env(RAZORPAY_KEY_ID)%'   # scalars are never autowirable
    lazy: true                            # proxy until first method call

<?php
declare(strict_types=1);

use Symfony\Component\DependencyInjection\Attribute\Autowire;

final class InvoiceService
{
    public function __construct(
        private readonly PaymentGateway $gateway,        // autowired by type
        #[Autowire('%env(int:INVOICE_RETRIES)%')]
        private readonly int $retries,                   // scalar needs help
    ) {}
}

// PHP 8.4 native lazy objects: no proxy library required
$ref     = new ReflectionClass(HeavyReportBuilder::class);
$builder = $ref->newLazyProxy(static fn () => new HeavyReportBuilder($pdo));
// nothing is constructed until a method is actually called

Key Points

  • PSR-11 defines only get() and has(); autowiring is implementation detail
  • Reflection resolves class types; scalars and unions need explicit config
  • Symfony compiles the graph at build time, Laravel reflects per request
  • Singletons holding request state break under Octane, RoadRunner, Swoole
  • PHP 8.4 newLazyProxy() replaces third-party proxy generators
💡 Pro Tip: Mark services private and inject them. The moment application code can call $container->get(), your dependency graph stops being checkable by PHPStan.
Q26

Why must PHPUnit data providers be static now, and when do you use createStub() instead of createMock()?

IntermediateTesting

Answer

A data provider feeds one test method many argument sets, and PHPUnit reports each set as its own test case, so a failure names the exact input instead of dying on the first assertion inside a foreach. Providers must be static from PHPUnit 10 onward, and PHPUnit 11 enforces it, because providers run while the suite is being built, before any test object exists. They cannot touch $this, setUp(), or a fixture.

If your provider wants database rows you are holding it wrong: build the fixture inside the test and pass identifiers through the provider. The same release line moved metadata from doc-blocks to attributes, so #[DataProvider('salaryBands')], #[Test], #[CoversClass(InvoiceService::class)] and #[Group('slow')] replace @dataProvider and friends. Test doubles split by intent. createStub() returns canned values and asserts nothing, which is right for a collaborator that merely supplies data. createMock() with expects($this->once())->with(...) asserts an interaction, which is right for a collaborator you command, such as a mailer or a payment gateway.

Reaching for the second kind everywhere produces tests that fail on every harmless refactor. Isolation is the production gotcha: PHPUnit runs the whole suite in one PHP process, so static properties, singletons, a mutated $_SERVER and any container you booted in one test survive into the next, which is how a suite passes alone and fails in CI ordering. Reset that state in tearDown() rather than paying for #[RunInSeparateProcess], which forks a fresh PHP for the test and is slow.

Database tests should begin a transaction in setUp() and roll it back in tearDown() instead of truncating tables. For coverage, Xdebug needs xdebug.mode=coverage and roughly triples runtime; PCOV is far faster for line coverage in CI.

<?php
declare(strict_types=1);

use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;

#[CoversClass(InvoiceService::class)]
final class InvoiceServiceTest extends TestCase
{
    /** @return iterable<string, array{int, float, int}> */
    public static function discounts(): iterable   // MUST be static
    {
        yield 'no discount'  => [10000, 0.0,  10000];
        yield 'ten percent'  => [10000, 0.10,  9000];
        yield 'rounds up'    => [999,   0.10,   899];
    }

    #[Test]
    #[DataProvider('discounts')]
    public function it_applies_discounts(int $paise, float $rate, int $want): void
    {
        // Stub: supplies data, asserts nothing
        $rates = $this->createStub(RateCard::class);
        $rates->method('for')->willReturn($rate);

        // Mock: asserts the interaction happened exactly once
        $audit = $this->createMock(AuditLog::class);
        $audit->expects($this->once())
              ->method('record')
              ->with($this->identicalTo($paise));

        self::assertSame($want, (new InvoiceService($rates, $audit))->charge($paise));
    }
}

Key Points

  • Providers run before the test object exists, so they must be static
  • Attributes replaced annotations: #[Test], #[DataProvider], #[CoversClass]
  • createStub() supplies data; createMock()->expects() asserts behaviour
  • One PHP process for the whole suite means static state leaks between tests
  • PCOV beats Xdebug for line coverage speed in CI
Q27

How do PHPStan levels and a baseline let you introduce static analysis into a legacy PHP codebase?

IntermediateStatic Analysis

Answer

PHPStan inspects code without executing it and grades strictness in numbered levels. Level 0 catches unknown classes, functions and methods; level 5 checks arguments against declared parameter types; level 6 demands type information everywhere, including array value types; level 8 makes calling a method on a possibly-null value an error; level 9 treats mixed strictly, and level 10, added in PHPStan 2.0, also reports implicit mixed. Pointing level 9 at a five-year-old CodeIgniter or legacy Laravel codebase produces thousands of errors and nobody ever fixes them.

That is exactly what the baseline solves: phpstan analyse --generate-baseline writes every current error into phpstan-baseline.neon, and the tool then ignores those specific occurrences. New and modified code is checked at full strictness from day one, and the baseline shrinks as files get touched. The rule the team has to agree on is that regenerating the baseline to make CI green is a review offence; the only legitimate reason to regenerate is a PHPStan upgrade.

Most of the remaining value comes from phpdoc types the language itself cannot express: array shapes such as array{id: int, title: string}, generics with @template, list<JobPost> instead of a bare array, non-empty-string, and @phpstan-assert so a custom validator narrows types for the caller. Framework magic is invisible without extensions, because Eloquent models resolve columns through __get and facades resolve through __callStatic; larastan handles Laravel, phpstan-symfony and phpstan-doctrine handle that stack, all wired up by phpstan/extension-installer. Two settings worth knowing: treatPhpDocTypesAsCertain: false stops PHPStan trusting annotations it cannot verify, and every ignore should carry an identifier so a broad regex does not silently swallow new errors later.

# phpstan.neon
parameters:
  level: 8
  paths: [src, tests]
  treatPhpDocTypesAsCertain: false
  ignoreErrors:
    - identifier: missingType.iterableValue   # scoped, not a blanket regex
      path: src/Legacy/*
includes:
  - phpstan-baseline.neon

# One-time adoption, then never regenerate to silence CI
# vendor/bin/phpstan analyse --generate-baseline
# vendor/bin/phpstan analyse --memory-limit=1G

<?php
declare(strict_types=1);

/**
 * @param  list<array{id: int, title: non-empty-string}> $rows
 * @return array<int, non-empty-string>
 */
function titlesById(array $rows): array
{
    $out = [];
    foreach ($rows as $row) {
        $out[$row['id']] = $row['title'];
    }
    return $out;
}

/** @phpstan-assert non-empty-string $value */
function assertFilled(string $value): void
{
    if ($value === '') {
        throw new InvalidArgumentException('empty');
    }
}

Key Points

  • Levels 0 to 10; level 8 is the realistic bar for existing code
  • --generate-baseline freezes current errors so new code is strict
  • Never regenerate the baseline to make a red build green
  • Array shapes, list<T> and @template express what PHP types cannot
  • larastan and phpstan-symfony teach it about framework magic methods
💡 Pro Tip: Run PHPStan on the diff in CI, not just the whole tree. A baseline plus a diff check keeps review feedback fast on a large repository.
Q28

How do PHP 8 attributes replace doc-block annotations, and what does reading them with Reflection cost?

IntermediateAttributes

Answer

Attributes are real syntax: #[Route('/jobs', methods: ['GET'])] is parsed by the compiler into class metadata, not scanned out of a comment string. A class becomes an attribute by carrying #[Attribute] itself, optionally with targets and Attribute::IS_REPEATABLE. Reading them is a two-step dance that surprises people: getAttributes() returns ReflectionAttribute objects that hold only the name and the raw arguments, and the attribute class is not instantiated until you call newInstance().

That laziness is deliberate, because it lets a scanner filter by name cheaply before constructing anything. Pass ReflectionAttribute::IS_INSTANCEOF as the second argument to getAttributes() when you want subclasses of a base attribute too. The important contrast with the old Doctrine-style annotations is that attributes live in the compiled AST, so they survive regardless of opcache.save_comments, while doc-block annotations are strings inside comments and vanish when comments are stripped.

That single property is why Symfony, Doctrine and PHPUnit all migrated. The cost is the reflection pass, not the syntax. Walking every class in src/, opening a ReflectionClass for each and reading its methods is slow enough that no framework does it per request: Symfony compiles routes and DI wiring into generated PHP during cache warmup, and Laravel caches routes with php artisan route:cache.

If you build your own attribute-driven feature, plan the cache from the start and invalidate it on deploy. Two other gotchas worth naming: attribute arguments must be constant expressions, so you cannot pass a service or the result of a function call, and an attribute whose class does not exist stays silent until newInstance() throws, which makes a typo in an import a runtime error rather than a compile one.

<?php
declare(strict_types=1);

#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
final readonly class Route
{
    /** @param list<string> $methods */
    public function __construct(
        public string $path,
        public array $methods = ['GET'],
    ) {}
}

final class JobController
{
    #[Route('/jobs', methods: ['GET'])]
    #[Route('/careers', methods: ['GET'])]   // repeatable
    public function index(): void {}
}

// Build the map ONCE at deploy time, never per request
function compileRoutes(string $class): array
{
    $map = [];
    foreach ((new \ReflectionClass($class))->getMethods() as $method) {
        foreach ($method->getAttributes(Route::class, \ReflectionAttribute::IS_INSTANCEOF) as $attr) {
            $route = $attr->newInstance();   // instantiated only here
            foreach ($route->methods as $verb) {
                $map[$verb][$route->path] = [$class, $method->getName()];
            }
        }
    }
    return $map;
}

file_put_contents('var/routes.php', '<?php return ' . var_export(compileRoutes(JobController::class), true) . ';');

Key Points

  • Attributes are compiled into class metadata, not parsed from comments
  • getAttributes() is lazy; newInstance() is what constructs the object
  • IS_INSTANCEOF lets you match subclasses of a base attribute
  • Unlike annotations, attributes do not depend on opcache.save_comments
  • Arguments must be constant expressions: no services, no function calls
Q29

Why should DateTimeImmutable be the default, and how do you keep Asia/Kolkata data correct in a UTC database?

IntermediateDate and Time

Answer

DateTime is mutable and its modifier methods return $this, so ->modify('+1 day') changes the object in place. Pass a DateTime into a helper that shifts it and you have silently changed the caller's value, which is one of the most common date bugs in PHP applications. DateTimeImmutable has the same API but every modifier returns a new instance, so the only way to lose a value is to ignore the return.

Both implement DateTimeInterface, so type-hint the interface when you accept a date and return DateTimeImmutable when you produce one. The storage rule that survives contact with production: store UTC, convert at the edges. A MySQL DATETIME column carries no zone at all, so whatever you insert is what you read back, while TIMESTAMP silently converts using the connection time_zone, which means the same row reads differently from two servers configured differently.

Set date_default_timezone_set('UTC') in your bootstrap, send SET time_zone = '+00:00' on the PDO connection, and call setTimezone(new DateTimeZone('Asia/Kolkata')) only when rendering. India has no daylight saving and a constant UTC+5:30 offset, which tempts people to hardcode +05:30; do not, because the moment you add a second market or handle a historical date the tzdata name is the only thing that stays right. Parsing deserves care too: strtotime() is lenient and returns false on failure, while DateTimeImmutable::createFromFormat() with a leading '!' in the format zeroes every field you did not specify, and DateTimeImmutable::getLastErrors() reports the warnings that otherwise pass unnoticed. PHP 8.4 added createFromTimestamp(), which accepts a float and removes the old '@' string trick.

<?php
declare(strict_types=1);

date_default_timezone_set('UTC');
$pdo->exec("SET time_zone = '+00:00'");

$ist = new \DateTimeZone('Asia/Kolkata');

// Mutable trap
$a = new \DateTime('2026-08-12 09:30:00');
$b = $a->modify('+1 day');
var_dump($a === $b);            // true: SAME object, $a moved too

// Immutable: the only way to lose the value is to drop the return
$x = new \DateTimeImmutable('2026-08-12 09:30:00', $ist);
$y = $x->modify('+1 day');
var_dump($x->format('c'), $y->format('c'));  // $x unchanged

// Store UTC, render IST
$utc = $x->setTimezone(new \DateTimeZone('UTC'));
$stmt->execute([$utc->format('Y-m-d H:i:s')]);
echo $utc->setTimezone($ist)->format('d M Y, g:i a');

// Strict parsing: '!' zeroes unspecified fields
$d = \DateTimeImmutable::createFromFormat('!d/m/Y', '31/02/2026', $ist);
var_dump($d?->format('Y-m-d'));                   // 2026-03-03, rolled over
var_dump(\DateTimeImmutable::getLastErrors());    // warning_count = 1

// PHP 8.4
$t = \DateTimeImmutable::createFromTimestamp(1786000000.25);

// diff(): only the DateInterval from diff() has a valid ->days
var_dump($x->diff($y)->days);   // 1

Key Points

  • DateTime mutates in place and returns $this; DateTimeImmutable does not
  • DATETIME stores no zone, TIMESTAMP converts by connection time_zone
  • Store UTC, convert with setTimezone at the rendering edge only
  • Use the Asia/Kolkata tzdata name, never a hardcoded +05:30 offset
  • createFromFormat with a leading '!' plus getLastErrors() for strict parsing
💡 Pro Tip: Ban DateTime in new code with a PHPStan rule or a CS-Fixer check. Mixed mutable and immutable date objects in one codebase is worse than either choice alone.
Q30

What goes wrong when you call json_decode() without flags on a third-party payload?

IntermediateJSON

Answer

json_decode() returns null on failure, and null is also the correct decoding of the literal input 'null', so the return value alone cannot tell you whether the parse succeeded. Code that writes $data = json_decode($body); if (!$data) treats a malformed webhook and an empty one identically. Since PHP 7.3 the fix is JSON_THROW_ON_ERROR, which raises JsonException instead, and it belongs on every decode and encode call you write; the older alternative is checking json_last_error() immediately afterwards.

The second argument controls whether objects become associative arrays or stdClass, and mixing the two conventions across a codebase causes a steady trickle of 'Attempt to read property on array' errors. The precision trap is worse and specific to PHP: integers larger than PHP_INT_MAX decode to floats, so a 19-digit payment or Twitter-style identifier comes back as 1.2345678901235E+18 and every subsequent comparison is wrong. JSON_BIGINT_AS_STRING keeps them intact as strings.

Depth defaults to 512 nested levels and a deeper document fails with JSON_ERROR_DEPTH rather than truncating. On the encode side, json_encode() returns false when the input contains invalid UTF-8, which is exactly what happens when a legacy latin1 MySQL column feeds an API; JSON_INVALID_UTF8_SUBSTITUTE replaces the bad bytes instead of failing the whole response. JSON_UNESCAPED_UNICODE keeps Devanagari and other Indic text readable rather than expanding it to \uXXXX escapes, and JSON_UNESCAPED_SLASHES stops URLs turning into https:\/\/. PHP 8.3 added json_validate(), which checks a string without allocating the decoded structure, which is the cheap way to reject a large garbage payload before it costs you memory.

<?php
declare(strict_types=1);

$body = '{"order_id": 9223372036854775808, "note": "नमस्ते", "url": "https://x.in"}';

// Wrong: null is both 'failed' and a valid decoding of null
$data = json_decode($body, true);

// Right: throws JsonException, keeps the big integer intact
try {
    $data = json_decode(
        $body,
        associative: true,
        depth: 64,
        flags: JSON_THROW_ON_ERROR | JSON_BIGINT_AS_STRING
    );
} catch (\JsonException $e) {
    throw new \RuntimeException('bad webhook payload', previous: $e);
}

var_dump($data['order_id']);   // string(19) "9223372036854775808"
// without JSON_BIGINT_AS_STRING: float(9.2233720368548E+18)

echo json_encode($data, JSON_THROW_ON_ERROR
    | JSON_UNESCAPED_UNICODE     // keeps नमस्ते readable
    | JSON_UNESCAPED_SLASHES     // https://x.in, not https:\/\/x.in
    | JSON_INVALID_UTF8_SUBSTITUTE);

// PHP 8.3: validate a 40 MB body without building the structure
if (!json_validate($body, depth: 64)) {
    http_response_code(400);
    exit;
}

Key Points

  • null is both the failure return and a valid decode of 'null'
  • JSON_THROW_ON_ERROR on every encode and decode is the modern default
  • Integers above PHP_INT_MAX become lossy floats without JSON_BIGINT_AS_STRING
  • json_encode returns false on invalid UTF-8 from legacy latin1 columns
  • PHP 8.3 json_validate() rejects garbage without allocating the structure
Q31

Why does $_FILES come back empty on a large upload, and how do you validate the file you did receive?

IntermediateFile Uploads

Answer

Two different limits produce two very different failures, and confusing them wastes hours. upload_max_filesize applies per file: exceed it and the file still appears in $_FILES with error code UPLOAD_ERR_INI_SIZE, while the rest of $_POST arrives normally, so you can show a clean message. post_max_size applies to the entire request body: exceed it and PHP discards the whole body before your script runs, so $_POST and $_FILES are both completely empty, there is no error code anywhere, and your CSRF check usually fails first and reports something misleading. The only signal is that CONTENT_LENGTH is larger than the configured limit, which is worth checking explicitly at the top of an upload handler. Around those two sit max_file_uploads (default 20 files per request), upload_tmp_dir which must exist and be writable by the FPM user, and nginx client_max_body_size, which returns a 413 before PHP is even invoked if it is smaller than post_max_size.

Set nginx slightly higher than post_max_size, and post_max_size comfortably higher than upload_max_filesize. Validation is where security lives. $_FILES['cv']['type'] comes straight from the client's Content-Type header and is trivially forged, and the filename extension is equally meaningless. Use finfo with FILEINFO_MIME_TYPE to read the real magic bytes of the temporary file, check the result against an allowlist, and reject everything else.

Always move the file with move_uploaded_file(), which verifies the path really was an upload, never with rename() or copy(). Generate your own storage name from random_bytes() instead of reusing the client's, keep the directory outside the document root or push to S3, and make sure no path under your web root can execute PHP.

<?php
declare(strict_types=1);

// post_max_size exceeded: $_POST and $_FILES are BOTH empty, no error code
$max = (int) (ini_get('post_max_size') ? 8 * 1024 * 1024 : 0);
if (($_SERVER['CONTENT_LENGTH'] ?? 0) > $max && $_FILES === []) {
    http_response_code(413);
    exit('Upload exceeded post_max_size');
}

$file = $_FILES['cv'] ?? null;
if ($file === null || $file['error'] !== UPLOAD_ERR_OK) {
    throw new RuntimeException(match ($file['error'] ?? UPLOAD_ERR_NO_FILE) {
        UPLOAD_ERR_INI_SIZE   => 'File exceeds upload_max_filesize',
        UPLOAD_ERR_FORM_SIZE  => 'File exceeds the form MAX_FILE_SIZE',
        UPLOAD_ERR_PARTIAL    => 'Upload was interrupted',
        UPLOAD_ERR_NO_TMP_DIR => 'upload_tmp_dir is missing',
        UPLOAD_ERR_CANT_WRITE => 'Disk not writable',
        default               => 'No file uploaded',
    });
}

// NEVER trust $file['type'] or the extension. Read the magic bytes.
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']);
$allowed = ['application/pdf' => 'pdf', 'image/jpeg' => 'jpg'];
if (!isset($allowed[$mime])) {
    throw new RuntimeException("Rejected mime {$mime}");
}

$name = bin2hex(random_bytes(16)) . '.' . $allowed[$mime];
if (!move_uploaded_file($file['tmp_name'], "/var/app/storage/cv/{$name}")) {
    throw new RuntimeException('move_uploaded_file failed');
}

Key Points

  • upload_max_filesize gives UPLOAD_ERR_INI_SIZE; post_max_size empties everything
  • nginx client_max_body_size must exceed post_max_size or you get a 413
  • $_FILES['x']['type'] is a client header: verify with finfo magic bytes
  • move_uploaded_file() is the only safe way to relocate the temp file
  • Generate the stored filename yourself and keep it out of the web root
💡 Pro Tip: When someone reports 'the form just reloads with no error' on a big file, check post_max_size first. An empty $_POST with a large CONTENT_LENGTH is the fingerprint.
Q32

APCu, Redis or OPcache: which cache belongs where, and how do you stop a cache stampede?

IntermediateCaching

Answer

The three are not alternatives, they solve different problems. OPcache holds compiled opcodes in shared memory and never stores your data; it removes parse and compile time and nothing else. APCu holds user data in the shared memory of one machine, so a get is a local memory read with no network hop and no serialization over a socket, which makes it the fastest option by a wide margin.

Its limits are structural: the cache is per server, so five web nodes means five independent copies with no way to invalidate them together, and it is wiped when PHP-FPM restarts, which on a deploy is every time. Redis or Valkey lives outside the process, is shared by every node, survives restarts, and gives you TTLs, atomic counters and locks, at the cost of a round trip on every read. The usual production shape is a two-level cache: APCu as L1 for values that tolerate a few seconds of skew, such as feature flags and config, backed by Redis as L2 as the source of truth.

A stampede happens when a hot key expires and every in-flight request misses simultaneously, so a hundred workers all run the same expensive query and the database falls over. Three fixes are worth naming. Take a short lock with SET lockkey token NX PX 5000 so exactly one worker rebuilds while the others serve the stale value or briefly wait.

Use probabilistic early expiry, where each reader recomputes with a small probability that rises as the TTL approaches. Or never expire the key at all and refresh it from a cron or queue worker. Also remember to use SCAN rather than KEYS in production; KEYS on a large Redis blocks the entire server.

<?php
declare(strict_types=1);

function remember(Redis $r, string $key, int $ttl, callable $build): mixed
{
    // L1: same-machine shared memory, no network hop
    $local = apcu_fetch($key, $hit);
    if ($hit) { return $local; }

    $raw = $r->get($key);
    if ($raw !== false) {
        apcu_store($key, $v = unserialize($raw), 5);   // short L1 TTL
        return $v;
    }

    // Stampede guard: exactly one worker rebuilds
    $token = bin2hex(random_bytes(8));
    if ($r->set("lock:{$key}", $token, ['NX', 'PX' => 5000])) {
        try {
            $value = $build();
            $r->set($key, serialize($value), ['EX' => $ttl]);
            apcu_store($key, $value, 5);
            return $value;
        } finally {
            // release only if we still own it
            $r->eval(
                "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) end",
                ["lock:{$key}", $token],
                1
            );
        }
    }

    usleep(50_000);              // loser waits briefly, then reads the fresh value
    return unserialize((string) $r->get($key)) ?: $build();
}

Key Points

  • OPcache caches code, APCu caches data per server, Redis caches data per cluster
  • APCu is wiped by every FPM restart, so treat it as a short-lived L1
  • SET key val NX PX ttl is the one-line distributed lock for rebuilds
  • Release a lock only if the token still matches, via a Lua script
  • Use SCAN, never KEYS, against a production Redis instance
Q33

What actually causes 'Cannot modify header information: headers already sent by', and how do you find the culprit?

IntermediateOutput and Headers

Answer

HTTP requires the status line and headers to precede the body, and PHP enforces that literally. The moment the first byte of body content leaves PHP, the headers are flushed to the SAPI, and every later header(), setcookie(), session_start() or http_response_code() call emits a warning and does nothing at all. The warning is unusually helpful because it names the file and line where output actually started, which is the answer to the question rather than the place the header call failed.

In practice the source is almost always one of four things: a blank line or stray whitespace after a closing ?> in an included file, a UTF-8 byte order mark saved by an editor at the top of a file, a leftover var_dump or echo during debugging, or a PHP notice printed to the browser because display_errors is on in production. Two of those are prevented by policy. PSR-12 forbids the closing ?> in files that contain only PHP, precisely so trailing whitespace cannot become output, and display_errors must be Off with log_errors On on any production host.

Output buffering is the other lever: with output_buffering set to 4096 or an explicit ob_start() at the front controller, PHP holds the body in memory so headers stay mutable until the buffer flushes, which is exactly how frameworks that build a Response object and send it once at the end sidestep the whole class of bug. headers_sent($file, $line) lets you detect the condition programmatically before attempting a redirect. The opposite case matters too: for a streaming response such as server-sent events you must disable buffering at both ends, because nginx will happily hold your output even after PHP flushes it.

<?php
// includes/config.php  <- the real culprit is usually a file like this
// ?>
//        <- a single trailing newline here becomes response body

// Detect it before you try to redirect
if (headers_sent($file, $line)) {
    error_log("output started at {$file}:{$line}");
} else {
    header('Location: /dashboard', true, 302);
    exit;
}

// Buffer at the front controller so headers stay mutable
ob_start();
try {
    $response = $kernel->handle($request);
    ob_clean();                       // discard anything stray code echoed
    foreach ($response->headers as $name => $value) {
        header("{$name}: {$value}", true);
    }
    echo $response->body;
} finally {
    ob_end_flush();
}

// Streaming (SSE): buffering must be off in PHP *and* nginx
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('X-Accel-Buffering: no');      // nginx: stop proxy buffering
while (ob_get_level() > 0) { ob_end_flush(); }
ob_implicit_flush(true);

Key Points

  • Headers are flushed the instant the first body byte is emitted
  • The warning names the file and line where output began: read it
  • Omit the closing ?> in pure-PHP files, and save without a BOM
  • display_errors=Off in production, or a notice becomes your body
  • For SSE, disable PHP buffering and send X-Accel-Buffering: no for nginx
Q34

Why can preg_match() return false, and how does catastrophic backtracking take down a PHP-FPM pool?

IntermediateRegex

Answer

preg_match() has three possible returns: 1 for a match, 0 for no match, and false when PCRE itself failed. Because false is falsy just like 0, the common if (preg_match($re, $s)) form silently treats an engine failure as a clean no-match, which is how a validation rule quietly stops validating anything. Always compare against 1, or check preg_last_error() straight afterwards; PHP 8.0 added preg_last_error_msg() so you get a readable string instead of an integer constant.

The failures you will actually meet are PREG_BAD_UTF8_ERROR, when the /u modifier is set and the subject is not valid UTF-8, which happens the moment a latin1 database column reaches your regex, and PREG_BACKTRACK_LIMIT_ERROR, when the match exceeded pcre.backtrack_limit, which defaults to one million steps. That second one is the interesting half. A pattern with nested quantifiers, such as (\d+)+$ or (a|aa)+, can explore an exponential number of paths on a subject that nearly matches but fails at the end.

On a user-supplied field this is a denial of service: each request pins one PHP-FPM worker at full CPU until the backtrack limit trips, and since concurrency is capped by pm.max_children, a few dozen crafted requests exhaust the pool and the site returns 502. The defences are pattern-level. Possessive quantifiers like \d++ and atomic groups (?>...) tell PCRE never to give back what it has consumed.

Anchor patterns, avoid .* before a required literal, and prefer plain string functions or filter_var for things a regex should not be doing at all, starting with email validation. Also cap input length before matching, and remember preg_quote() for any user text interpolated into a pattern.

<?php
declare(strict_types=1);

$subject = str_repeat('1', 40) . 'x';

// Catastrophic: nested quantifier explores exponential paths
$bad = preg_match('/^(\\d+)+$/', $subject);
var_dump($bad);                       // false, NOT 0
var_dump(preg_last_error_msg());      // "Backtrack limit exhausted"

// Possessive quantifier: PCRE never gives back what it consumed
var_dump(preg_match('/^\\d++$/', $subject));   // 0, returns instantly
// Atomic group does the same job
var_dump(preg_match('/^(?>\\d+)$/', $subject));

// The dangerous idiom: false is falsy, so this 'passes' validation
if (preg_match('/^(\\d+)+$/', $subject)) { /* never runs */ }

// Safe wrapper
function matches(string $pattern, string $subject): bool
{
    $r = preg_match($pattern, $subject);
    if ($r === false) {
        throw new \RuntimeException('pcre: ' . preg_last_error_msg());
    }
    return $r === 1;
}

// Do not write an email regex
var_dump(filter_var('a@b.in', FILTER_VALIDATE_EMAIL) !== false);

// User input inside a pattern must be quoted
$re = '/' . preg_quote($userTerm, '/') . '/iu';

Key Points

  • preg_match returns 1, 0 or false; false is an engine error, not a no-match
  • preg_last_error_msg() (PHP 8.0) names the failure in plain text
  • pcre.backtrack_limit defaults to 1,000,000 steps
  • Nested quantifiers on user input pin a worker at 100% CPU: real DoS
  • Possessive quantifiers, atomic groups and filter_var are the fixes
💡 Pro Tip: Cap the length of any string before it reaches a regex. Most ReDoS payloads need a long subject, and a 256-character limit removes the attack without touching the pattern.
Q35

How do you configure Guzzle so one slow third-party API cannot exhaust your PHP-FPM pool?

IntermediateHTTP Clients

Answer

Start from the capacity arithmetic, because that is what interviewers are really testing. PHP-FPM is blocking: an outbound HTTP call occupies a worker for its entire duration. If a payment or KYC provider averages 8 seconds and pm.max_children is 40, your absolute ceiling is five such requests per second no matter how fast your own code is, and every one of those workers is unavailable to serve a homepage.

Guzzle makes this worse by default, because timeout is 0, meaning no time limit at all: a hung upstream holds the worker until FPM's request_terminate_timeout kills it. Set connect_timeout and timeout separately, since a DNS or TCP failure should give up in two or three seconds while a slow but working endpoint may deserve five or ten. Add a retry middleware that only retries idempotent verbs plus 429 and 5xx responses, uses exponential backoff with jitter so your whole fleet does not retry in lockstep, honours Retry-After, and caps total attempts, because a naive three-retry policy on a 10-second timeout turns one request into a 30-second worker hold.

Put a circuit breaker in Redis so that once a provider has failed repeatedly you fail fast for a minute instead of queueing behind it. When you need several independent calls, issue them concurrently with promises and Utils::settle(), or use Symfony HttpClient, which multiplexes over HTTP/2 natively; three parallel 400ms calls then cost 400ms rather than 1.2 seconds. Anything genuinely slow belongs on a queue rather than in the request path, and TLS verification stays on: 'verify' => false is not a fix for a certificate problem.

<?php
declare(strict_types=1);

use GuzzleHttp\Client;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use GuzzleHttp\Promise\Utils;
use GuzzleHttp\Psr7\Request;
use Psr\Http\Message\ResponseInterface;

$stack = HandlerStack::create();
$stack->push(Middleware::retry(
    static function (int $tries, $req, ?ResponseInterface $res, ?\Throwable $e): bool {
        if ($tries >= 3) { return false; }
        if ($e instanceof \GuzzleHttp\Exception\ConnectException) { return true; }
        return $res !== null && in_array($res->getStatusCode(), [429, 502, 503, 504], true);
    },
    static fn (int $tries): int => (2 ** $tries) * 100 + random_int(0, 100) // ms + jitter
));

$client = new Client([
    'handler'         => $stack,
    'connect_timeout' => 2.0,   // DNS + TCP + TLS
    'timeout'         => 5.0,   // total wall clock, NEVER leave at 0
    'http_errors'     => false, // handle status codes yourself
    'verify'          => true,
    'headers'         => ['User-Agent' => 'goodspace/1.0'],
]);

// Three independent calls in parallel: max(t), not sum(t)
$responses = Utils::settle([
    'pan'  => $client->sendAsync(new Request('GET', '/kyc/pan')),
    'gst'  => $client->sendAsync(new Request('GET', '/kyc/gst')),
    'bank' => $client->sendAsync(new Request('GET', '/kyc/bank')),
])->wait();

Key Points

  • Guzzle's default timeout is 0: an unresponsive upstream holds a worker forever
  • connect_timeout and timeout are separate knobs and need separate values
  • Retries multiply worker hold time: cap attempts and add jitter
  • pm.max_children divided by upstream latency is your real throughput ceiling
  • Utils::settle() or Symfony HttpClient for concurrent calls; queues for slow work
💡 Pro Tip: Record upstream latency as its own metric, separate from total request time. When a page slows down, that one chart tells you immediately whether the problem is your code or somebody else's API.
Q36

What do the xdebug.mode values do, and what do you use for profiling when Xdebug is not an option?

IntermediateProfiling

Answer

Xdebug 3 replaced a scattering of independent ini flags with a single xdebug.mode setting, and knowing the values is table stakes. off disables everything; develop gives you the enhanced var_dump and readable stack traces on errors; debug enables step debugging over DBGp; coverage enables code coverage collection for PHPUnit; profile writes a cachegrind file per request into xdebug.output_dir; trace records a function-level execution log; gcstats reports garbage collection activity. Modes combine with commas. The other Xdebug 3 change people trip over is the port: step debugging moved from 9000 to 9003 because 9000 collided with PHP-FPM itself, and xdebug.start_with_request accepts yes, no or trigger, where trigger means the session only starts when an XDEBUG_TRIGGER cookie, GET parameter or environment variable is present.

That trigger mode is what makes a shared dev container usable. Profiling mode is not something you leave on: it writes a multi-megabyte file for every single request, so you enable it with the trigger for one URL and read the output in KCachegrind, qcachegrind or Webgrind. None of this belongs in production, where the overhead and the disk writes are unacceptable.

Production profiling uses sampling instead: excimer takes periodic stack samples with very low overhead and feeds flamegraphs, Blackfire and Tideways use a probe that only instruments requests carrying a signed trigger, and the OpenTelemetry PHP auto-instrumentation gives you distributed traces across services. Before installing anything, remember that PHP-FPM already ships a free profiler of sorts: request_slowlog_timeout writes a full PHP backtrace for every request slower than the threshold, with no extension and no measurable cost.

; Development container: step debugging on demand only
zend_extension=xdebug.so
xdebug.mode=develop,debug
xdebug.start_with_request=trigger   ; needs XDEBUG_TRIGGER to activate
xdebug.client_host=host.docker.internal
xdebug.client_port=9003             ; was 9000 in Xdebug 2
xdebug.log=/tmp/xdebug.log

; Profiling a single slow endpoint
xdebug.mode=profile
xdebug.start_with_request=trigger
xdebug.output_dir=/tmp/profiles
xdebug.profiler_output_name=cachegrind.out.%R.%t

# Trigger one profiled request, then open it in qcachegrind
# curl -H 'Cookie: XDEBUG_TRIGGER=1' https://localhost/jobs/search

; CI: coverage without Xdebug's overhead
extension=pcov.so
pcov.enabled=1
pcov.directory=src

; Production: no Xdebug at all. FPM's own slowlog is free.
request_slowlog_timeout = 3s
slowlog = /var/log/php-fpm-slow.log

# Sampling profiler for continuous production flamegraphs
# pecl install excimer

Key Points

  • xdebug.mode: off, develop, debug, coverage, profile, trace, gcstats
  • Step debugging listens on 9003 in Xdebug 3, not 9000
  • start_with_request=trigger keeps shared dev environments usable
  • Profile mode writes a cachegrind file per request: never leave it on
  • Production uses sampling (excimer, Blackfire) or the free FPM slowlog
Q37

Why does enabling the OPcache JIT usually not speed up a Laravel or Symfony application?

AdvancedJIT

Answer

The JIT compiles hot PHP opcodes into native machine code at runtime, sitting on top of OPcache rather than replacing it. It is configured with two settings: opcache.jit, which takes an alias such as tracing or function (or the four-digit CRTO form), and opcache.jit_buffer_size, which defaults to zero, meaning the JIT is inert until you give it memory. Tracing mode records hot loops and function paths and compiles the traces; function mode compiles whole functions on a call-count threshold and is generally weaker.

The honest answer to why it disappoints on web workloads is that a typical request in a framework is not CPU bound. It is dominated by database round trips, Redis and HTTP calls, plus hash table lookups, string handling and memory allocation inside the engine, all of which are already implemented in optimised C that the JIT does not change. What the JIT does help with is long arithmetic loops in pure PHP: image processing, Mandelbrot-style benchmarks, numeric simulation, and some of the work in a Rector or PHPStan run, where gains can be large.

For a normal CRUD application, the honest expected improvement is somewhere between nothing and a few percent, and OPcache itself plus fixing your N+1 queries will beat it by an order of magnitude. There are costs to weigh as well: the JIT buffer consumes shared memory that OPcache could have used, JIT frames confuse some profilers and debuggers, and historically the JIT has been the source of harder-to-reproduce segfaults than the interpreter. PHP 8.4 rebuilt the JIT on a new intermediate representation, which improved maintainability and code quality, but it did not change the fundamental point about I/O bound workloads. Benchmark your own application before and after; do not enable it on faith.

; JIT is inert until the buffer is non-zero
opcache.enable=1
opcache.enable_cli=0
opcache.jit=tracing            ; alias for the 1254 CRTO form
opcache.jit_buffer_size=64M    ; 0 = JIT off, which is the default

<?php
// Confirm it is actually running, do not assume
$s = opcache_get_status(false);
var_dump($s['jit']['enabled'], $s['jit']['on'], $s['jit']['buffer_free']);

// This is the shape of code JIT genuinely accelerates:
// tight arithmetic, no I/O, no object graph
function mandelRow(float $y, int $w, int $iter): array
{
    $row = [];
    for ($x = 0; $x < $w; $x++) {
        $cr = ($x / $w) * 3.5 - 2.5;
        $zr = 0.0; $zi = 0.0; $n = 0;
        while ($zr * $zr + $zi * $zi <= 4.0 && $n < $iter) {
            [$zr, $zi] = [$zr * $zr - $zi * $zi + $cr, 2 * $zr * $zi + $y];
            $n++;
        }
        $row[] = $n;
    }
    return $row;
}

// This is the shape it does NOT help: the time is in the wire
$user = $pdo->query('SELECT * FROM users WHERE id = 1')->fetch();
$resp = $http->get('https://api.example.in/kyc');

Key Points

  • JIT needs opcache.jit_buffer_size above zero; the default disables it
  • tracing mode compiles hot traces, function mode compiles whole functions
  • Framework requests are I/O bound, so gains are typically near zero
  • Real wins are numeric loops: image work, simulations, analysis tooling
  • The buffer competes with OPcache memory and complicates profiling
💡 Pro Tip: If someone proposes JIT as the fix for a slow endpoint, ask for the flamegraph first. Nine times out of ten the time is in queries, not in opcode dispatch.
Q38

What problem do Fibers solve in PHP 8.1, and why does adding one not make your code asynchronous?

AdvancedConcurrency

Answer

A Fiber is a full-stack, cooperatively scheduled coroutine: a block of code with its own call stack that can suspend itself anywhere, deep inside nested function calls, and be resumed later with a value. You create one with new Fiber(callable), begin it with start(), pause it from anywhere inside with the static Fiber::suspend($value), and continue it from outside with resume($value); getReturn() gives you the callable's return value once it completes. What this fixes is the coloured-function problem that generators had.

Before fibers, writing non-blocking code in PHP meant every function in the call chain had to be a generator that yielded, so a single blocking leaf forced you to rewrite everything above it. A fiber suspends the entire stack, so ordinary synchronous-looking code can be paused by a library several frames down without any caller knowing. What a fiber emphatically does not do is create concurrency by itself.

There is exactly one thread, only one fiber runs at a time, and Fiber::suspend() just hands control back to whoever resumed it. To get real overlap you need two more pieces: an event loop to decide which fiber to resume next, and non-blocking I/O so that waiting on a socket yields instead of blocking. That is precisely what Revolt provides as the loop and AMPHP v3 and ReactPHP provide as the I/O layer, and it is why AMPHP v3 dropped the yield-everywhere style.

Two limitations matter in practice. Calling Fiber::suspend() when no fiber is active throws FiberError, and you cannot suspend across an internal C function frame, so suspending inside a usort or array_map callback fails. Also, a blocking call such as PDO::query or file_get_contents still blocks the whole process, fiber or not.

<?php
declare(strict_types=1);

$fiber = new Fiber(function (string $name): string {
    echo "start {$name}\n";
    $token = Fiber::suspend('need-token');   // pauses the WHOLE stack
    echo "resumed with {$token}\n";
    return "done:{$name}";
});

$request = $fiber->start('kyc');    // runs until the first suspend
var_dump($request);                 // 'need-token'
$fiber->resume('abc123');           // continues from inside the closure
var_dump($fiber->getReturn());      // 'done:kyc'
var_dump($fiber->isTerminated());   // true

// Suspension works from ANY depth: this is what generators could not do
function level3(): string { return Fiber::suspend('deep'); }
function level2(): string { return level3(); }
function level1(): string { return level2(); }

$deep = new Fiber(level1(...));
var_dump($deep->start());           // 'deep'
$deep->resume('value-from-outside');

// Outside a fiber this is a hard error
try {
    Fiber::suspend('x');
} catch (\FiberError $e) {
    echo $e->getMessage();          // Cannot suspend outside of fiber
}

// One thread only: real concurrency needs Revolt's loop plus
// non-blocking I/O from amphp/amp v3 or reactphp.

Key Points

  • Fibers suspend the entire call stack, not just one generator frame
  • start(), Fiber::suspend(), resume(), getReturn(), isTerminated()
  • Single threaded and cooperative: no parallelism on their own
  • They need an event loop (Revolt) and non-blocking I/O (AMPHP, ReactPHP)
  • Cannot suspend across an internal C frame such as a usort callback
Q39

What breaks when you move a Laravel or Symfony app from PHP-FPM to a worker runtime like Octane, RoadRunner or Swoole?

AdvancedWorker Runtimes

Answer

Under FPM the framework boots on every request and the process forgets everything at the end, so a whole category of sloppiness is invisible. Worker runtimes boot once and then serve thousands of requests in the same PHP process, which converts that sloppiness into bugs that are intermittent, user-specific and terrifying. The big one is state that outlives a request.

A static property holding the current user, a singleton service that cached a tenant id or a locale, a config value overwritten at runtime, a Carbon test-now that was never reset: each of these leaks into whichever unlucky request the same worker handles next, so user A sees user B's data. Anything registered in the container as a singleton that receives a request-scoped dependency in its constructor freezes the first request's copy forever, which is why Octane requires you to resolve such services fresh or list them for rebinding. Superglobals behave differently too, because the runtime synthesises the request object rather than PHP populating $_SERVER and $_POST from the SAPI, so code that reads superglobals directly may see stale values.

Second, memory. Reference cycles that were previously reclaimed by the end-of-request teardown now accumulate, and any unbounded static array is a leak, so you cap it with a max-requests setting that recycles workers. Third, resources: a MySQL connection held for hours will hit wait_timeout and produce 'MySQL server has gone away', so the connection needs a ping or a reconnect strategy.

Fourth, deploys: new code does not take effect until workers are restarted, so your pipeline must call the runtime's reload command. The upside is real, often two to four times the throughput, but it demands the discipline FPM let you skip.

<?php
declare(strict_types=1);

// LEAKS across requests in a worker runtime, harmless under FPM
final class CurrentTenant
{
    private static ?string $id = null;                 // survives the request
    public static function set(string $id): void { self::$id = $id; }
    public static function get(): ?string { return self::$id; }
}

// Also leaks: a singleton that captured a request-scoped dependency
$container->singleton(ReportService::class, static fn ($app) =>
    new ReportService($app->make(Request::class))       // frozen forever
);

// Safer: resolve per invocation
$container->bind(ReportService::class, static fn ($app) =>
    new ReportService($app->make(Request::class))
);

// config/octane.php: reset framework state between requests
'flush' => [CurrentTenant::class],
'warm'  => [\Illuminate\Cache\CacheManager::class],

# .rr.yaml (RoadRunner): bound leaks and recycle workers
# server:
#   command: "php psr-worker.php"
# http:
#   pool:
#     num_workers: 16
#     max_jobs: 500          # recycle after N requests
#     supervisor:
#       max_worker_memory: 256   # MB, restart before the OOM killer does

# Deploys must reload the workers, not just swap files
# php artisan octane:reload   |   ./rr reset

Key Points

  • Static properties and singletons now persist across users' requests
  • A singleton holding a request-scoped dependency freezes the first request
  • Reference cycles accumulate: cap lifetime with max_jobs or max-requests
  • Long-lived MySQL connections hit wait_timeout: ping or reconnect
  • Deploys need an explicit worker reload before new code runs
💡 Pro Tip: Before migrating, grep for 'static ' properties and for any direct read of $_SERVER, $_GET or $_POST outside the HTTP layer. That grep finds most of the bugs before your users do.
Q40

How does opcache.preload differ from ordinary OPcache, and what makes it awkward to deploy?

AdvancedPerformance

Answer

Ordinary OPcache stores compiled opcodes in shared memory, but each worker still has to run the autoloader, include the file and link the class into its own runtime on first use per request. Preloading, added in PHP 7.4, goes further: at server startup the master process executes a script you nominate with opcache.preload, and every class and function that script requires is compiled, linked and made permanently resident in shared memory. Those symbols then exist in every worker from the first line of every request with no autoloading, no include and no linking at all.

When the server runs as root you must also set opcache.preload_user, or PHP refuses to start. In practice you use opcache_compile_file() for most files and require for the small set that must actually be linked eagerly, and both Symfony and Laravel can generate the script for you. The awkwardness is operational.

Preloaded code is immutable for the life of the master process: changing a file on disk does nothing, and even a graceful FPM reload is not always enough, so deploys need a full restart of the FPM master, which is a hard cut rather than a drain. Because the preload script runs once at startup, before any request exists, anything with side effects belongs nowhere near it: no database connections, no environment reads that depend on request context. Classes whose parents or interfaces are not preloadable are skipped, with warnings in the error log that most people never read, so you should verify what actually landed.

Symlink-based deploys are the classic trap, since the preload script resolves absolute paths at startup and keeps pointing at the release directory that existed then. The realistic gain for a big framework app is a few percent, so weigh it against a deploy process that can no longer reload gracefully.

; php.ini
opcache.enable=1
opcache.preload=/var/www/current/preload.php
opcache.preload_user=www-data      ; mandatory when the master runs as root
opcache.memory_consumption=256

<?php
// /var/www/current/preload.php  (runs ONCE at FPM master startup)
declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

$it = new RecursiveIteratorIterator(
    new RecursiveDirectoryIterator(__DIR__ . '/vendor/symfony', FilesystemIterator::SKIP_DOTS)
);

foreach ($it as $file) {
    if ($file->getExtension() !== 'php') { continue; }
    // compile without executing; unlinkable classes are skipped with a warning
    opcache_compile_file($file->getRealPath());
}

// Verify what actually landed, instead of assuming
// php -r '$s = opcache_get_status(); var_dump(count($s["scripts"]));'

// Deploy implications:
//   * editing a preloaded file on disk has NO effect
//   * systemctl restart php8.4-fpm  (restart, not reload)
//   * absolute paths are frozen, so symlink swaps need care

Key Points

  • Preloaded classes are linked once and resident in every worker
  • opcache.preload_user is required when the FPM master runs as root
  • Changing a preloaded file does nothing until the master restarts
  • No side effects in the preload script: it runs before any request exists
  • Unlinkable classes are silently skipped, so verify the resident count
Q41

Why is unserialize() on untrusted input a remote code execution risk, and what is a POP chain?

AdvancedSecurity

Answer

unserialize() does not merely parse data, it instantiates objects. The serialized string names a class and its property values, and PHP will construct an instance of any class currently loaded, bypassing the constructor, then call that object's magic methods as it is used and destroyed. An attacker who controls the input therefore controls which classes exist in memory and what their properties contain.

A property-oriented programming chain, or POP chain, strings those magic methods together into something dangerous using only code that already exists in your application and its vendor directory. A typical gadget starts at __destruct or __wakeup, which always run, and reaches a method that writes a file, deletes a path, or invokes a callable stored in a property. Because Composer projects load thousands of classes, well-known chains exist for widely used libraries, and tools like PHPGGC ship ready-made payloads for Laravel, Symfony, Doctrine, Guzzle and Monolog.

The defence in order of preference: do not unserialize untrusted data at all, use json_decode for data interchange since it produces only arrays and scalars. Where you cannot avoid it, pass the allowed_classes option, either false to permit no objects or an explicit allowlist, which turns anything else into an __PHP_Incomplete_Class that does nothing. If the payload must round-trip through a client, sign it with hash_hmac and verify with hash_equals before unserialize ever sees it.

Two PHP-specific extras interviewers like. Since PHP 7.4, __serialize and __unserialize supersede __sleep and __wakeup and give you an explicit array contract. And the phar:// stream wrapper deserializes Phar metadata during ordinary file operations, so a file_exists() on an attacker-controlled path was historically an exploitation route, which is why your upload validation must reject phar content, not just check extensions.

<?php
declare(strict_types=1);

// A gadget: nothing here is malicious on its own
final class TempFile
{
    public string $path = '/tmp/scratch';
    public function __destruct(): void
    {
        @unlink($this->path);   // attacker chooses $path
    }
}

// Attacker-supplied payload deletes any file the FPM user can write
$evil = 'O:8:"TempFile":1:{s:4:"path";s:26:"/var/www/current/.env";}';
// unserialize($evil);  // object built, __destruct fires at scope exit

// Defence 1: allow no objects at all
$safe = unserialize($input, ['allowed_classes' => false]);

// Defence 2: explicit allowlist
$safe = unserialize($input, ['allowed_classes' => [Money::class]]);

// Defence 3: if it must round-trip through the client, sign it
$payload = base64_encode(serialize($state));
$sig     = hash_hmac('sha256', $payload, $appKey);

$ok = hash_equals($sig, $givenSig);   // constant time, verify BEFORE unserialize
if (!$ok) { throw new RuntimeException('tampered payload'); }

// Best: no objects in the wire format at all
$state = json_decode($input, true, 32, JSON_THROW_ON_ERROR);

// PHP 7.4+ explicit contract, replaces __sleep and __wakeup
final class Session
{
    public function __construct(private string $id) {}
    public function __serialize(): array { return ['id' => $this->id]; }
    public function __unserialize(array $d): void { $this->id = $d['id']; }
}

Key Points

  • unserialize() instantiates arbitrary loaded classes and skips the constructor
  • POP chains build an exploit out of __destruct and __wakeup gadgets in vendor code
  • allowed_classes => false is the single most effective mitigation
  • HMAC-sign and hash_equals-verify any payload that leaves your server
  • phar:// deserializes metadata during ordinary filesystem calls
💡 Pro Tip: Grep the codebase for unserialize( and check every call site for a second argument. A serialized value in a cookie, a queue payload or a cache key is a finding in any PHP security review.
Q42

A payment webhook fires twice and you credit the wallet twice. How do you make the handler idempotent in PHP?

AdvancedConcurrency Control

Answer

Start by accepting that retries are normal: Razorpay, Stripe and every other gateway retry on timeout or a non-2xx response, and network partitions mean at-least-once delivery is the only guarantee you get. So the handler must be safe to run twice concurrently, not merely twice in sequence. The mistake almost everyone makes first is a check-then-act: SELECT to see whether the event id was processed, then INSERT and credit.

Two FPM workers on two servers run that SELECT in the same millisecond, both see nothing, and both credit. There is no in-process mutex to save you, because each request is a separate process on possibly a separate machine, so flock and APCu locks only ever protect one server. The durable fix is a database constraint, because a unique index is the only thing in the stack that is atomic across every application server.

Insert the gateway's event id into a processed_events table with a UNIQUE key inside the same transaction that credits the wallet: the second worker's INSERT fails with SQLSTATE 23000 (MySQL error 1062), you catch it, roll back, and return 200 so the gateway stops retrying. Where you need to serialise rather than reject, SELECT ... FOR UPDATE inside a transaction locks the row for the duration, and Redis SET key token NX PX gives you a cross-service lock provided you release it with a Lua script that checks the token and you accept that a lock which expires mid-job offers no protection without a fencing token. Two more PHP details: MySQL raises error 1213 on deadlock and the correct response is to retry the whole transaction, not the statement, and PDO has no real nested transactions, so a framework's nested begin is a SAVEPOINT.

<?php
declare(strict_types=1);

// CREATE TABLE processed_events (event_id VARCHAR(64) PRIMARY KEY) ENGINE=InnoDB;

function handleWebhook(PDO $pdo, string $eventId, int $userId, int $paise): int
{
    for ($attempt = 1; $attempt <= 3; $attempt++) {
        try {
            $pdo->beginTransaction();

            // The unique key, not a SELECT, is what makes this atomic
            $pdo->prepare('INSERT INTO processed_events (event_id) VALUES (?)')
                ->execute([$eventId]);

            // Lock the wallet row for the rest of the transaction
            $row = $pdo->prepare('SELECT balance FROM wallets WHERE user_id = ? FOR UPDATE');
            $row->execute([$userId]);

            $pdo->prepare('UPDATE wallets SET balance = balance + ? WHERE user_id = ?')
                ->execute([$paise, $userId]);

            $pdo->commit();
            return 200;
        } catch (\PDOException $e) {
            $pdo->rollBack();

            if ($e->getCode() === '23000') {
                return 200;              // duplicate delivery: already done
            }
            if (($e->errorInfo[1] ?? 0) === 1213 && $attempt < 3) {
                usleep(random_int(20_000, 120_000));
                continue;                // deadlock: retry the whole transaction
            }
            throw $e;
        }
    }

    return 500;
}

Key Points

  • Check-then-act loses the race: two workers both see 'not processed'
  • A UNIQUE index is the only cross-server atomic primitive you already have
  • Catch SQLSTATE 23000 (MySQL 1062) and return 200 so retries stop
  • SELECT ... FOR UPDATE serialises; Redis SET NX PX locks across services
  • MySQL 1213 deadlock means retry the transaction, not the statement
💡 Pro Tip: Return 200 for a duplicate, not 409. A gateway that sees a non-2xx keeps retrying, and you end up debugging a retry storm you created yourself.
Q43

What do PHP 8.4 property hooks, asymmetric visibility and lazy objects change about how you write a model class?

AdvancedLanguage Evolution

Answer

These three features together remove most of the reasons a PHP class used to need boilerplate or magic. Property hooks let a declared property define get and set behaviour inline, so a computed or validated value is still a real property with a real type: no getter method, no __get, and the hook is visible to reflection, IDEs and static analysis in a way magic never was. A get hook can be an expression or a block, a set hook receives the assigned value and can transform or reject it, and a hook declared on an interface makes the property part of the contract, which was impossible before.

Asymmetric visibility separates read and write access, so public private(set) int $hits gives you a property everyone can read and only the class can change, which is what readonly was being abused to approximate. Unlike readonly it still permits internal mutation, so counters and state machines work. Lazy objects give the engine native support for deferred initialisation: ReflectionClass::newLazyProxy() or newLazyGhost() hands back an instance that looks and type-checks exactly like the real one but does not run the initialiser until a property or method is touched, which is what Doctrine and Symfony previously needed generated proxy classes for.

The practical consequence for a model class is that you can drop the getter wall, express invariants where the data lives, and let an ORM hydrate without violating your encapsulation. PHP 8.5 continued in the same direction with clone with for producing a modified copy without a wither method, the pipe operator for chaining transformations left to right, #[\NoDiscard] to flag return values that must not be ignored, array_first() and array_last(), and fatal error backtraces so an out-of-memory kill finally tells you where it happened.

<?php
declare(strict_types=1);

final class Candidate
{
    // PHP 8.4: asymmetric visibility, public read and private write
    public private(set) int $applicationCount = 0;

    // PHP 8.4: property hooks. A real typed property, not a magic method.
    public string $email {
        set (string $value) {
            $clean = strtolower(trim($value));
            if (!filter_var($clean, FILTER_VALIDATE_EMAIL)) {
                throw new InvalidArgumentException('bad email');
            }
            $this->email = $clean;
        }
    }

    public string $initial {
        get => strtoupper($this->name[0]);
    }

    public function __construct(public string $name, string $email)
    {
        $this->email = $email;   // runs the set hook
    }

    public function apply(): void { $this->applicationCount++; }
}

$c = new Candidate('Saksham Sandhu', '  SAK@GoodSpace.AI ');
echo $c->email;      // sak@goodspace.ai
echo $c->initial;    // S
$c->applicationCount = 5;   // Error: cannot modify private(set) property

// PHP 8.4 lazy objects replace generated ORM proxy classes
$ghost = (new ReflectionClass(Candidate::class))
    ->newLazyGhost(static fn (Candidate $o) => $o->__construct('Riya', 'r@x.in'));

Key Points

  • Property hooks give get and set behaviour on a real, typed, reflectable property
  • Hooks can be declared on interfaces, so properties become part of a contract
  • public private(set) expresses public-read private-write without readonly
  • newLazyGhost and newLazyProxy replace generated ORM proxy classes
  • PHP 8.5 added clone with, the pipe operator, #[\NoDiscard] and array_first()
Q44

Production is returning intermittent 502s from PHP-FPM under load. Walk through your diagnosis.

AdvancedProduction Debugging

Answer

Read the nginx error log first, because 502 is nginx's report of what happened between it and FPM, and the message names the failure mode. 'connect() to unix:/run/php/php8.4-fpm.sock failed (11: Resource temporarily unavailable)' means the listen backlog is full: every worker is busy and the queue overflowed, so this is worker exhaustion. 'recv() failed (104: Connection reset by peer)' or 'child exited on signal 11' in the FPM log means the worker died mid-request, which is a segfault or an OOM kill, not a capacity problem. 'upstream sent too big header while reading response header' is neither, it is fastcgi_buffer_size being smaller than your response headers, which happens when a session or a stack of Set-Cookie headers grows. And a 504 rather than a 502 means fastcgi_read_timeout fired, which should always be set higher than request_terminate_timeout so PHP kills its own request first and you get a backtrace. Next, get numbers instead of guesses: enable pm.status_path and read active processes, listen queue and max listen queue, because a non-zero max listen queue is proof of exhaustion.

Then ask why the workers are busy. In a blocking model the usual answer is that something downstream got slow, so check request_slowlog_timeout output, which gives you a real PHP backtrace for every slow request, and look for a database or third-party call at the top. Check the FPM log for 'Allowed memory size of ... exhausted', which points at memory_limit, and remember pm.max_children multiplied by memory_limit must fit in RAM or the OOM killer produces the same symptom for a different reason. Raising pm.max_children without fixing the slow dependency just moves the queue and starts swapping.

# 1. What does nginx actually say?
tail -f /var/log/nginx/error.log
#   "Resource temporarily unavailable" -> workers exhausted, backlog full
#   "upstream sent too big header"     -> raise fastcgi_buffer_size
#   "upstream timed out"               -> 504, fastcgi_read_timeout fired

# 2. Did workers die, or were they just busy?
grep -E 'exited on signal|memory size|max_children' /var/log/php8.4-fpm.log
#   "server reached pm.max_children setting" is the smoking gun

# 3. Live pool numbers
curl -s 'http://127.0.0.1/fpm-status?full' | grep -E 'active|listen queue|slow requests'

# 4. Why are they busy? Free PHP backtraces for slow requests.
# pool.d/www.conf
slowlog = /var/log/php-fpm-slow.log
request_slowlog_timeout = 3s
request_terminate_timeout = 30s      ; must be BELOW fastcgi_read_timeout
catch_workers_output = yes

# nginx
# fastcgi_read_timeout 35s;
# fastcgi_buffer_size 32k;
# fastcgi_buffers 8 16k;

# 5. Capacity check before touching pm.max_children
#    pm.max_children * memory_limit  must fit in RAM
#    free -m ; ps -o rss= -C php-fpm | awk '{s+=$1} END {print s/1024 " MB"}'

Key Points

  • The nginx error text distinguishes exhaustion, crashes and header overflow
  • 'reached pm.max_children' in the FPM log confirms worker starvation
  • pm.status_path gives active processes and max listen queue
  • request_terminate_timeout must be lower than fastcgi_read_timeout
  • Raising max_children without fixing the slow dependency causes swapping
💡 Pro Tip: Nine out of ten FPM exhaustion incidents trace back to one slow outbound dependency. Find it in the slowlog before you touch a single pool setting.
Q45

How would you plan a PHP 7.4 to 8.4 upgrade for a large legacy application without a big-bang rewrite?

AdvancedMigration

Answer

Sequence it, because the breaks are spread across five releases and land in different layers. First get visibility: run the test suite on 7.4 with error_reporting set to E_ALL and an error handler that fails the build on deprecations, and run PHPCompatibility through PHP_CodeSniffer with the target set to 8.4. That gives you a real list instead of a guess.

Then work version by version. PHP 8.0 is the largest jump: saner string to number comparisons flip results for in_array and switch, most internal function warnings became TypeError or ValueError, the curl, gd and similar resources became objects so every is_resource() check silently fails, and #[ starts a comment no longer. PHP 8.1 made passing null to non-nullable internal parameters a deprecation, which hits code doing htmlspecialchars($maybeNull) or strlen(null) everywhere, and added tentative return types that require #[\ReturnTypeWillChange] on classes implementing internal interfaces such as ArrayAccess.

PHP 8.2 deprecated dynamic properties, which breaks any model that assigns undeclared fields, and deprecated ${var} interpolation. PHP 8.4 deprecated implicit nullable parameters, so function f(string $s = null) must become ?string. Rector automates most of this: point it at the level set for each target version, run one version at a time, review the diff, ship, repeat.

Do not run all five sets at once. Alongside the code, plan the dependency wall, because Composer will block the PHP platform bump until every package supports it, and an abandoned package with no 8.x release is the usual reason an upgrade stalls; find those with composer why and budget for replacing them. Run both versions in production behind a traffic split before cutting over.

// rector.php: one target version per pull request, never all at once
use Rector\Config\RectorConfig;
use Rector\Set\ValueObject\LevelSetList;

return RectorConfig::configure()
    ->withPaths([__DIR__ . '/src', __DIR__ . '/tests'])
    ->withSets([LevelSetList::UP_TO_PHP_80])   // then 81, 82, 83, 84
    ->withImportNames();

# Inventory before you change anything
# vendor/bin/phpcs -p src --standard=PHPCompatibility --runtime-set testVersion 8.4
# composer why-not php 8.4          <- the dependency wall, package by package
# vendor/bin/rector process --dry-run

<?php
// 8.0: resources became objects, so this check silently fails
$ch = curl_init();
var_dump(is_resource($ch));          // false on 8.0+, true on 7.4
var_dump($ch instanceof \CurlHandle); // the 8.x check

// 8.1: null to a non-nullable internal parameter is deprecated
echo htmlspecialchars($row['bio'] ?? '');   // not htmlspecialchars($row['bio'])

// 8.2: dynamic properties are deprecated
#[\AllowDynamicProperties]
class LegacyModel {}

// 8.4: implicit nullable parameters are deprecated
function find(?string $slug = null): void {}   // was: string $slug = null

Key Points

  • Fail the build on deprecations under 7.4 first: that is your real backlog
  • 8.0 comparison changes plus resource-to-object breaks silent is_resource checks
  • 8.1 null-to-internal-parameter deprecations are the highest-volume fix
  • 8.2 dynamic properties and 8.4 implicit nullables need mechanical edits
  • Rector one LevelSetList per PR; the dependency wall is what stalls upgrades
💡 Pro Tip: Do the dependency audit before you write any code. An abandoned package with no PHP 8 release turns a two-week upgrade into a two-month one, and you want to know that in week one.

Companies Hiring PHP

Automattic
Info Edge (Naukri.com)
Shaadi.com
MakeMyTrip
BookMyShow
Zomato
TCS
Tech Mahindra

Salary Insights

Average in India
₹4-15 LPA

Frequently Asked Questions

What salary can a PHP developer expect in India in 2026?

The broad band is ₹4-15 LPA, and where you sit inside it depends more on the stack around PHP than on PHP itself. Freshers in service companies and agencies typically start at ₹3-5 LPA, and WordPress-only or CodeIgniter maintenance roles tend to stay in that lower half. Two to five years with strong Laravel or Symfony, real MySQL tuning and queue experience usually lands ₹8-15 LPA at product companies. Senior and lead roles at firms running large PHP services, such as Info Edge, Shaadi.com or BookMyShow, go above the published band, and remote roles for global WordPress-ecosystem employers pay in a different currency altogether. The premium in 2026 goes to people who can talk about PHP-FPM capacity, OPcache, caching layers and a queue architecture, not to people who can only name framework features.

How long should I prepare for a PHP interview?

If you already write PHP daily, two to three weeks of focused evenings is enough: one week on language behaviour that changed in PHP 8 (comparison semantics, enums, match, readonly, property hooks), one week on PDO, transactions, sessions and caching, and a few days on PHP-FPM, OPcache and a production incident you can narrate end to end. If you are coming back to PHP after time in another language, budget six to eight weeks, because the version gap is the thing that catches people: PHP 8.4 code looks very different from PHP 7.x code and interviewers notice immediately. Freshers should plan two to three months and spend most of it building and deploying one real application rather than reading, because almost every question in this set has a better answer if you have actually operated something.

How do PHP interviews differ for freshers versus candidates with five years of experience?

Fresher rounds stay inside the language and the framework: type juggling, arrays, OOP basics, a short coding exercise, and enough Laravel or WordPress to show you have built something. Getting the syntax right and explaining your own project clearly is most of the bar. From roughly three years, the questions move outward. Interviewers stop asking what a trait is and start asking why a page got slow, how you found it, and what you changed. Expect PDO and transaction behaviour, N+1 queries, caching strategy, PHP-FPM pool sizing, OPcache configuration, a deploy that went wrong, and how you handle a webhook that fires twice. At five years plus you are also expected to have an opinion on testing discipline, static analysis adoption and a version upgrade you led. The technical ceiling is similar; the evidence required is completely different.

Is PHP still worth learning in 2026?

As a first-and-only language, it would not be the obvious choice today. As a skill that pays reliably and has far less competition per opening than JavaScript or Python, it is a genuinely good bet. An enormous amount of revenue-generating software runs on PHP and is not being rewritten, WordPress alone guarantees steady demand, and Laravel remains one of the most productive web frameworks in any language. The market is also less crowded at the senior end, because many strong engineers left PHP during the 5.x years and never looked at PHP 8, so someone who understands the JIT, fibers, worker runtimes and modern tooling stands out quickly. The realistic caution is that PHP roles concentrate in web applications and CMS work; if you want machine learning or data engineering, learn Python alongside it.

Should I learn plain PHP or go straight to Laravel?

Learn enough plain PHP to be dangerous, then learn Laravel, then come back to the language. Job postings are written for Laravel, so the framework is what gets you shortlisted, but interviews are lost on the language underneath it. The pattern is very consistent: a candidate can build a full Laravel CRUD application and then cannot explain what Eloquent does with __get, why the container behaves differently under Octane, what a database transaction actually locks, or why a queue worker's memory grows. Two to three months of Laravel after a month of core PHP is a reasonable plan. Symfony is worth knowing if you are targeting larger product engineering teams, since its compiled container and component design come up in senior interviews, and WordPress is a separate track with its own hiring pool and its own pay curve.

PHP or Node.js for a backend job in India?

They compete for different openings more often than they compete for the same one. Node roles cluster in startups, real-time features and teams that want one language across frontend and backend; PHP roles cluster in established product companies, marketplaces, agencies and anything CMS-adjacent. Node pays somewhat better at the junior end because it rides the JavaScript hiring wave, while experienced PHP engineers close most of that gap and face fewer applicants per role. Technically the interesting contrast is the execution model, and interviewers on both sides ask about it: Node is a single-threaded event loop where a blocking call stalls everything, PHP-FPM is shared-nothing with one process per request where a blocking call only costs one worker. Knowing why that difference exists, and what PHP fibers and worker runtimes change about it, answers the question well from either direction.

Introduction

PHP powers a very large share of production web traffic in 2026, and the language people interview on today has little in common with the PHP 5 codebases that gave it a bad reputation. PHP 8.0 brought the JIT, union types, named arguments, attributes and match expressions; 8.1 added enums, readonly properties and fibers; 8.4 added property hooks, asymmetric visibility and lazy objects. Underneath all of it sits the same shared-nothing execution model: every request starts with a clean symbol table, runs to completion inside a PHP-FPM worker, and throws its memory away at the end. Almost every interesting PHP interview question eventually traces back to that model.

Indian hiring for PHP splits into three markets. Product companies such as Info Edge, Shaadi.com and BookMyShow run large in-house PHP services and interview hard on PDO, transactions, caching and PHP-FPM tuning. Services and agency firms hire around Laravel, WordPress and Magento, where questions lean toward framework internals, queue workers and package design. Automattic and other distributed WordPress-ecosystem employers recruit from India for core PHP work at global pay bands. Whichever track you are on, interviewers in 2026 assume you already know strict types, Composer, PHPUnit and PHPStan, and they use production failure stories to separate people who have shipped from people who have only read.

Below are 45 questions drawn from real PHP screening rounds and onsite loops, ordered from fundamentals up to the topics that decide senior offers. Eighteen are basic, eighteen intermediate, nine advanced. Most carry a runnable code example, and every answer flags the production behaviour that catches people out: emulated prepared statements quietly ignoring your parameter types, opcache.validate_timestamps serving stale code after a deploy, post_max_size emptying $_POST with no error, static state surviving between requests under worker-mode runtimes. Work the basic block until the answers feel automatic, then spend the rest of your preparation on the memory, concurrency and upgrade questions at the end.

Ready to practice PHP interviews?

Don't just read, practice these PHP questions live with an AI interviewer that asks follow-ups and scores your answers.

AI-powered practice
Instant feedback
Free to start
Start Free Mock Interview