PHP Coding
Core PHP coding and the standard library — the array and string functions, closures and arrow functions, named arguments and first-class callables, JSON versus serialization, multibyte text, dependency injection, and streaming large files.
27 questions
JuniorTheoryVery commonHow do array_map, array_filter and array_reduce differ in what they return?
How do array_map, array_filter and array_reduce differ in what they return?
array_map applies a callback to every value and returns an array of the same size, keeping the keys when one array is passed. array_filter keeps the values whose callback result is truthy and preserves the original keys, so it leaves gaps. array_reduce folds the whole array into one accumulated value.
Common mistakes
- ✗Expecting
array_filterto reindex, then hitting gaps when the result is JSON-encoded - ✗Assuming
array_mapgives the callback the key as a second argument - ✗Forgetting that
array_filterwithout a callback drops every falsy value
Follow-up questions
- →Why does a filtered array serialise to a JSON object instead of an array?
- →What happens to the keys when
array_mapis given two arrays at once?
JuniorTheoryVery commonHow does array_slice differ from array_splice in what it touches and returns?
How does array_slice differ from array_splice in what it touches and returns?
array_slice is non-destructive: it leaves the source untouched and returns the extracted portion. array_splice takes the array by reference, removes the selected range from it in place, optionally substitutes a replacement, and returns the elements it removed. Splice always renumbers the integer keys.
Common mistakes
- ✗Calling
array_spliceexpecting a copy, and losing the elements it cut out of the source - ✗Assuming
array_slicealso renumbers string keys, when it preserves them - ✗Forgetting that
array_splicetakes its first argument by reference
Follow-up questions
- →What does the
preserve_keysargument ofarray_slicechange? - →How would you insert elements in the middle of an array without removing any?
JuniorCodeVery commonCounter closure with by-reference capture
Counter closure with by-reference capture
Return an anonymous function that captures $in by reference via use (&$in). By-reference capture lets the closure mutate the same variable across calls, so the running total persists. Without &, each call would start from the original $in because the value is copied at definition time.
Common mistakes
- ✗Capturing by value (
use ($in)) and expecting the total to persist between calls - ✗Believing PHP closures auto-bind the outer scope like JavaScript, without an explicit
use - ✗Confusing a by-reference
use (&$in)with astaticvariable inside the closure
Follow-up questions
- →How does
use ($in)differ fromuse (&$in)at the moment the closure is defined? - →What does the arrow-function syntax
fn() =>capture automatically, and by value or reference?
JuniorTheoryVery commonWhat does a class receive through its constructor under dependency injection?
What does a class receive through its constructor under dependency injection?
It receives its collaborators already built. Instead of calling new PdoUserRepository() inside itself, the class declares a UserRepository parameter and is handed an instance by whoever constructs it. The dependency becomes visible in the signature, and a test can pass a fake in place of the real thing without touching the class.
Common mistakes
- ✗Calling
newon a collaborator inside the class, hiding the dependency from its signature - ✗Confusing injection with pulling the object out of a global container inside the method
- ✗Type-hinting a concrete implementation where the interface would do
Follow-up questions
- →Why does injecting an interface rather than a concrete class matter for testing?
- →What builds and passes the collaborators in a framework application?
MiddleTheoryVery commonHow do array_merge and the + union operator differ on duplicate keys?
How do array_merge and the + union operator differ on duplicate keys?
array_merge renumbers integer keys, so entries are appended rather than overwritten, and for a duplicate string key the later argument wins. The + operator never renumbers and never overwrites: for any key already present in the left operand, the left value is kept. So + favours the first array, array_merge the last.
Common mistakes
- ✗Using
+to merge two lists and silently keeping only the longer one's tail - ✗Expecting
array_mergeto keep the integer keys instead of renumbering them - ✗Applying defaults with
array_mergewhen+was needed to let the caller's values win
Follow-up questions
- →Which of the two would you use to apply a defaults array under user-supplied options?
- →What does
array_mergedo when the same string key appears in three arguments?
JuniorTheoryCommonWhy prefer array_key_first over reset to get an array's first key?
Why prefer array_key_first over reset to get an array's first key?
reset and end were built for the internal array pointer: they move it and return the first or last value, never the key. array_key_first and array_key_last return the key itself, leave the pointer where it was, and hand back null on an empty array instead of false, so no side effect leaks into surrounding code.
Common mistakes
- ✗Reading the value returned by
resetas if it were the array's first key - ✗Not noticing that
resetandendmove the internal pointer as a side effect - ✗Testing the result against
falsewhen an empty array yieldsnull
Follow-up questions
- →What does
array_key_firstreturn for an empty array, and how do you test for it? - →Where does the internal array pointer still matter in modern PHP code?
JuniorTheoryCommonWhat do array_keys and array_values return, and how do their results differ?
What do array_keys and array_values return, and how do their results differ?
array_keys returns a list of the array's keys; array_values returns a list of its values. Both drop the original key-to-value association and hand back a fresh, zero-indexed list, which is why array_values is the idiomatic way to close the gaps array_filter leaves behind.
Common mistakes
- ✗Expecting
array_keysto preserve the original keys instead of returning a zero-indexed list - ✗Believing
array_valuessorts the values, when it only renumbers the keys - ✗Collecting keys by hand in a
foreachloop thatarray_keysalready returns
Follow-up questions
- →What does the optional second argument of
array_keysdo? - →Why does a gapped array encode as a JSON object rather than a JSON array?
JuniorTheoryCommonWhen would you reach for array_walk rather than array_map?
When would you reach for array_walk rather than array_map?
array_map is a transformation: it feeds each value to the callback and builds a brand-new array from the return values. array_walk is a side-effecting visit: it passes each element and its key to the callback and returns only a boolean, so it modifies the original array in place, and only when the value parameter is taken by reference.
Common mistakes
- ✗Expecting
array_walkto return the modified array rather than a boolean - ✗Modifying the value inside an
array_walkcallback without taking it by reference - ✗Assuming
array_mapgives the callback the key as a second argument, asarray_walkdoes
Follow-up questions
- →How would you get both the key and the value inside an
array_mapcallback? - →What does the third argument of
array_walkpass to the callback?
JuniorTheoryCommonWhat do named arguments change about calling a function in PHP 8?
What do named arguments change about calling a function in PHP 8?
A named argument is passed as name: value, so the caller identifies a parameter by its declared name instead of its position. That lets you skip every optional parameter you do not care about and set only the one you need, and it makes a call with several booleans readable. Named arguments must follow any positional ones.
Common mistakes
- ✗Putting a positional argument after a named one, which is a compile-time error
- ✗Assuming the parameter name is private, when naming it makes it part of the public API
- ✗Believing a named argument may repeat a parameter already passed positionally
Follow-up questions
- →Why does renaming a parameter become a breaking change once callers use named arguments?
- →How do named arguments interact with unpacking an array that has string keys?
JuniorTheoryCommonWhat is the difference between sort, asort and ksort on an associative array?
What is the difference between sort, asort and ksort on an associative array?
All three sort the array in place, by reference, and return a boolean rather than a new array. sort orders by value and throws the keys away, renumbering from zero. asort orders by value but keeps each key attached to its value. ksort orders by key instead, leaving the values with their keys.
Common mistakes
- ✗Writing
$sorted = sort($arr)and gettingtrueinstead of the sorted array - ✗Reaching for
sorton an associative array and silently losing every key - ✗Confusing
asortwithksort, sorting by value when the key was meant
Follow-up questions
- →Which function would you use to sort by value with your own comparison rule?
- →What does the
SORT_*flags argument change about how values are compared?
JuniorTheoryCommonWhat does ... do in a call, in an array literal, and in a signature?
What does ... do in a call, in an array literal, and in a signature?
In a call f(...$args) unpacks an array into separate arguments; in a literal [...$a, ...$b] splices arrays together. In a signature it does the opposite — function f(...$rest) collects the remaining arguments into an array. Since PHP 8.1 unpacking also accepts string keys, which behave like named arguments.
Common mistakes
- ✗Reading
...in a signature as unpacking rather than as collecting the rest - ✗Assuming unpacking an array with string keys is always a fatal error
- ✗Expecting
[...$a, ...$b]to preserve the original integer keys
Follow-up questions
- →What happens to integer keys when you unpack two arrays into one literal?
- →How does unpacking with string keys relate to named arguments in a call?
JuniorTheoryCommonWhat did str_contains, str_starts_with and str_ends_with replace, and why?
What did str_contains, str_starts_with and str_ends_with replace, and why?
They replaced the error-prone strpos idioms. Because strpos returns the position of a hit and false when there is none, a match at offset 0 is falsy, so the check had to be written strpos($h, $n) !== false and a prefix test needed === 0. The three PHP 8 functions return a plain boolean and say what they mean.
Common mistakes
- ✗Writing
if (strpos($h, $n)), which silently fails on a match at offset 0 - ✗Assuming the three functions are multibyte-aware rather than byte-based
- ✗Expecting them to ignore case, when all three compare case-sensitively
Follow-up questions
- →Why is a match at offset 0 the bug that the
strposidiom kept producing? - →What would you use to test for a case-insensitive substring?
MiddleTheoryCommonHow does an arrow function fn differ from an anonymous function in what it captures?
How does an arrow function fn differ from an anonymous function in what it captures?
An anonymous function captures nothing implicitly — every outer variable must be listed in a use clause, and it is captured by value at the moment the closure is created. An arrow function captures automatically, by value, every outer variable its single expression actually mentions, so it needs no use clause and cannot hold a multi-statement body.
Common mistakes
- ✗Expecting an arrow function to capture by reference and see later changes to the variable
- ✗Trying to give an arrow function a multi-statement body with braces
- ✗Forgetting the
useclause on an anonymous function and reading an undefined variable
Follow-up questions
- →How would you capture a variable by reference in an anonymous function, and when is that right?
- →Why does an arrow function still see
$thiswithout any explicit binding?
JuniorTheoryOccasionalHow does an indexed PHP array differ from an associative one under the hood?
How does an indexed PHP array differ from an associative one under the hood?
There is only one array type: an ordered hash map whose keys are ints or strings. An indexed array is simply one whose keys happen to be 0..n-1, which array_is_list() checks. Numeric-string keys are cast to int, so $a['8'] and $a[8] are one slot, and order follows insertion.
Common mistakes
- ✗Thinking indexed and associative arrays are two different data structures
- ✗Expecting
$a['8']and$a[8]to be two separate keys - ✗Assuming
foreachyields keys in sorted order rather than insertion order
Follow-up questions
- →What does
array_is_list()check, and when does a filtered array stop being a list? - →Which key types are cast on assignment, and what happens to a
trueornullkey?
JuniorCodeOccasionalTitle-case a string with Unicode support
Title-case a string with Unicode support
Use mb_convert_case($string, MB_CASE_TITLE). The multibyte mb_* family is UTF-8 aware, so it uppercases the first letter of each word correctly for Cyrillic. The non-multibyte ucwords treats each byte as a character and corrupts multibyte letters, producing mojibake on привет.
Common mistakes
- ✗Reaching for
ucwords/ucfirst, which operate on single bytes and break UTF-8 - ✗Assuming UTF-8 source encoding makes byte-based string functions Unicode-safe
- ✗Forgetting to set or pass the encoding, leaving
mb_*to guess the wrong charset
Follow-up questions
- →Why does
strlen('привет')return 12 rather than 6, and which function returns 6? - →How do you make
mb_*functions assume UTF-8 without passing the encoding each call?
JuniorTheoryOccasionalHow do preg_match and preg_match_all differ in what they return and fill in?
How do preg_match and preg_match_all differ in what they return and fill in?
preg_match stops at the first match: it returns 1, 0 or false on error, and fills $matches with one flat array where index 0 is the whole match and the rest are the capture groups. preg_match_all scans the entire subject, returns how many matches it found, and fills $matches with a two-dimensional array grouped by capture group.
Common mistakes
- ✗Reading
preg_matchas if it collected every match rather than only the first - ✗Treating the return value of
preg_matchas a boolean and missing thefalseerror case - ✗Indexing
preg_match_allresults as if$matcheswere flat, forgetting the outer group level
Follow-up questions
- →What does the
PREG_SET_ORDERflag change about the shape of$matches? - →How do you tell a failed match apart from an error in
preg_match?
MiddleCodeOccasionalCache-aside caching in getByIds
Cache-aside caching in getByIds
Apply the cache-aside (read-through) pattern. Read the cache for $ids, compute the misses with array_diff($ids, array_keys($cached)), return early if none, otherwise fetch the missing ids from the client, write them back to the cache, and return array_merge($cached, $fetched). The injected Cache and Client make the method testable.
Common mistakes
- ✗Diffing ids against cached values instead of
array_keys($cached), so hits are never excluded - ✗Overwriting the cache with only the fetched items, dropping previously cached entries
- ✗Fetching the full id list on every call, defeating the point of the cache
Follow-up questions
- →How would you record cache hit/miss metrics without changing the method's return value?
- →What changes if the cache returns a partial set keyed by id versus a flat list?
MiddleTheoryOccasionalWhy is constructor injection preferred over pulling services from a locator?
Why is constructor injection preferred over pulling services from a locator?
Constructor injection makes every dependency explicit in the signature, so the class cannot be built in an invalid state and a reader sees its collaborators at a glance. A service locator hides them inside method bodies: the class now depends on the container itself, a test must configure that global, and a missing binding surfaces only at the moment of the call.
Common mistakes
- ✗Injecting the container itself and calling it a form of dependency injection
- ✗Believing a locator is testable because the container can be swapped globally
- ✗Discovering a missing binding only when the method that resolves it finally runs
Follow-up questions
- →When is a service locator nonetheless the pragmatic choice in a legacy codebase?
- →How does constructor property promotion change the cost of injecting many dependencies?
MiddleTheoryOccasionalWhat does the first-class callable syntax strlen(...) produce?
What does the first-class callable syntax strlen(...) produce?
It produces a Closure object bound to that function or method, without calling it. The literal ... is not a spread here: it tells the compiler to build the closure at compile time, so a typo in the name is caught immediately, the scope and $this of a method are captured correctly, and no callable string or array is involved.
Common mistakes
- ✗Reading the
...as a spread of arguments rather than as a closure-creation marker - ✗Expecting a callable string, when the result is a real
Closureobject - ✗Assuming the function is invoked at that point rather than merely referenced
Follow-up questions
- →How does
$this->method(...)differ fromClosure::fromCallable([$this, 'method'])? - →Why is a typo in the name caught earlier here than with a callable string?
MiddleTheoryOccasionalWhen do you choose json_encode over serialize for storing a PHP value?
When do you choose json_encode over serialize for storing a PHP value?
JSON is an interchange format: any language can read it, but it carries no class information, so an object comes back as stdClass or an array. serialize is a PHP-only format that restores the original class and even its private properties. Prefer JSON almost always — unserialize on untrusted input is an object-injection vector.
Common mistakes
- ✗Calling
unserializeon data that came from a cookie or a request body - ✗Expecting
json_decodeto give back the original class rather thanstdClass - ✗Forgetting that
json_encodesilently skips an object's private properties
Follow-up questions
- →What does implementing
JsonSerializablechange about how an object is encoded? - →Why is
unserializeon user input an object-injection risk, and what mitigates it?
MiddleCodeOccasionalCounting errors in a large log within a time budget
Counting errors in a large log within a time budget
Stream the file line by line with fgets in a loop, so memory stays constant regardless of file size. For each line explode(';', ...) and compare the field to $errorCode. Track elapsed time with microtime(true) and break once it exceeds 100 ms. Crucially fclose the handle (the reference snippet leaks it), and note the budget yields a partial count.
Common mistakes
- ✗Loading the whole file (
file,file_get_contents) instead of streaming, blowing memory - ✗Forgetting
fclose, leaking the file handle (the reference snippet has this flaw) - ✗Treating the time-budgeted partial count as a complete count of all matches
Follow-up questions
- →Why is
fgetsconstant-memory whilefile()is proportional to file size? - →How would you make the partial result honest — return a flag saying the scan was cut off?
MiddleTheoryOccasionalWhat does the PHP 8.5 URI extension give you that parse_url does not?
What does the PHP 8.5 URI extension give you that parse_url does not?
parse_url splits a string into an array of parts on a best-effort basis: it neither validates nor normalises, and it happily accepts a malformed URL. The always-on uri extension gives real objects that parse against a specification — Uri\Rfc3986\Uri for RFC 3986 and Uri\WhatWg\Url for the WHATWG rules — and reject invalid input.
Common mistakes
- ✗Trusting
parse_urlto reject a malformed URL, when it neither validates nor normalises - ✗Reaching for a single
Uri\Uriclass instead of the RFC 3986 and WHATWG ones - ✗Assuming the extension must be installed separately, when it is always available
Follow-up questions
- →Why do RFC 3986 and the WHATWG URL standard need two separate parsers?
- →What would you check before feeding a user-supplied URL to an HTTP client?
MiddleTheoryOccasionalWhat goes wrong when strlen and substr are used on UTF-8 text?
What goes wrong when strlen and substr are used on UTF-8 text?
A PHP string is a byte array, so strlen counts bytes, not characters, and a Cyrillic word reports roughly twice its visible length. Worse, substr cuts at a byte offset, so it can slice a multi-byte character in half and emit invalid UTF-8. The mb_* functions from mbstring operate on characters in a declared encoding.
Common mistakes
- ✗Reading
strlenas a character count and truncating text at the wrong place - ✗Cutting with
substrand emitting a broken half-character at the boundary - ✗Assuming
strtoupperuppercases accented letters the waymb_strtoupperdoes
Follow-up questions
- →Which encoding do the
mb_*functions assume when you do not pass one? - →How would you truncate a UTF-8 string to N characters without breaking one?
JuniorTheoryRareWhen is explode enough to split a string, and when do you need preg_split?
When is explode enough to split a string, and when do you need preg_split?
explode splits on one fixed literal separator, with no pattern matching at all — it is the right tool for a CSV line or a colon-separated field, and it is the faster of the two. preg_split splits on a regular expression, so reach for it only when the separator varies: any run of whitespace, or a comma that may be followed by spaces.
Common mistakes
- ✗Passing a regex pattern to
explode, which treats it as a literal separator - ✗Using
preg_spliton a fixed separator and paying for the regex engine needlessly - ✗Forgetting that
explodewith an empty separator throws aValueError
Follow-up questions
- →What does the
limitargument do inexplode, and what does a negative limit mean? - →How would you drop the empty pieces that a split can produce?
MiddleTheoryRareWhat does the pipe operator |> in PHP 8.5 require of its right-hand side?
What does the pipe operator |> in PHP 8.5 require of its right-hand side?
The right-hand side must evaluate to a callable that takes exactly one parameter; the left-hand value is passed into it and the result becomes the value of the expression. So $x |> trim(...) |> strtolower(...) chains without nesting. An arrow function on the right must be wrapped in parentheses, and a callable needing two arguments will not fit.
Common mistakes
- ✗Writing a bare arrow function on the right without wrapping it in parentheses
- ✗Piping into a callable that needs more than one argument
- ✗Reading
|>as a bitwise operator rather than as function application
Follow-up questions
- →How would you pipe into a function whose piped value is not the first parameter?
- →What does
|>change about readability compared with deeply nested calls?
SeniorDesignRareYour team has just moved a large PHP application onto 8.5, and a developer has opened a proposal to adopt the new pipe operator across the codebase, rewriting the deeply nested transformation calls in the import and reporting layers as pipe chains. Some of the team find the chains far more readable; others worry about churn, about reviewers who do not know the syntax, and about what happens when a step needs more than one argument. You have to give the team a decision. Explain how you would evaluate the proposal, what you would let the operator be used for and what you would not, how the change would be rolled out across an existing codebase, and how you would know afterwards whether it was worth it.
Your team has just moved a large PHP application onto 8.5, and a developer has opened a proposal to adopt the new pipe operator across the codebase, rewriting the deeply nested transformation calls in the import and reporting layers as pipe chains. Some of the team find the chains far more readable; others worry about churn, about reviewers who do not know the syntax, and about what happens when a step needs more than one argument. You have to give the team a decision. Explain how you would evaluate the proposal, what you would let the operator be used for and what you would not, how the change would be rolled out across an existing codebase, and how you would know afterwards whether it was worth it.
Judge it on readability at the call site, not novelty. The operator only fits a linear chain of single-argument callables, so allow it exactly there — inside transformation pipelines — and forbid it where a step needs a second argument or a branch, since wrapping those in closures is worse than the nesting it replaced. Adopt it in new code first, never as a sweeping rewrite.
Common mistakes
- ✗Rewriting a working codebase wholesale for a syntax change that adds no behaviour
- ✗Forcing a two-argument step into a chain by wrapping it in an awkward closure
- ✗Treating the operator as an optimisation rather than a readability choice
Follow-up questions
- →What does a step that needs a second argument force you to write, and is it still an improvement?
- →How would you keep a mixed codebase coherent while only new code uses the operator?
SeniorDesignRareAn endpoint in your PHP application accepts file uploads — customers send CSV exports that are routinely several gigabytes, and the uploaded file has to end up in object storage, with each row also validated before the response is returned. The current implementation reads the whole upload into a string, validates it, and then hands that string to the storage client; under real files it exhausts the memory limit and the worker dies. Describe how you would redesign the upload path so that memory use stays flat no matter how large the file is: where the bytes live while the request is in flight, how you would read and validate them, how they reach object storage, and what limits and failure cases you would handle.
An endpoint in your PHP application accepts file uploads — customers send CSV exports that are routinely several gigabytes, and the uploaded file has to end up in object storage, with each row also validated before the response is returned. The current implementation reads the whole upload into a string, validates it, and then hands that string to the storage client; under real files it exhausts the memory limit and the worker dies. Describe how you would redesign the upload path so that memory use stays flat no matter how large the file is: where the bytes live while the request is in flight, how you would read and validate them, how they reach object storage, and what limits and failure cases you would handle.
Never materialise the body. PHP already spools the upload to a temp file, so open a stream over it, read row by row with fgetcsv, validate as you go, and hand the storage client the stream handle rather than a string, so memory stays at one row plus a buffer. Cap the size in both the web server and PHP, and clean the temp file up on failure.
Common mistakes
- ✗Reading the request body into a string before doing anything else with it
- ✗Raising
memory_limitinstead of removing the whole-file buffer - ✗Passing a string to the storage client where a stream handle would do
Follow-up questions
- →Where does PHP put an uploaded file before your code ever sees it?
- →How would you enforce a maximum upload size before the bytes are even accepted?