PHP Developer Interview Questions · 2026

PHP Developer Interview Questions (2026): 25 Most Asked, With Answers

PHP still runs the majority of the web. W3Techs put the number at 71.8% of websites with a known server-side language as of mid-2026, which sits oddly next to the 2025 Stack Overflow Developer Survey, where only 18.2% of professional developers said they actually use it. That gap, most of the web running on it, a minority of developers admitting to it, is a big part of why PHP interview questions catch candidates off guard.

Here's an opinion that might be wrong: most PHP interview content floating around online reads like it's stuck in 2015. It's obsessed with mysql_query() deprecation warnings, old ternary tricks, and Zend Framework trivia nobody ships anymore. The 2026 loops candidates actually report spend far more time on password hashing, PDO, and Laravel middleware than any of that gets credit for.

This page covers the 25 PHP interview questions that come up most across junior, mid, and senior loops: core syntax and data types, arrays and strings, OOP with traits and interfaces, superglobals and sessions, error handling, security, MySQL through PDO, Laravel basics, and one live coding question. Each answer includes what the interviewer is actually listening for, not just the textbook definition.

52Questions
8Topics
71.8%PHP web share
Junior-SeniorLevels

Core PHP interview questions: syntax and data types

These show up first, often in a phone screen, to confirm you know PHP beyond copy-pasted snippets. They're easy to answer badly if you've never had to explain the "why" out loud.

Easy questions

15

== checks whether two values are equal after PHP converts them to a common type. === checks both value and type, with no conversion. "5" == 5 evaluates to true; "5" === 5 evaluates to false because one side is a string and the other an integer.

This is usually a filter question early in a phone screen. The real test comes in the follow-up: ask what in_array($needle, $haystack) returns without a third argument, and a lot of candidates forget it defaults to loose comparison, which can make a string like "0" match against a value it shouldn't logically match at all.

PHP has eight primitive types: four scalar (int, float, string, bool), two compound (array, object), and two special (resource, null). Type juggling means PHP converts a value's type automatically based on context, so "10" + 5 returns 15 as an integer.

Interviewers dig into where juggling gets dangerous: string-to-number comparisons, and why PHP 8.0 changed non-numeric string comparisons so a string like "abc" no longer silently converts to 0 when compared against a number. If you've only ever worked in PHP 8, say so, and mention you know the old PHP 7 behavior was different. It signals you understand the language evolved, not just memorized the current rules.

array_unique() removes duplicate values, comparing them as strings by default (you can pass SORT_NUMERIC or other flags as a second argument). It keeps the original array keys, so the result usually needs array_values() afterward if you need a clean, re-indexed array.

Watch for the gotcha interviewers like to plant: array_unique([1, "1", true]) returns just [1], because all three values compare equal as strings. If someone asks how to dedupe by an object property or a custom rule instead, the answer is a manual loop tracking seen keys, not array_unique(), which only works cleanly on scalar-like values.

php
$fruit = ['apple', 'banana', 'apple', 'cherry', 'banana'];
$unique = array_values(array_unique($fruit));
// ['apple', 'banana', 'cherry']

Since PHP 8.0, str_contains($haystack, $needle) does exactly what it sounds like and returns a boolean. Before PHP 8, the standard was strpos($haystack, $needle) !== false, and the !== mattered: a match at position 0 returns 0, which is falsy, so a loose != false check would silently break on that one specific case.

If a candidate answers with only the old strpos() pattern and never mentions str_contains(), a lot of interviewers assume the candidate hasn't touched PHP in a few years. Know both, but lead with the modern one.

public members are accessible from anywhere. protected members are accessible from within the class and any subclass, but not from outside code. private members are accessible only inside the exact class that declared them, a subclass can't touch a parent's private property even though it inherits from that parent.

A common trap: candidates say private members are hidden from everything, which isn't quite right once reflection or magic methods (__get, __set) enter the picture. Mentioning that a private property can still surface through var_dump() or reflection shows you understand visibility as a language convention, not a hard security boundary.

Superglobals are built-in PHP arrays available in every scope, functions and classes included, without needing a global keyword. The commonly tested ones: $_GET, $_POST, $_SESSION, $_COOKIE, $_SERVER, $_FILES, $_REQUEST, and $_ENV.

Interviewers often ask why $_REQUEST is generally discouraged in production code. It merges GET, POST, and COOKIE data based on a configurable order, which means a value you expect from a form submission could actually come from a cookie an attacker controls. Being explicit about GET or POST is safer and clearer.

A cookie is stored entirely in the browser and sent to the server with every matching request. It's client-controlled, so a user, or an attacker, can read, modify, or forge it if it isn't signed or encrypted. A session stores the real data server-side; the browser only holds a session ID, so sensitive state like a login status never sits on the client in a readable form.

Interviewers sometimes ask when you'd use a cookie instead of a session anyway. Remembering a "keep me logged in" preference across browser restarts is the classic case, since sessions typically expire once the browser closes unless configured otherwise.

mysqli is built specifically for MySQL and MariaDB. PDO (PHP Data Objects) is a database abstraction layer, the same API works against MySQL, PostgreSQL, SQLite, and several other drivers, so a project built on PDO can switch database engines without rewriting every query call.

Both support prepared statements, so the security argument alone doesn't favor either one. The practical reason most modern projects default to PDO is that abstraction, plus a cleaner object-oriented interface. mysqli still shows up in legacy codebases and a few hosting environments where PDO's MySQL driver isn't enabled by default.

PDO::FETCH_ASSOC returns each row as an associative array keyed by column name, accessed with $row['email']. PDO::FETCH_OBJ returns a stdClass instance instead, accessed with $row->email. Functionally they hold the same data, the difference is purely how you interact with it in the calling code.

This rarely gets a deep follow-up on its own. It's more often a quick check that you know PDO has fetch modes at all, before the interviewer moves on to something meatier like transactions or the difference between fetch() and fetchAll().

Routes are declared in routes/web.php (session-based, for browser-facing pages) or routes/api.php (stateless, typically token-authenticated). Each route maps an HTTP verb and a URI pattern to either a closure or a controller method. Route parameters, like {id} in /users/{id}, get passed as arguments to whatever handles the route.

Interviewers sometimes ask about route model binding, where Laravel automatically resolves {user} into a full User model instance by querying the database for you, instead of writing User::findOrFail($id) manually in every controller method. It's a small feature, but a good signal that you've actually built something in Laravel, not just read about it.

Both output a string to the response, but echo can take multiple comma-separated arguments in a single call and has no return value, while print only ever takes one argument and always returns 1, which is why print can be used inside a larger expression and echo can't. Neither one requires parentheses, since both are language constructs rather than real functions, though people write echo('x') out of habit anyway.

In practice the choice comes down to style, not capability. echo is very slightly faster in microbenchmarks because it skips setting up a return value, but that difference has never mattered for a real request. Most style guides just settle on echo since almost nobody actually relies on print's return value.

const is a language construct resolved at compile time, so it can only appear at the top level of a script or inside a class body, and the value has to be a constant expression, not the result of calling a function. define() is a genuine function call resolved at runtime, so it can sit inside an if block or a function body, and the value can be computed at runtime, for example define('APP_ENV', getenv('APP_ENV') ?: 'production').

const also supports visibility modifiers inside a class, public const, protected const, and private const since PHP 7.1, so you can scope a constant to a class hierarchy. define() only ever creates a global constant, there's no way to attach it to a class or namespace it beyond a string prefix convention.

isset($x) checks whether a variable exists and holds something other than null, it returns false both for a variable that was never set and for one explicitly set to null. empty($x) is broader, it returns true if the variable is unset, null, or holds a "falsy" value like 0, '0', an empty string, or an empty array, so empty($x) behaves roughly like !isset($x) || !$x.

is_null($x) only answers whether the value is literally null, it doesn't tell you anything about whether the variable exists in the first place, and referencing an undefined variable inside is_null() will throw a warning where isset() and empty() stay quiet. For validating form input from $_POST or $_GET, isset() or array_key_exists() is almost always the right tool, is_null() gets used less often outside of checking a value that's already guaranteed to exist.

explode($delimiter, $string) splits a string into an array on a delimiter, so explode(',', 'a,b,c') gives ['a', 'b', 'c']. implode($glue, $array), also available under the alias join(), reverses that and glues the array elements back into a single string with the given separator between them.

A gotcha worth knowing: if the delimiter never appears anywhere in the string, explode() still returns an array, just with one element containing the entire original string, it never returns an empty array or false. Passing an empty string as the delimiter argument, on the other hand, throws a ValueError as of PHP 8, it used to just emit a warning in older versions.

An indexed array uses sequential integer keys starting at 0, like ['red', 'green', 'blue'], while an associative array uses keys you choose, usually strings, like ['name' => 'Alice', 'role' => 'admin']. Under the hood PHP doesn't actually treat these as separate types, both are the same ordered hash map structure, an indexed array is just an associative array whose keys happen to be sequential integers.

foreach ($array as $value) works fine for an indexed array where you only care about the values. For an associative array you almost always want foreach ($array as $key => $value) so the loop body can use both the key and the value, since looping with just $value alone throws away the information the keys were carrying.

Medium questions

25

require throws a fatal error and halts the script if the file can't be found. include only raises a warning and keeps executing. The _once variants track which files were already loaded and skip duplicates, which matters most for files declaring classes or functions, since loading one twice would crash with a redeclaration error.

The follow-up worth prepping: why not just use require_once everywhere? The file-tracking check adds a small overhead per call (OPcache reduces this in most production setups). Autoloading through Composer's PSR-4 standard makes most manual require calls unnecessary anyway.

array_map() transforms every element with a callback and returns a new array of the same length. array_filter() runs a callback as a test and keeps only the elements that pass, so the result can be shorter than the input. array_reduce() folds the entire array into a single accumulated value, like a running total or a concatenated string.

A sharp follow-up: what does array_filter() do if you skip the callback entirely? It drops every "falsy" value, empty strings, 0, null, and false included, using PHP's normal truthiness rules. That default surprises people who expect it to remove only null.

An abstract class can hold real implementation, properties, and constructors, but a class can only extend one. An interface defines only method signatures, a contract, and a class can implement as many interfaces as it needs. Interfaces can also declare constants, which has been true since early PHP 5 versions, so it doesn't blur the distinction as much as people sometimes assume.

The practical answer interviewers want: use an abstract class when subclasses share real behavior worth inheriting. Use an interface when unrelated classes need to guarantee the same method exists, like Countable, or a custom Payable contract shared by an Invoice class and a Subscription class that otherwise have nothing in common.

A trait is a set of methods a class can pull in with the use keyword. Because PHP only allows single inheritance through extends, traits give you a way to share behavior across unrelated class hierarchies without forcing them into one parent. Under the hood it behaves closer to compile-time copy-paste than real inheritance, an instanceof check against a trait name doesn't work, since the class never actually inherits from it.

Conflict resolution is the part that trips people up. If two traits define the same method name, PHP throws a fatal error unless you resolve it explicitly with insteadof and as. Interviewers who've been burned by a real trait conflict like hearing that you know this exists, even if you can't write the exact syntax cold.

php
trait Loggable {
    public function log(string $message): void {
        echo "[" . static::class . "] " . $message . PHP_EOL;
    }
}

trait Timestampable {
    public function touch(): void {
        $this->updatedAt = new DateTime();
    }
}

class Order {
    use Loggable, Timestampable;

    public ?DateTime $updatedAt = null;
}

$order = new Order();
$order->touch();
$order->log('Order updated');

Calling session_start() either creates a new session ID or resumes an existing one from the PHPSESSID cookie the browser sends back. Only that ID lives client-side. The actual data in $_SESSION lives server-side, by default in a flat file inside the directory set by session.save_path, often /tmp on Linux.

Senior-leaning follow-up: what happens to sessions across multiple servers behind a load balancer? File-based sessions break unless you use sticky sessions, because server B can't read a session file written on server A. The real fix is a shared session handler, Redis or Memcached being the common choices, wired up through session_set_save_handler() or a framework driver like Laravel's.

Since PHP 7, most fatal errors, calling a method on null, a type mismatch, dividing by zero, get thrown as Error objects instead of just halting the script silently. Error and Exception both implement the Throwable interface, so a single catch (Throwable $e) block can catch either. Before PHP 7, a fatal error simply couldn't be caught at all, it just stopped execution.

A good answer distinguishes intent: exceptions represent conditions your code anticipates and can recover from, a failed API call, invalid input. Errors generally represent programmer mistakes, calling an undefined method, that you should fix rather than gracefully paper over in most cases.

try wraps code that might throw. catch handles a specific exception type, or several at once using a pipe between them (a feature since PHP 7.1), if one gets thrown. finally runs whether or not an exception was thrown or caught, which is why it's the standard place to close a file handle or database connection.

finally almost always runs, with narrow exceptions: calling exit() or die() inside the try block, a fatal uncatchable error, or the PHP process getting killed externally. If a candidate says "finally always runs, no exceptions," that's technically wrong and worth a gentle correction, not just letting it slide.

Use prepared statements with bound parameters, through PDO or mysqli, and never concatenate raw user input into a SQL string. Prepared statements send the query structure to the database separately from the parameter values, so even a malicious string like ' OR '1'='1 gets treated as literal data, not as SQL syntax.

Interviewers occasionally push further: what about escaping with mysqli_real_escape_string() instead? It's better than nothing, but it's not equivalent. It handles quote characters but doesn't cover every injection vector, and it's easy to forget on one input path in a large codebase (the OWASP SQL Injection Prevention Cheat Sheet is the reference most senior engineers actually check). Parameterized queries remove the class of bug entirely instead of relying on someone remembering to escape every time.

XSS injects malicious JavaScript into a page that other users then load, typically because user-supplied content got rendered without escaping. CSRF tricks a victim's browser, which already holds valid session cookies, into submitting a request the victim never intended, like a hidden form that fires on page load.

Defend against XSS by escaping output with htmlspecialchars() at render time, not just on input. Defend against CSRF with a random token embedded in every form and validated server-side before processing the request, this is exactly what Laravel's @csrf directive generates automatically. A candidate who conflates the two, or thinks one fix covers both, probably hasn't implemented either defense in a real project.

Both are fast, general-purpose hash functions built for speed, which is exactly the wrong property for password storage. Speed is the actual enemy here: a modern GPU can compute billions of MD5 hashes per second, which makes brute-forcing or rainbow-table lookups against a leaked password database practical within hours, sometimes minutes for weak passwords.

Password hashing needs to be deliberately slow and salted per password. That's exactly what password_hash() does. If a candidate's answer stops at "MD5 is insecure" without explaining why speed matters, push for the deeper reasoning, it's the difference between memorizing a rule and understanding the actual threat model.

password_hash($password, PASSWORD_DEFAULT) generates a random salt automatically, runs it through bcrypt (the current default, though PHP also supports Argon2i and Argon2id), and returns one string bundling the algorithm identifier, cost factor, salt, and hash together. You never store the salt separately, it's baked into the returned string.

password_verify($plain, $hash) re-derives the hash using the salt and cost factor pulled from that stored string and compares the result in a timing-safe way. Interviewers sometimes ask about the cost factor specifically: raising it above the default of 10 rounds (see the official password_hash() manual page for the full option list) makes each hash slower to compute, which is intentional. It raises the cost of brute-forcing proportionally as hardware keeps getting faster.

php
$hash = password_hash($plainPassword, PASSWORD_DEFAULT);
// store $hash in the database, never the plaintext

if (password_verify($submittedPassword, $hash)) {
    // password matches
} else {
    // reject login
}

Prepare the statement with a named placeholder, bind the actual value, execute, then fetch. The point of doing it this way instead of interpolating $email straight into the string is that the driver handles the escaping internally, it's not something you have to get right by hand every time.

php
$stmt = $pdo->prepare('SELECT id, name, email FROM users WHERE email = :email LIMIT 1');
$stmt->execute(['email' => $email]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);

if (!$user) {
    // no matching user
}

A common follow-up: what if you need multiple rows instead of one? Swap fetch() for fetchAll(), and drop the LIMIT 1 if it no longer applies. Interviewers also sometimes ask whether you set PDO::ATTR_ERRMODE to PDO::ERRMODE_EXCEPTION, so a failed query throws instead of failing silently, which a surprising number of candidates forget to configure.

Eloquent is Laravel's built-in ORM, following the ActiveRecord pattern where each model class maps to a table and each instance maps to a row. The N+1 problem shows up when you loop over a collection and access a relationship inside the loop, one query fetches the list, then one extra query fires per record to pull each item's related data. A list of 50 posts each showing an author turns into 51 queries instead of 2.

The fix is eager loading with with(), which pulls the related data in one batched query up front instead of one per row. Interviewers who care about performance sometimes hand you a slow endpoint and ask you to spot the N+1 pattern in a query log, this shows up in real production incidents more than almost any other question on this list.

php
// N+1: fires one query per post to load its author
$posts = Post::all();
foreach ($posts as $post) {
    echo $post->author->name;
}

// Fixed: eager load the relationship in one extra query
$posts = Post::with('author')->get();
foreach ($posts as $post) {
    echo $post->author->name;
}

Loop through the input array. For each element, check whether it's itself an array with is_array(). If it is, recurse into it and merge the flattened result. If it isn't, append the value directly. This handles arrays nested to any depth without knowing the depth ahead of time.

The follow-up interviewers ask most often: what's the time complexity here, and would you ever reach for array_walk_recursive() instead? The recursive version above is O(n) across every element at every depth. array_walk_recursive() does something similar built-in, but writing it by hand shows the interviewer you understand what the built-in function is actually doing underneath.

php
function flattenArray(array $items): array {
    $result = [];

    foreach ($items as $item) {
        if (is_array($item)) {
            $result = array_merge($result, flattenArray($item));
        } else {
            $result[] = $item;
        }
    }

    return $result;
}

$nested = [1, [2, 3, [4, 5]], 6, [[7]]];
print_r(flattenArray($nested));
// [1, 2, 3, 4, 5, 6, 7]

Using a reference in a foreach loop, foreach ($array as &$value), leaves $value bound by reference to the last element of the array after the loop ends. If you then run a second, unrelated foreach ($array as $value) right after without unsetting $value first, that second loop silently overwrites earlier array elements, because on each iteration PHP assigns the new value into whatever $value is currently pointing at, which is still that leftover reference to the array's last slot.

The fix is a single line, unset($value) immediately after the reference-based loop, which breaks the binding. This bug produces no warning and no error, the array just quietly ends up with duplicated data in the wrong place, which is exactly why it survives in code review more often than bugs that actually crash.

php
foreach ($items as &$value) {
    $value = strtoupper($value);
}
unset($value); // breaks the reference

foreach ($items as $value) {
    // safe: this loop won't clobber $items anymore
}

A static variable declared with static $count = 0; inside a function retains its value between separate calls to that function, but it's private to that function, nothing outside the function can read it or reset it directly. It's handy for memoizing something without polluting global state or requiring a class just to hold one counter.

A static property, public static $count = 0; on a class, is shared across every instance of that class and is accessed through ClassName::$count rather than through any single object. Because it lives on the class itself rather than on an instance, every object sees the same value, and it exists independently of whether any instances have even been created yet. That makes static properties useful for counters or shared registries, but it also makes unit testing harder, since state leaks across test cases unless something explicitly resets it between tests.

By default, use($var) copies the outer variable's value at the moment the closure is defined, so later changes to the outer $var don't affect what's inside the closure. Writing use(&$var) instead captures by reference, so mutations from either side, the closure or the surrounding code, are visible on both, which is the pattern behind something like use (&$total) inside an array_walk() callback that needs to accumulate a running total across calls.

Arrow functions, fn($x) => $x + $total, introduced in PHP 7.4, automatically capture any outer variable referenced in the expression by value, so there's no use() clause to write at all. The tradeoff is that arrow functions only support a single expression, not a full statement body, and they can't capture by reference, so anything that needs the mutate-through-reference behavior still has to fall back to a regular closure.

__get($name) and __set($name, $value) fire automatically when code tries to read or write a property that's inaccessible from outside the class or doesn't exist at all. __call($name, $arguments) fires when code invokes a method that isn't defined on the object. Frameworks lean on these constantly, Eloquent models use __get/__set so attributes feel like ordinary public properties while actually routing through attribute casting, and __call is what powers dynamic query-builder style method chains.

The cost is that overusing magic methods hides the class's real API from IDEs, static analyzers, and anyone reading the source, since there's no explicit list of properties or methods to look at. A typo in a property name that would normally cause an obvious fatal error instead falls into __get silently and returns null, turning a compile-time-obvious mistake into a runtime mystery that surfaces somewhere unrelated. It's a tool for genuinely dynamic scenarios, not a substitute for declaring real properties when you already know what they are.

DateTime is mutable, calling a method like ->modify('+1 day') or ->add() changes the object in place and returns that same instance. That's convenient right up until you pass a DateTime object into a function expecting it to hand back a new date, and instead the caller's original object also changed, because it was never a separate copy to begin with.

DateTimeImmutable exposes an identical API, but every mutating-style method returns a brand new instance and leaves the original untouched, so $tomorrow = $today->modify('+1 day') is the only way to get the new value, $today itself is unaffected. Anywhere dates get passed between functions or stored as value objects, DateTimeImmutable is the safer default, and most current PHP code, including newer Symfony conventions, leans toward it specifically to avoid the aliasing bugs mutable date objects tend to produce.

An empty PHP array encodes to [] in JSON, but the moment the array has even one string key, PHP encodes it as a JSON object {} instead, since there's no way for PHP to tell an empty list apart from an empty map without extra information. If you specifically need an empty PHP array to come out as {} in the JSON, you have to cast it to an object first, or pass the JSON_FORCE_OBJECT flag.

By default json_encode() returns false on failure, say the data contains malformed UTF-8, and you have to remember to call json_last_error() afterward to find out what happened. Since PHP 7.3 you can pass JSON_THROW_ON_ERROR, which throws a JsonException instead, much easier to catch and log than checking a return value everywhere. On the decode side, json_decode($json) returns stdClass objects unless you pass true as the second argument for associative arrays, and it returns null both for actual JSON null and for invalid input, so json_last_error() matters on that side too.

php
json_encode([]);          // "[]"
json_encode(['a' => 1]);  // '{"a":1}'
json_encode((object) []); // "{}"

Quantifiers like * and + in PCRE, the engine behind preg_match(), preg_replace(), and the rest, are greedy by default, they try to consume as much text as possible and only give characters back if that causes the rest of the pattern to fail. A pattern like /<.+>/ against '<b>bold</b>' matches the entire string from the first < to the very last >, because .+ greedily swallows everything in between rather than stopping at the first closing tag.

Adding a ? after the quantifier makes it lazy, so /<.+?>/ matches as little as possible, giving you just '<b>' on the first match. This matters most when parsing anything with repeated delimiters, HTML tags or quoted strings, where a greedy match will happily jump over multiple delimiters and hand back a result nobody asked for. For actual HTML you should reach for a DOM parser rather than regex, but for simpler delimited text, log lines, CSV-ish formats, knowing greedy versus lazy is often what separates a correct extraction from a silently wrong one.

All three sort an array using a comparator callback you supply, the difference is what happens to the keys afterward. usort() re-indexes the array from 0 once sorting finishes, discarding any existing keys, including string keys, which is fine for a plain list but destructive if the keys carried meaning, like a user ID. uasort() sorts by value the same way but preserves each element's original key, so sorting an associative array of records by a 'score' field with uasort() keeps every record attached to its original ID.

uksort() is different again, it sorts by the keys themselves rather than the values, using your callback to compare keys instead. Across all three, the comparator itself follows the same convention, take two elements and return a negative number, zero, or a positive number to indicate their relative order, the classic C-style comparator contract.

PSR-4 maps a namespace prefix to a base directory in composer.json under autoload.psr-4, for example "App\\": "src/" means App\Services\Mailer resolves to src/Services/Mailer.php. Composer registers a callback with spl_autoload_register() through vendor/autoload.php, and PHP calls that callback the first time an unknown class name is referenced anywhere, the autoloader translates the fully qualified class name into a file path and requires it.

If the file isn't at the expected path, or the class or namespace declared inside it doesn't exactly match what the mapping expected, case matters on Linux filesystems even though it doesn't on macOS or Windows, which is a classic deploy-only bug, you get a fatal "Class not found" error. PHP never falls back to scanning the whole src/ directory looking for the class, it only tries the specific path the mapping computed. Running composer dump-autoload -o regenerates and optimizes the class map, worth doing after moving files around or editing the autoload rules in composer.json.

PHP's plain string functions, strlen(), substr(), strtoupper(), and so on, operate byte by byte with no concept of character encoding. That's fine for pure ASCII, but the moment a string contains multi-byte UTF-8 characters, accented letters, emoji, CJK text, strlen() returns a byte count rather than a character count, and substr() can cut a multi-byte character in half, producing invalid, unrenderable UTF-8 in the output.

The mb_ extension functions, mb_strlen(), mb_substr(), mb_strtoupper(), and the rest, are encoding-aware, you can pass the encoding explicitly or rely on the internal encoding set through mb_internal_encoding(). Any application accepting input in a language other than English, or even just names with accented characters, needs to standardize on the mb_ functions for string manipulation, otherwise you get subtly corrupted text that only shows up with real production data, since English-only test fixtures never trigger the bug.

Dependency injection means a class receives the objects it depends on from the outside, usually through its constructor, instead of creating them itself with new. Rather than a class doing $this->mailer = new SmtpMailer() inside its constructor, it accepts a MailerInterface $mailer as a constructor argument and stores it, so whoever builds the class decides which concrete implementation to hand over.

The practical payoff is testability. A class that hardcodes new SmtpMailer() can't be unit tested without an actual working SMTP connection somewhere in CI. With constructor injection you pass in a fake or mock implementation of MailerInterface during tests and assert on what it received, no network call happens at all. Frameworks like Laravel and Symfony formalize this with a service container that reads type hints, or explicit config bindings, and automatically resolves and injects the right concrete class when an object is built, so wiring dependencies by hand is rarely necessary once the bindings are registered once.

Hard questions

12

self:: always resolves to the class where the code is physically written. static:: uses late static binding and resolves to whichever class the method was actually called on at runtime. The difference only shows up when inheritance is involved, in a standalone class the two behave identically.

This is a senior-level question because it's genuinely confusing until you've been bitten by it once. A parent class with a factory method built on self:: always returns an instance of the parent, even when called through a child class. Switch that to static:: and the factory returns the correct child type. Anyone who's built a base repository or ActiveRecord-style class in PHP has almost certainly hit this.

php
class Model {
    public static function create(): static {
        return new static();
    }

    public static function createSelf(): self {
        return new self();
    }
}

class User extends Model {}

$user = User::create();      // returns instance of User
$wrong = User::createSelf(); // returns instance of Model, not User

PHP-FPM keeps a pool of worker processes around to handle requests, and the pm setting controls how that pool grows and shrinks. static keeps exactly pm.max_children processes running at all times, no scaling logic, giving predictable memory use but wasting RAM during idle periods and offering no headroom beyond the fixed count when traffic spikes. dynamic starts with pm.start_servers processes and scales between pm.min_spare_servers and pm.max_spare_servers based on load, the common default, though memory usage fluctuates and a sudden spike still has to wait for new workers to spawn instead of already being available. ondemand only spawns a worker when a request actually needs one and kills idle workers after pm.process_idle_timeout, minimizing idle memory footprint at the cost of a cold-start latency hit on the first request after a quiet period.

Sizing pm.max_children comes down to available RAM divided by memory per worker. Take the server's available memory, leaving headroom for MySQL, Redis, or anything else sharing the box, and divide by the average resident memory of a single FPM worker under real load, checked with ps aux rather than assumed from php.ini's memory_limit. That quotient is your ceiling. Set it too high and the OOM killer starts reaping processes under load, set it too low and requests queue in the FPM backlog and time out even with spare CPU sitting idle, so the number has to come from measuring actual worker memory, not from guessing.

This is almost always OPcache serving stale compiled bytecode. OPcache caches each file's compiled form keyed by file path, and by default it checks file modification timestamps, opcache.validate_timestamps=1 with an opcache.revalidate_freq interval, to decide whether to recompile. It's common in production to set opcache.validate_timestamps=0 for a small performance win, which means OPcache never rechecks the source file at all until the FPM process restarts, so a deploy that simply overwrites files in place leaves every already-running worker serving the old cached bytecode indefinitely.

The fix is to make cache invalidation part of the deploy step rather than depending on file-timestamp checks. That's usually an atomic symlink swap paired with an opcache_reset() call through a small script or endpoint once the new code is in place, or a full php-fpm reload as the last deploy step, which flushes the shared memory cache along with restarting the workers. Some teams route through a load balancer with a brief drain window so no request lands on a worker mid-restart. Whatever the mechanism, the deploy pipeline needs an explicit step that resets or lets OPcache recompile, treating "the files changed on disk" as sufficient is exactly what causes this symptom.

Pessimistic locking grabs a row lock at the database level before reading the value you're about to check, using SELECT ... FOR UPDATE inside a transaction. A second concurrent request trying to read the same row blocks until the first transaction commits or rolls back, so by the time it reads the stock count it sees the already-decremented value and correctly rejects the purchase. This is simple to reason about, but it means requests queue up waiting on the lock, which hurts throughput under heavy contention on one hot row.

Optimistic locking skips the lock and instead ties the update's WHERE clause to a version or the current quantity value, then checks the affected row count afterward. If zero rows were affected, someone else got there first, or stock hit zero, so the application retries the read-and-decide cycle or tells the customer the item just sold out. This scales better under contention since no lock is held while application code does other work between the read and the write, but it does require the business logic to explicitly handle the zero-rows-affected retry path, which is easy to forget and ship as a silently ignored failed update.

php
$stmt = $pdo->prepare(
    'UPDATE inventory SET quantity = quantity - 1, version = version + 1
     WHERE id = ? AND version = ? AND quantity > 0'
);
$stmt->execute([$productId, $currentVersion]);

if ($stmt->rowCount() === 0) {
    // stale version or out of stock: retry the read-decide cycle
}

PHP uses reference counting as its primary memory management mechanism, an object is freed the instant its refcount hits zero. A circular reference, a Parent object holding a Child, and that Child holding a back-reference to its Parent, would never hit zero under pure reference counting alone, since each object keeps the other alive. PHP's cycle collector, enabled by default and checkable with gc_enabled(), periodically scans for these root buffers and can detect and free genuinely unreachable cycles, so in the general case, yes, circular references do get cleaned up eventually.

Where it still leaks in practice is long-running processes, a queue worker or daemon, where circular structures accumulate faster than the periodic collection cycle runs, since gc_collect_cycles() only fires automatically once the root buffer hits a threshold, 10,000 by default, not continuously. Closures that capture $this and then get stored back onto the object they came from are a very common source of accidental cycles nobody thinks of as circular. For a long-running worker, calling gc_collect_cycles() explicitly on an interval, or restructuring the code to avoid holding closures and objects longer than needed and letting the worker itself restart periodically, is the usual fix, rather than trusting the automatic threshold alone.

The first move isn't to raise memory_limit and move on, that treats the symptom and just delays the crash until a slightly bigger dataset shows up. Instead, drop memory_get_peak_usage(true) calls at checkpoints through the suspect code path to bisect where memory actually balloons, passing true gives the real allocation reported by the OS rather than just what the Zend engine tracks internally, which tends to be more honest when native extensions are involved.

A few root causes come up constantly: a buffered mysqli query pulling an entire result set into PHP memory before a single row is touched, so a two million row report query dies here even if the code only needs to stream and discard rows one at a time; array_map() or array_merge() building a huge intermediate array instead of processing in a generator or streaming fashion; and objects quietly accumulating in a loop because something, a static property, an identity map, a closure, still holds a reference to every iteration's object. If the leak only shows up under real production load and never locally, attaching Xdebug's profiler or a tool like Blackfire in a staging environment that mirrors production data volume is worth it, since small local datasets often just don't reproduce the problem at all.

A backed enum ties each case to a scalar value, int or string, declared like enum Status: string { case Active = 'active'; case Suspended = 'suspended'; }. Unlike the plain "pure" enums PHP 8.1 also introduced, cases with no backing value, just distinct identities, a backed enum gives you Status::Active->value for the underlying string, and Status::from() or Status::tryFrom() to build a case from a raw value coming out of a database column or an API payload, with tryFrom() returning null instead of throwing when the value doesn't match any case. This replaces the long-standing pattern of class constants like const STATUS_ACTIVE = 'active' for a fixed set of options, but with real type safety, a function typed to accept Status can't be handed an arbitrary string by mistake the way a constant-based approach always allowed.

readonly properties are an unrelated PHP 8.1 feature, they let you declare public readonly string $id on a class, and once set, typically inside the constructor, any later assignment throws an Error. It's PHP's answer to immutable value objects without needing a full DateTimeImmutable-style API, useful for DTOs where the object's state must never change after construction. The two features pair naturally, an enum case is already immutable by nature, and a readonly property typed as an enum gives you a field that's both constrained to a fixed set of values and can never be reassigned.

Reflection lets code inspect classes, methods, properties, and parameters at runtime, including their type hints and attributes, without the inspected code needing to cooperate or implement any special interface. The most common production use is inside dependency injection containers, when a container needs to build an object of a class it's never seen before, it uses ReflectionClass to look at the constructor's parameters, reads each parameter's type hint, and recursively resolves and instantiates whatever those type-hinted dependencies are. That's literally how Laravel's container and PHP-DI autowire constructor arguments without hand-written binding code for every class.

Another real use is attribute-driven behavior. PHP 8 attributes like #[Route('/users/{id}')] on a controller method are inert metadata by themselves, a framework's router has to call ReflectionMethod::getAttributes() at boot time to actually read them and build the routing table. ORMs use the same mechanism to read column-mapping attributes on entity properties instead of maintaining a separate XML or YAML config file. The tradeoff is performance, reflection is noticeably slower than direct code, which is why frameworks leaning on it heavily cache the results, compiled container definitions, compiled routes, rather than re-reflecting the same classes on every request.

A generator function uses yield instead of return, and each call to it returns a Generator object that produces values lazily, one at a time, as something iterates over it, rather than computing the whole result set up front and holding it all in memory. This matters for anything working over a dataset too large to comfortably fit in memory, streaming rows out of a large database result set, parsing a multi-gigabyte log file line by line, or reading a large CSV export.

yield from lets a generator delegate to another iterable, array or generator, inline, useful for composing generators or flattening a generator of generators without materializing the intermediate results. The tradeoff is that a generator can typically only be iterated once, since it maintains internal cursor state, if the same data needs to be looped over twice you either call the generator function again or materialize it into an array with iterator_to_array(), which defeats the memory-saving point of using a generator at all. Generators also can't be rewound arbitrarily or accessed by index the way an array can, only the linear "give me the next value" pattern is supported.

php
function readLargeFile(string $path): Generator
{
    $handle = fopen($path, 'r');
    while (($line = fgets($handle)) !== false) {
        yield $line;
    }
    fclose($handle);
}

foreach (readLargeFile('access.log') as $line) {
    // one line in memory at a time, regardless of file size
}

Most queue systems, including Laravel's queue workers, guarantee at-least-once delivery, not exactly-once. A job can start executing, then crash, time out, or get requeued because a worker restarted before acknowledging completion, and the queue will redeliver it. If the job's logic is "charge the customer's card" or "send the welcome email" with no other safeguard, a retry after a partial failure means the customer gets charged twice, or gets two welcome emails, even though the application only ever "meant" to run the job once.

The standard fix is keying the job's side effect on something unique to that specific business event, not the queue message ID, since a redelivered message can even get a new ID depending on the queue implementation. Before doing the actual charge or send, the job checks, inside a transaction, backed by a unique constraint rather than a plain application-level check that could itself race, whether an idempotency key, the original order ID plus an operation type, has already been recorded as processed. If it has, the job returns immediately having done nothing, treating that as success. This usually means an idempotency_keys table with a unique index on the key, and having the job's first write inside the same transaction as the actual side effect be an insert into that table, so a concurrent or retried execution either succeeds at the insert and proceeds, or hits the unique constraint violation and knows it already ran, rather than relying on a preceding SELECT that leaves a race window between the check and the insert.

A deadlock happens when two transactions each hold a lock the other one needs, and each is waiting on the other to release it, so neither can proceed. In InnoDB this most commonly shows up when two transactions update the same set of rows in a different order, transaction A locks row 1 then tries to lock row 2, while transaction B has already locked row 2 and is trying to lock row 1, at which point MySQL's deadlock detector picks one transaction as the victim, rolls it back, and returns error 1213 to that connection.

The most reliable fix is enforcing a consistent lock order across every code path that touches the same rows, always acquiring locks in ascending primary key order regardless of which business operation triggered the transaction, so two concurrent transactions never end up waiting on each other in a cycle. On the PHP side, that means catching the specific deadlock error code from PDOException and retrying the whole transaction from the start a small number of times with a short backoff, rather than treating it as an unrecoverable failure, since a deadlock rollback is often successfully retried on the very next attempt once the competing transaction releases its locks. Keeping transactions short, doing the minimum work inside BEGIN/COMMIT and avoiding any external network call while a transaction is open, also shrinks the window during which a deadlock can occur at all.

php
try {
    $pdo->beginTransaction();
    // ... update rows in a fixed, consistent order ...
    $pdo->commit();
} catch (PDOException $e) {
    $pdo->rollBack();
    if ($e->errorInfo[1] === 1213) {
        // deadlock: safe to retry the whole transaction
    }
    throw $e;
}

A per-server in-memory counter, a static array or even APCu, stops working the moment there's more than one PHP-FPM server behind a load balancer, since each server tracks its own count independently, a client would effectively get the limit multiplied by however many servers are in the pool. The counter state has to live somewhere shared and fast, which almost always means Redis.

The usual approach is a token bucket or sliding window: on each request, middleware runs a Redis command, often a small Lua script executed atomically via EVAL so the check-and-decrement isn't split across two round trips a race condition could slip through, that increments a counter keyed by client identifier plus a time window, checks it against the limit, and returns whether the request is allowed along with the remaining count for rate-limit response headers. Using EXPIRE on the key avoids needing a separate cleanup job for old windows. A fixed window has an edge case where a client bursts right at the boundary between two windows to effectively get double the limit, so for a true sliding window Redis sorted sets work better, storing a timestamp per request and using ZREMRANGEBYSCORE to drop entries older than the window before counting what's left with ZCARD. The middleware sits early in the request pipeline, before any expensive application logic runs, so a rejected request costs one Redis round trip, not a full framework boot and database connection.

How to prepare for a PHP interview in 2026

Across PHP mock interview sessions run through LastRoundAI, one pattern shows up more than any single missed question: candidates who know the right answer but can't explain the trade-off when the interviewer pushes back. Knowing that password_hash() beats md5() is table stakes. Explaining why speed is the actual vulnerability, and what a cost factor controls, is what separates a pass from a maybe.

A few things worth doing before the loop, in rough order of return on time invested:

  • Rebuild two or three of the code answers above from scratch, without looking. The muscle memory for a password_hash() flow, a PDO prepared statement, and the array-flattening function covers a large share of what actually gets asked.
  • If your recent experience is mostly Laravel, spend an hour on raw PDO and vanilla OOP. Interviewers use these to check whether you understand what the framework is doing for you, not just how to call an Eloquent method.
  • If your recent experience is mostly vanilla PHP, skim Laravel's routing and Eloquent docs even if the job doesn't require it. Most PHP postings in 2026 mention Laravel by name, and interviewers often assume some familiarity even for framework-agnostic roles.
  • Practice saying your security answers out loud, not just reading them. "SQL injection, use prepared statements" is a flashcard answer. Explaining why string concatenation is dangerous, in your own words, under a little time pressure, is closer to what the interview actually measures.

None of this replaces mock practice under real time pressure. Reading an answer and producing it cold, three questions deep into a 45-minute round, are different skills entirely.

If you want to practice these out loud before the real thing, LastRoundAI's mock interview product runs realistic PHP rounds with follow-up questions modeled on what's above. Once you land the interview, Auto-Apply handles the volume of applications it usually takes to get there, with every send reviewed before it goes out.

Leave a Reply

Your email address will not be published. Required fields are marked *