TL;DR: PHP coding rounds test whether a candidate can pick the right built-in function, handle edge cases and state the time cost of a solution. These 30 challenges run from string basics to an LRU cache and a dependency container, and every solution below was run on PHP 8.5.11.
PHP 8.5 added the pipe operator, |>, which passes a value through a chain of functions from left to right. It shipped on November 20, 2025, and PHP 8.4 added property hooks a year earlier.
Both now appear in coding rounds, next to the array and string problems that interviewers have always used. Each challenge below has a task, a tested solution and a note on the trap it sets.
- 1PHP 8.2 gets security fixes only until December 31, 2026. After that, the oldest supported branch is 8.3.
- 2PHP arrays keep insertion order, which is why a PHP LRU cache needs no linked list.
- 3Since PHP 8.4, calling
fgetcsv()without the$escapeargument raises a deprecation notice. - 4
strrev()andstr_split()work on bytes, not characters, so they break UTF-8 text.
Beginner Challenges
1. Reverse a string that may contain accented letters.
Input 'héllo' should return 'olléh'.
function reverseString(string $s): string
{
return implode('', array_reverse(mb_str_split($s)));
}
strrev() reverses bytes, so it splits the two bytes of é and returns broken UTF-8. mb_str_split() splits by character first. The cost is O(n) time and memory.
strlen('héllo')is 6strrev(),str_split(),substr()- Safe for ASCII only
mb_strlen('héllo')is 5mb_str_split(),mb_substr(),mb_trim()(PHP 8.4)- Need the mbstring extension
2. Check whether a sentence is a palindrome, ignoring case and punctuation.
'A man, a plan, a canal: Panama' returns true; 'race a car' returns false.
function isPalindrome(string $s): bool
{
$clean = preg_replace('/[^a-z0-9]/', '', strtolower($s));
return $clean === strrev($clean);
}
Normalize first, then compare once. A candidate who strips only spaces will fail on the colon and commas. This version assumes ASCII text; say so, or use the u regex flag and the multibyte reverse from challenge 1.
3. Return the second-largest distinct number in an array, or null.
[4, 9, 9, 2] returns 4, not 9. [7, 7] returns null.
function secondLargest(array $nums): ?int
{
$unique = array_values(array_unique($nums));
if (count($unique) < 2) {
return null;
}
rsort($unique);
return $unique[1];
}
The trap is duplicates: sorting and taking index 1 returns 9. Sorting costs O(n log n). A single pass that tracks the top two values is O(n), and a strong candidate offers it as a follow-up.
4. Check whether two phrases are anagrams, ignoring spaces and case.
'Dormitory' and 'dirty room' are anagrams.
function isAnagram(string $a, string $b): bool
{
$norm = fn (string $s) => count_chars(str_replace(' ', '', strtolower($s)), 1);
return $norm($a) == $norm($b);
}
count_chars($s, 1) returns a byte-count map, and == on two arrays checks that they hold the same key and value pairs. That makes the check O(n) instead of the O(n log n) of sorting both strings.
5. Two sum: return the indexes of the two numbers that add up to a target.
[2, 7, 11, 15] with target 9 returns [0, 1].
function twoSum(array $nums, int $target): ?array
{
$seen = [];
foreach ($nums as $i => $n) {
if (isset($seen[$target - $n])) {
return [$seen[$target - $n], $i];
}
$seen[$n] = $i;
}
return null;
}
The nested-loop answer is O(n²). The hash map answer is O(n): for each number, look up the value it still needs. Checking before inserting is what makes [3, 3] with target 6 work.
6. Return the n most frequent words in a text, with their counts.
"the cat and the hat and the bat" with n = 2 returns ['the' => 3, 'and' => 2].
function topWords(string $text, int $n): array
{
$words = preg_split('/[^a-z\']+/', strtolower($text), -1, PREG_SPLIT_NO_EMPTY);
$counts = array_count_values($words);
arsort($counts);
return array_slice($counts, 0, $n, true);
}
Pass true as the last argument to array_slice() so integer keys survive.
It matters as soon as someone widens the pattern to allow digits, because a word such as "2026" becomes an integer key. arsort() is stable since PHP 8.0, so words with equal counts keep their first-seen order.
7. Sum the digits of an integer, including negative ones.
-4096 returns 19.
function sumDigits(int $n): int
{
return array_sum(str_split((string) abs($n)));
}
Without abs(), the minus sign becomes an element. PHP 8.5 skips it with the warning "Addition is not supported on type string", so the result looks right while the log fills with warnings.
The arithmetic version, repeated % 10 and intdiv(), avoids the string conversion and is a fair follow-up.
Intermediate Challenges
8. Find the first character that appears only once.
'swiss' returns 'w'. 'aabb' returns null.
function firstUnique(string $s): ?string
{
$chars = mb_str_split($s);
$counts = array_count_values($chars);
foreach ($chars as $c) {
if ($counts[$c] === 1) {
return $c;
}
}
return null;
}
Two passes, both O(n): count everything, then walk the original order. Walking the counts array instead also works here, because PHP arrays keep insertion order, but the candidate should say they rely on that.
9. Check that every bracket in a string is closed in the right order.
'{[()()]}' is balanced. '([)]', '((' and ')' are not.
function isBalanced(string $s): bool
{
$pairs = [')' => '(', ']' => '[', '}' => '{'];
$stack = [];
foreach (str_split($s) as $c) {
if (in_array($c, $pairs, true)) {
$stack[] = $c;
} elseif (isset($pairs[$c])) {
if (array_pop($stack) !== $pairs[$c]) {
return false;
}
}
}
return $stack === [];
}

A counter per bracket type fails on '([)]', which is why this needs a stack. array_pop() on an empty array returns null, so a stray closer fails the comparison without a separate check.
10. Flatten a nested array of any depth.
[1, [2, [3, [4]], 5]] returns [1, 2, 3, 4, 5].
function flatten(array $items): array
{
$out = [];
array_walk_recursive($items, function ($v) use (&$out) {
$out[] = $v;
});
return $out;
}
array_walk_recursive() visits only leaf values, so it handles any depth without hand-written recursion. The closure needs use (&$out) by reference; without the & it fills a copy and returns an empty array.
11. Group a list of words into sets of anagrams.
['eat', 'tea', 'tan', 'ate', 'nat', 'bat'] returns [['eat', 'tea', 'ate'], ['tan', 'nat'], ['bat']].
function groupAnagrams(array $words): array
{
$groups = [];
foreach ($words as $w) {
$key = str_split($w);
sort($key);
$groups[implode('', $key)][] = $w;
}
return array_values($groups);
}
The sorted letters are the group key. sort() works in place and returns true, so implode('', sort($key)) is a common bug. Cost: O(n · k log k) for n words of length k.
12. Merge overlapping intervals.
[[8, 10], [1, 3], [2, 6], [15, 18]] returns [[1, 6], [8, 10], [15, 18]].
function mergeIntervals(array $intervals): array
{
usort($intervals, fn ($a, $b) => $a[0] <=> $b[0]);
$merged = [];
foreach ($intervals as [$start, $end]) {
$last = array_key_last($merged);
if ($last !== null && $start <= $merged[$last][1]) {
$merged[$last][1] = max($merged[$last][1], $end);
} else {
$merged[] = [$start, $end];
}
}
return $merged;
}
Sort by start first; without the sort, [8, 10] is compared with the wrong neighbour. max() handles an interval that sits entirely inside another. Sorting makes it O(n log n).
13. Find the largest sum of any k consecutive numbers.
[2, 1, 5, 1, 3, 2] with k = 3 returns 9 (5 + 1 + 3).
function maxWindowSum(array $nums, int $k): ?int
{
if ($k <= 0 || count($nums) < $k) {
return null;
}
$window = array_sum(array_slice($nums, 0, $k));
$best = $window;
for ($i = $k; $i < count($nums); $i++) {
$window += $nums[$i] - $nums[$i - $k];
$best = max($best, $window);
}
return $best;
}
The sliding window adds the new element and subtracts the one that left, so it is O(n) rather than the O(n · k) of summing every window. Ask what happens when k is larger than the array; this version returns null.
14. Read a nested array value with a dot path and a default.
For ['db' => ['host' => 'localhost', 'port' => null]], 'db.host' returns 'localhost', 'db.user' with default 'root' returns 'root', and 'db.port' returns null, not the default.
function dataGet(array $data, string $path, mixed $default = null): mixed
{
foreach (explode('.', $path) as $key) {
if (!is_array($data) || !array_key_exists($key, $data)) {
return $default;
}
$data = $data[$key];
}
return $data;
}
The trap is isset(), which is false for a key that exists with a null value. array_key_exists() tells "missing" apart from "set to null". This is the idea behind Laravel's data_get() helper.
15. Group database rows by a column.
Rows for teams api, web, api become ['api' => [row1, row3], 'web' => [row2]].
function groupBy(array $rows, string $key): array
{
$out = [];
foreach ($rows as $row) {
$out[$row[$key]][] = $row;
}
return $out;
}
Appending with $out[$row[$key]][] = $row creates each group on first use, so no isset() check is needed. It is O(n). Pair it with array_column() when you need just one field from each group.
16. Convert snake_case to camelCase.
'created_at_utc' returns 'createdAtUtc'.
function snakeToCamel(string $s): string
{
return lcfirst(str_replace('_', '', ucwords($s, '_')));
}
ucwords() takes a second argument for word delimiters, which many candidates do not know. Without it they write a loop over explode('_', $s), which also works but is longer.
Advanced Challenges
17. Build an LRU cache with O(1) get and put.
With capacity 2: put a, put b, get a, put c. Now b has been evicted and a and c remain.
final class LruCache
{
private array $items = [];
public function __construct(private int $capacity) {}
public function get(string $key): mixed
{
if (!array_key_exists($key, $this->items)) {
return null;
}
$value = $this->items[$key];
unset($this->items[$key]);
$this->items[$key] = $value; // move to the end: most recent
return $value;
}
public function put(string $key, mixed $value): void
{
unset($this->items[$key]);
$this->items[$key] = $value;
if (count($this->items) > $this->capacity) {
unset($this->items[array_key_first($this->items)]);
}
}
}
PHP arrays are ordered hash maps, so unsetting a key and adding it again moves it to the end. array_key_first() then gives the least recently used key in O(1). In other languages this needs a hash map plus a doubly linked list.
18. Write a dependency container that autowires constructor arguments.
Resolving Svc, which needs a Logger, which needs a Clock, should build all three without any configuration. An interface needs an explicit binding.
final class Container
{
private array $bindings = [];
public function bind(string $abstract, callable $factory): void
{
$this->bindings[$abstract] = $factory;
}
public function make(string $class): object
{
if (isset($this->bindings[$class])) {
return ($this->bindings[$class])($this);
}
$ctor = (new ReflectionClass($class))->getConstructor();
if ($ctor === null) {
return new $class();
}
$args = [];
foreach ($ctor->getParameters() as $param) {
$type = $param->getType();
if (!$type instanceof ReflectionNamedType || $type->isBuiltin()) {
throw new LogicException("Cannot resolve \${$param->getName()} in $class");
}
$args[] = $this->make($type->getName());
}
return new $class(...$args);
}
}
Reflection reads each constructor parameter's type and resolves it recursively. Built-in types such as string cannot be guessed, so it throws. Ask the follow-up: how would you stop an infinite loop when two classes depend on each other?
19. Build a middleware pipeline from a list of callables.
Each middleware receives the request and a $next callable. With middleware A and B, the call order must be A in, B in, handler, B out, A out.
function pipeline(array $middleware, callable $handler): callable
{
return array_reduce(
array_reverse($middleware),
fn (callable $next, callable $mw) => fn ($request) => $mw($request, $next),
$handler
);
}

Reversing the list first is the key step: array_reduce() wraps from the inside out, so the last middleware must be wrapped first. This is how Laravel's pipeline and PSR-15 stacks work.
20. Read a large CSV file as associative arrays without loading it all into memory.
function readCsv(string $path): Generator
{
$fh = fopen($path, 'r');
try {
$header = fgetcsv($fh, escape: '');
while (($row = fgetcsv($fh, escape: '')) !== false) {
yield array_combine($header, $row);
}
} finally {
fclose($fh);
}
}
A generator yields one row at a time, so memory stays flat for a file of any size. The finally block closes the file even if the caller stops iterating early.
fgetcsv() without $escape now raises "the $escape parameter must be provided as its default value will change". Passing escape: '' turns off the old non-standard escape character, which is what RFC 4180 CSV expects.21. Implement a token-bucket rate limiter that you can test without sleeping.
Capacity 2 and one token per second: three calls at t = 0 return true, true, false. At t = 1, one more call succeeds and the next fails.
final class TokenBucket
{
private float $tokens;
private float $updatedAt;
public function __construct(
private int $capacity,
private float $refillPerSecond,
private Closure $now,
) {
$this->tokens = $capacity;
$this->updatedAt = ($this->now)();
}
public function allow(): bool
{
$now = ($this->now)();
$elapsed = $now - $this->updatedAt;
$this->tokens = min($this->capacity, $this->tokens + $elapsed * $this->refillPerSecond);
$this->updatedAt = $now;
if ($this->tokens < 1) {
return false;
}
$this->tokens -= 1;
return true;
}
}
Injecting the clock as a closure is the real test here. A limiter that calls microtime() directly can only be tested with sleep(). The bucket refills based on elapsed time, so it needs no background process.
22. Retry a failing call with exponential backoff.
A call that fails twice and then succeeds, with a 100 ms base, should wait 100 ms and then 200 ms.
function retry(callable $fn, int $attempts, int $baseMs, ?callable $sleep = null): mixed
{
$sleep ??= fn (int $ms) => usleep($ms * 1000);
for ($i = 1; ; $i++) {
try {
return $fn($i);
} catch (RuntimeException $e) {
if ($i >= $attempts) {
throw $e;
}
$sleep($baseMs * 2 ** ($i - 1));
}
}
}
Catch only the errors worth retrying; catching Throwable would retry a TypeError too. After the last attempt, rethrow the original exception. Production code adds random jitter so many clients do not retry in step.
23. Model money as an immutable value object and split it without losing cents.
Splitting 100 cents three ways returns 34, 33 and 33.
final readonly class Money
{
public function __construct(public int $cents, public string $currency) {}
public function add(Money $other): Money
{
if ($other->currency !== $this->currency) {
throw new InvalidArgumentException('Currency mismatch');
}
return new Money($this->cents + $other->cents, $this->currency);
}
/** @return Money[] */
public function split(int $parts): array
{
$base = intdiv($this->cents, $parts);
$remainder = $this->cents % $parts;
$out = [];
for ($i = 0; $i < $parts; $i++) {
$out[] = new Money($base + ($i < $remainder ? 1 : 0), $this->currency);
}
return $out;
}
}
Storing cents as integers avoids float rounding: 0.1 + 0.2 is not 0.3 in floating point. The remainder goes to the first parts, so the pieces always add back to the total. A readonly class (PHP 8.2) makes every property write-once.
24. Given package dependencies, return a valid build order or report a cycle.
app needs http and db, which both need log. A valid order is log, http, db, app.
function buildOrder(array $deps): array
{
$inDegree = [];
$dependents = [];
foreach ($deps as $node => $requires) {
$inDegree[$node] ??= 0;
foreach ($requires as $req) {
$inDegree[$req] ??= 0;
$inDegree[$node]++;
$dependents[$req][] = $node;
}
}
$queue = array_keys(array_filter($inDegree, fn ($d) => $d === 0));
$order = [];
while ($queue) {
$node = array_shift($queue);
$order[] = $node;
foreach ($dependents[$node] ?? [] as $next) {
if (--$inDegree[$next] === 0) {
$queue[] = $next;
}
}
}
if (count($order) !== count($inDegree)) {
throw new RuntimeException('Cycle detected');
}
return $order;
}
This is Kahn's algorithm: start with nodes that depend on nothing, and release each dependent when its last requirement is built. If some nodes never reach zero, there is a cycle.
It runs in O(V + E); array_shift() is O(n) in PHP, so a SplQueue is better for large graphs.
What Changed Recently
25. Using PHP 8.4's array functions, find the first overdue unpaid invoice and check the whole list.
$invoices = [
['id' => 101, 'due' => '2026-09-30', 'paid' => false],
['id' => 102, 'due' => '2026-08-15', 'paid' => false],
['id' => 103, 'due' => '2026-07-01', 'paid' => true],
];
$today = '2026-09-24';
$firstOverdue = array_find($invoices, fn ($i) => !$i['paid'] && $i['due'] < $today);
$anyOverdue = array_any($invoices, fn ($i) => !$i['paid'] && $i['due'] < $today);
$allPaid = array_all($invoices, fn ($i) => $i['paid']);
array_find() returns the first matching value, or null. array_any() and array_all() stop at the first element that decides the answer. All three were added in PHP 8.4, along with array_find_key(). Here $firstOverdue['id'] is 102.
Comparing Y-m-d strings works because that format sorts in date order.
26. Add a computed fahrenheit property to a Temperature class with a PHP 8.4 property hook.
Reading fahrenheit on 100 °C returns 212. Setting it to 32 makes celsius 0.
class Temperature
{
public function __construct(public float $celsius) {}
public float $fahrenheit {
get => $this->celsius * 9 / 5 + 32;
set(float $value) {
$this->celsius = ($value - 32) * 5 / 9;
}
}
}
fahrenheit is a virtual property: it never touches its own storage, so it takes no memory and always reflects celsius. See the property hooks manual page. Before 8.4 this needed getFahrenheit() and setFahrenheit() methods or __get.
27. Make an order status readable from outside but changeable only through methods.
class Order
{
public private(set) string $status = 'pending';
public function ship(): void
{
if ($this->status !== 'paid') {
throw new LogicException("Cannot ship a {$this->status} order");
}
$this->status = 'shipped';
}
public function markPaid(): void
{
$this->status = 'paid';
}
}
public private(set) is asymmetric visibility from PHP 8.4. Code outside the class can read $order->status, but writing it throws an Error.
It replaces a private property plus a getter, and unlike readonly the class itself can change the value more than once.
28. Write a slugify function with the PHP 8.5 pipe operator.
' PHP 8.5: The Pipe Operator! ' returns 'php-8-5-the-pipe-operator'.
function slugify(string $title): string
{
return $title
|> trim(...)
|> strtolower(...)
|> (fn ($s) => preg_replace('/[^a-z0-9]+/', '-', $s))
|> (fn ($s) => trim($s, '-'));
}
Each step receives the previous result as its only argument. trim(...) is first-class callable syntax. Functions that need more than one argument must be wrapped in an arrow function, and that arrow function needs parentheses around it.
See the pipe operator RFC.
29. Write immutable "with" methods for a readonly query builder using PHP 8.5's clone with.
final readonly class Query
{
public function __construct(
public string $table,
public int $limit = 50,
public array $where = [],
) {}
public function limit(int $n): static
{
return clone($this, ['limit' => $n]);
}
public function where(string $col, mixed $val): static
{
return clone($this, ['where' => [...$this->where, $col => $val]]);
}
}
clone($this, [...]) copies the object and sets new values for the listed properties, even on a readonly class. The original stays unchanged: after $q1->where('active', true)->limit(10), $q1->limit is still 50.
Before 8.5, this meant rebuilding the object through its constructor. See the clone with RFC.
30. Parse a URL with PHP 8.5's URI extension and change its query string.
use Uri\Rfc3986\Uri;
$uri = new Uri('https://EXAMPLE.com/docs/../api/v2?page=3');
$next = $uri->withQuery('page=4');
getHost() returns the normalized 'example.com', while getRawHost() keeps 'EXAMPLE.com'. getPath() also resolves the dot segments to '/api/v2'. withQuery() returns a new object, so $next->toString() is 'https://example.com/api/v2?page=4'.
A malformed URL throws Uri\InvalidUriException, where parse_url() would return false or a partial result. See the URI extension manual.
Signs of a Strong Answer
- They ask about UTF-8 before touching a string, and know which functions work on bytes.
- They reach for a built-in (
array_count_values(),array_walk_recursive(),ucwords()with delimiters) before writing a loop. - They state the time cost without being asked and name the input that breaks the naive version.
- They use
===andarray_key_exists()where==orisset()would give the wrong answer, and can say why. - They inject clocks, sleepers and factories so the code can be tested without waiting.
- They can rewrite a solution with a PHP 8.4 or 8.5 feature and say when the older form is clearer.
Hiring PHP Developers
A coding round works best with one beginner task as a warm-up, one intermediate task and one advanced design task. Second Talent matches companies with pre-vetted PHP developers from Asia, screened with exercises like these.
See typical rates on our PHP developer cost page.
Tell us the stack and we send a shortlist within 24 hours. Start hiring, or pair these challenges with our Laravel interview questions and MySQL interview questions.






