PHP · Interview
PHP interview questions
Twelve questions that cover what PHP interviews actually probe: string quoting, the PHP 8 comparison rules, the === habit, array functions that keep or drop keys, references in foreach, strict types, generators, input validation, immutable objects and migrating legacy code. For the output and debugging questions, predict the result first and only then run the code; the explanation under each answer says why PHP behaves the way it does.
ConceptJuniorWhat is the difference between single-quoted and double-quoted strings in PHP, and when would you choose each?
Answer
Double-quoted strings are processed: variables such as $name (and {$order['id']}) are interpolated, and escape sequences like \n, \t and \$ are translated. Single-quoted strings are literal: only \' and \\ are special, and $name prints as the five characters $name. Interpolation makes messages that include values readable, so double quotes are the natural choice there. Single quotes tell the reader that nothing inside is dynamic, which is why many style guides use them for plain literals such as array keys and function names. There is no meaningful performance difference in modern PHP; the choice is about intent and about whether you need escapes. One gotcha: interpolating an array element with a quoted key needs braces, "{$row['name']}", because "$row['name']" is a parse error, while the unquoted form "$row[name]" is allowed inside a string.
Predict the outputJuniorWhat does this program print under PHP 8, and why does each line come out that way?
<?php
var_dump(0 == "a");
var_dump("1" == "01");
var_dump("10" == "1e1");
var_dump(100 == "1e2");
var_dump("abc" == 0);
var_dump(null == false);
Answer
PHP 8.0 changed how == compares a number with a non-numeric string: 0 == "a" is now false because the number is converted to a string and the two are compared as text, whereas PHP 7 converted "a" to 0 and answered true. When both operands are numeric strings, or one is a number and the other a numeric string, the comparison is numeric: "1" == "01", "10" == "1e1" and 100 == "1e2" are all true because they denote the same number. "abc" == 0 is false for the same PHP 8 reason as the first line. null == false is true because both are falsy under loose comparison. The lesson an interviewer wants to hear: use === unless you specifically want type juggling, because == follows rules you have to memorise, and those rules changed in PHP 8.0.
It prints
bool(false) bool(true) bool(true) bool(true) bool(false) bool(true)
DebuggingJuniorThis check should print image path for a path that starts with /images, but it prints not an image path. What is wrong, and how do you fix it?
image path for a path that starts with /images, but it prints not an image path. What is wrong, and how do you fix it?The code with the bug
<?php
$path = "/images/logo.png";
if (strpos($path, "/images") == false) {
echo "not an image path\n";
} else {
echo "image path\n";
}
Answer
strpos() returns the position of the match, and here the match is at position 0. The loose comparison 0 == false is true, so the code takes the wrong branch. The fix is the strict comparison === false, which distinguishes the integer 0 from the boolean false. This is the classic reason that PHP functions returning a position or an index must be compared with === or !==. Since PHP 8.0 there are clearer tools for this exact intent: str_starts_with($path, '/images') and str_contains() return real booleans, so the ambiguity disappears and the code says what it means.
<?php
$path = "/images/logo.png";
if (strpos($path, "/images") === false) {
echo "not an image path\n";
} else {
echo "image path\n";
}
Output
image path
Predict the outputJuniorWhat does this print? Pay attention to the types shown by var_dump.
<?php
echo 7 / 2, "\n";
echo intdiv(7, 2), "\n";
echo 7 % 2, "\n";
echo -7 % 2, "\n";
echo 2 ** 10, "\n";
var_dump(10 / 5);
var_dump(10 / 4);
Answer
7 / 2 gives 3.5: the division operator returns a float whenever the result is not a whole number. intdiv(7, 2) is integer division and gives 3. 7 % 2 is 1, and -7 % 2 is -1 because the sign of % follows the left operand; use fmod() or adjust by hand when you need a non-negative remainder. 2 ** 10 is exponentiation, 1024. The var_dump lines show the subtle part: 10 / 5 returns int(2) because both operands are integers and the result is exact, while 10 / 4 returns float(2.5). Code that assumes / always yields a float, or always an int, is wrong in one direction or the other; declare the return type you want, or use intdiv() and fdiv() deliberately.
It prints
3.5 3 1 -1 1024 int(2) float(2.5)
Predict the outputJuniorWhat does this print, and what difference between ??, isset() and array_key_exists() does it demonstrate?
??, isset() and array_key_exists() does it demonstrate?<?php
$settings = ['theme' => 'dark', 'font' => null, 'size' => 0];
echo $settings['theme'] ?? 'light', "\n";
echo $settings['font'] ?? 'serif', "\n";
echo $settings['size'] ?? 12, "\n";
echo $settings['lang'] ?? 'en', "\n";
echo isset($settings['font']) ? 'set' : 'not set', "\n";
echo array_key_exists('font', $settings) ? 'exists' : 'missing', "\n";
Answer
?? returns the left side when it exists and is not null, otherwise the right side. theme exists, so dark. font exists but is null, so ?? treats it as missing and prints serif. size is 0, which is not null, so 0 is printed: ?? does not test truthiness, unlike ?:. lang is absent, so en, and no warning is raised because ?? checks quietly. isset($settings['font']) is false for a null value, which is why the fifth line says not set, whereas array_key_exists('font', $settings) looks only at the key and says exists. The distinction matters whenever null is a meaningful stored value, such as a setting that was deliberately cleared, and it is why ?? should not be used to decide whether a key was present in submitted data.
It prints
dark serif 0 en not set exists
Predict the outputMid-levelA developer expected the first line to be a JSON array. What does this actually print, and why?
<?php
$scores = [40, 75, 62, 90];
$passed = array_filter($scores, fn(int $s): bool => $s >= 60);
echo json_encode($passed), "\n";
echo json_encode(array_values($passed)), "\n";
echo count($passed), "\n";
Answer
array_filter() keeps the original keys of the elements it retains, so $passed is [1 => 75, 2 => 62, 3 => 90]. json_encode() only produces a JSON array for a list, meaning an array whose keys are exactly 0, 1, 2 and so on in order; anything else becomes a JSON object, hence {"1":75,"2":62,"3":90}. Wrapping the result in array_values() renumbers the keys and gives [75,62,90]. count() is 3 either way. This matters in APIs: filtering a list and returning it can silently change the JSON shape from array to object and break clients. The same key preservation applies to array_slice() with preserve_keys, and usort() reindexes while uasort() does not, so knowing which functions keep keys is part of everyday PHP. PHP 8.1 added array_is_list() to check the shape explicitly.
It prints
{"1":75,"2":62,"3":90}
[75,62,90]
3
DebuggingMid-levelAfter doubling the prices with a by-reference loop and then walking the array once more, the last price is wrong. Explain the bug and fix it.
The code with the bug
<?php
$prices = [10, 20, 30];
foreach ($prices as &$price) {
$price = $price * 2;
}
foreach ($prices as $price) {
// nothing to do here, just walking the list
}
echo implode(',', $prices), "\n";
Answer
The first loop binds $price as a reference to each element in turn, and when it ends $price is still a reference to the last element. The second loop assigns each element's value to $price, which now writes through the reference into $prices[2]: it becomes 20, then 40, then 40 again (its own current value), so the array ends as 20,40,40. The fix is unset($price) after a by-reference loop, which breaks the reference without touching the array. Interviewers ask this because it is one of the few PHP behaviours that corrupt data silently; the safer habit is to avoid by-reference foreach altogether and use array_map() or assign through the key, $prices[$i] = ....
<?php
$prices = [10, 20, 30];
foreach ($prices as &$price) {
$price = $price * 2;
}
unset($price);
foreach ($prices as $price) {
// nothing to do here, just walking the list
}
echo implode(',', $prices), "\n";
Output
20,40,60
ConceptMid-levelWhat does declare(strict_types=1); change, and what does it not change?
declare(strict_types=1); change, and what does it not change?Answer
By default PHP runs in coercive typing mode: when a function declares int $n and you pass the string "5", PHP converts it, and true arriving where an int count was expected becomes 1 without complaint. With declare(strict_types=1) as the first statement of a file, calls made from that file must pass exactly the declared scalar type, with one exception: an int is still accepted where a float is declared, because that widening is lossless. Return values declared in that file are checked the same way, and so are typed properties assigned from it. What it does not change: it only affects calls originating in the file that declares it, not calls into your functions from other files; it does not affect arithmetic, comparison or string interpolation, so "5" + 3 is still 8; and it does not make mixed or untyped parameters strict. The reason to use it is that a coerced value can hide a bug, and a TypeError at the call site points straight at the cause.
CodingMid-levelWrite a function that takes an iterable of log lines and yields only the lines that start with ERROR, together with their 1-based line numbers, without building a second array. Show it in use.
ERROR, together with their 1-based line numbers, without building a second array. Show it in use.Answer
A function that contains yield is a generator: calling it returns a Generator object and nothing runs until the caller iterates. Each yield hands one item out and pauses the function, so no filtered array is ever built and memory stays flat however long the log is. yield $key => $value lets the generator supply its own keys, here the 1-based line number. The source can itself be a generator that reads a file line by line, which is the point an interviewer listens for: lazy pipelines compose. str_starts_with() (PHP 8.0) makes the prefix test explicit and returns a real boolean. Two limits worth mentioning: a generator can be iterated only once, so a caller that needs the results twice must materialise them with iterator_to_array(), and that function overwrites entries with duplicate keys unless you pass false as its second argument.
<?php
function errorsOnly(iterable $lines): Generator
{
foreach ($lines as $index => $line) {
if (str_starts_with($line, 'ERROR')) {
yield $index + 1 => $line;
}
}
}
$log = [
'INFO worker started',
'ERROR disk quota exceeded on /var/spool',
'WARN retrying upload',
'ERROR upstream timed out after 30s',
'INFO worker stopped',
];
foreach (errorsOnly($log) as $lineNumber => $entry) {
echo "$lineNumber: $entry\n";
}
Output
2: ERROR disk quota exceeded on /var/spool 4: ERROR upstream timed out after 30s
ScenarioMid-levelA checkout form posts a quantity field. Describe how you validate it in PHP and get it safely into a database, and what you would never do.
quantity field. Describe how you validate it in PHP and get it safely into a database, and what you would never do.Answer
Treat $_POST['quantity'] as untrusted text. First check that it is present (isset or ??), then validate the shape with filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 100]]), which returns false for anything that is not an integer in range, including "3.0", "1e2" and "abc". Reject with a clear error rather than clamping silently, because a silently corrected order becomes a support ticket later. For the database, use a prepared statement (PDO::prepare with a bound parameter, or the mysqli equivalent) so the value travels separately from the SQL text; injection then becomes impossible by construction and the column type enforces the rest. Never concatenate the raw value into SQL, never rely on (int) alone as validation (it turns "abc" into 0, which may look like a legitimate quantity downstream), and never trust client-side checks. If you log the rejected value for investigation, escape it before it reaches any HTML page.
CodingSeniorDesign a small immutable Money value object in modern PHP: it must refuse negative amounts, refuse adding different currencies, support equality, and be impossible to modify after construction. Show the behaviour.
Answer
A readonly class (PHP 8.2) makes every property readonly, so each can be written exactly once, in the constructor, and any later assignment throws an Error, as the last block shows. Constructor property promotion (PHP 8.0) keeps the declaration short. The currency is a backed enum (PHP 8.1), which gives a closed set of values and makes !== a safe identity check. Amounts are integer cents, never floats, so add is exact. add returns a new object instead of mutating, which is what makes the type safe to share between parts of a program. declare(strict_types=1) stops a numeric string from being coerced into the int parameter. An interviewer listens for the reasons: immutability removes a whole class of aliasing bugs, and validation in the constructor means an instance can never exist in an invalid state. PHP 8.4 adds asymmetric visibility (public private(set)) for the related case where a property must be readable outside the class but writable only inside it.
<?php
declare(strict_types=1);
enum Currency: string
{
case EUR = 'EUR';
case GBP = 'GBP';
}
final readonly class Money
{
public function __construct(
public int $cents,
public Currency $currency,
) {
if ($cents < 0) {
throw new InvalidArgumentException('amount must not be negative');
}
}
public function add(Money $other): Money
{
if ($other->currency !== $this->currency) {
throw new LogicException('cannot add ' . $other->currency->value . ' to ' . $this->currency->value);
}
return new Money($this->cents + $other->cents, $this->currency);
}
public function equals(Money $other): bool
{
return $this->cents === $other->cents && $this->currency === $other->currency;
}
public function __toString(): string
{
return sprintf('%d.%02d %s', intdiv($this->cents, 100), $this->cents % 100, $this->currency->value);
}
}
$deposit = new Money(1250, Currency::EUR);
$fee = new Money(75, Currency::EUR);
$total = $deposit->add($fee);
echo $total, "\n";
echo $deposit, "\n";
var_dump($total->equals(new Money(1325, Currency::EUR)));
try {
$total->add(new Money(100, Currency::GBP));
} catch (LogicException $e) {
echo 'Rejected: ', $e->getMessage(), "\n";
}
try {
$total->cents = 0;
} catch (Error $e) {
echo 'Rejected: ', $e->getMessage(), "\n";
}
Output
13.25 EUR 12.50 EUR bool(true) Rejected: cannot add GBP to EUR Rejected: Cannot modify readonly property Money::$cents
ScenarioSeniorAfter upgrading a legacy application from PHP 7.4 to 8.x, the logs fill with Undefined array key and Undefined variable warnings, and a few requests now fail with TypeError. How do you approach the migration?
Undefined array key and Undefined variable warnings, and a few requests now fail with TypeError. How do you approach the migration?Answer
First understand what changed. In PHP 8.0 reading a missing array key or an undefined variable was promoted from a notice to a warning, many internal functions throw TypeError or ValueError on bad arguments instead of returning null or false, and == between a number and a non-numeric string now compares as strings. So the warnings are not new bugs; they are old assumptions the runtime finally reports. The approach: run the test suite and a static analyser (PHPStan or Psalm) against the new version to get a complete list rather than fixing from logs; group the warnings by file and fix the cause, not the symptom, using ?? for genuinely optional keys, array_key_exists where null is a legitimate value, and explicit defaults for variables set only in some branches. Do not suppress with @ or lower error_reporting, because that hides the next real bug. For the TypeErrors, trace what the callers actually pass; most are functions receiving null from a missing key, and PHP 8.1 additionally deprecates passing null to non-nullable parameters of internal functions, so fix them once, properly. Ship in stages: in the test environment install an error handler that converts warnings into exceptions, get to zero there, then deploy. Finally add declare(strict_types=1) file by file to lock the gains in.
How this page was checked. Every program on it was run with PHP 8.4 at build time by the publishing checks, and the output shown is what it printed. Running PHP inside the browser is not available yet, so the Run button is absent rather than pretending; copy the code and run it with PHP 8.4 locally.