PHP 8 • OOP & Composer • PDO & Security • Laravel • Performance • 2026

PHP Interview Questions

30 questions What each one tests, an answer frame, a spoken answer 36 min read

This page is for anyone facing a PHP round, from a first web developer job to a senior backend role. Most PHP interviews open with the language basics, like loose and strict comparison, arrays and includes, then move to sessions, superglobals and OOP with traits and interfaces. Next come PDO, SQL injection, XSS and password hashing, followed by modern PHP 8 features, Composer, JSON APIs and light Laravel. Senior rounds add performance, a production story and a judgement call. Each question shows what the interviewer is really checking, the shape of a strong answer and a short answer to say out loud. Practise them, then swap in your own stories.

Search all questions by round, difficulty and level, or save the ones you want to practise.

Language Basics 6 questions

Easy Technical round Fresher, Mid-level Practice question

1. What's the difference between == and === in PHP, and did anything about loose comparison change in PHP 8?

What the interviewer is really testing:
Whether you understand type juggling well enough to avoid the classic comparison bugs, and whether your knowledge is current with PHP 8.
Answer frame:

Loose (==): converts the types first, then compares values, so the string '1' equals the integer 1.

Strict (===): true only if the type and the value both match, with no conversion.

PHP 8 change: a number compared with a non-numeric string is now compared as strings, so 0 == 'abc' is false.

Sample spoken answer:

"Double equals compares after type juggling. PHP converts both sides to a common type first, so the string '1' equals the integer 1, and null equals false. Triple equals is strict: the types must match and the values must match, with no conversion at all. PHP 8 fixed the worst surprise. In PHP 7, comparing 0 with a string like 'abc' turned the string into 0, so it came out true. In PHP 8, if the string isn't numeric, PHP compares them as strings instead, so it's false. Numeric strings are still compared as numbers, which is why '10' == '1e1' is still true. My habit is to use === by default, pass true as the strict flag to in_array and array_search, and only use == when I really want the conversion."

Code:
var_dump(0 == 'abc');     // false in PHP 8, true in PHP 7
var_dump('1' == '01');    // true: both are numeric strings
var_dump('10' == '1e1');  // true: compared as numbers
var_dump(null == false);  // true
var_dump(1 === '1');      // false: int vs string

var_dump(in_array('abc', [0]));       // false in PHP 8
var_dump(in_array('1', [1], true));   // false: strict check
Red flag to avoid:

Saying == and === differ only in speed, or not knowing that loose comparison converts types before it compares.

They may ask next:
  • Why can in_array give a wrong answer without the strict flag, and what does that flag change?
  • What does the spaceship operator return, and where would you use it?
Say it in 60 seconds
Easy Technical round Fresher Practice question

2. When would you use include, require, or their _once versions, and what happens if the file is missing?

What the interviewer is really testing:
Whether you know how PHP loads files and what failure looks like, which matters for bootstrapping an app safely.
Answer frame:

require: a missing file is a fatal error and the script stops.

include: a missing file raises a warning and the script carries on.

_once: skips a file that was already loaded, which avoids 'cannot redeclare' errors.

Today: Composer's autoloader loads classes, so manual includes are mostly for config and templates.

Sample spoken answer:

"Both pull another PHP file into the current script. The difference is what happens when the file can't be found. With require, it's a fatal error and execution stops. With include, PHP raises a warning and keeps going. So I use require for things the page can't work without, like the bootstrap file or the autoloader, and include for optional pieces, like a template partial, where a missing file shouldn't take the whole page down. The _once versions remember which files were already loaded and skip them the second time, which avoids 'cannot redeclare function' errors when two files pull in the same helper. In a modern project I rarely write these by hand. The one require I always have is vendor/autoload.php, and Composer loads classes on demand from there."

Red flag to avoid:

Getting the warning versus fatal error behaviour backwards, or building an include path from user input.

They may ask next:
  • What's the danger in passing a file name from the query string to include?
  • Does include return a value, and how do config files use that?
Say it in 60 seconds
Medium Technical round Fresher, Mid-level Practice question

3. PHP has only one array type. How does it work under the hood, and what surprises does that cause with keys?

What the interviewer is really testing:
Whether you know PHP arrays are ordered hash maps and can predict key casting and reindexing, which explains many subtle bugs.
Answer frame:

One structure: an ordered hash map that serves as list, dictionary, stack and queue.

Keys: only int or string; a string like '8' becomes the int 8, and true becomes 1.

Reindexing: array_merge renumbers integer keys, the plus operator keeps them, array_filter leaves gaps.

Lists: array_is_list, from PHP 8.1, checks the keys run 0, 1, 2 in order.

Sample spoken answer:

"A PHP array is an ordered hash map. The same structure acts as a list, a dictionary, a stack or a queue, and it always remembers insertion order. Keys can only be integers or strings, and PHP casts them: a string that looks like a plain integer, like '8', becomes the int 8, and true becomes 1. That causes real bugs. If I key an array by IDs, they're integers, and array_merge renumbers integer keys from zero, so the IDs are gone. The plus operator keeps keys, but it ignores any key that already exists on the left. Another surprise is gaps. array_filter keeps the original keys, so a filtered list might have keys 0, 2 and 4, and json_encode then outputs an object instead of an array. I call array_values to reindex. From PHP 8.1, array_is_list tells me whether the keys run from zero in order."

Code:
$a = ['8' => 'x', true => 'y'];
var_dump(array_keys($a));               // [8, 1], both ints

$byId = [10 => 'a', 20 => 'b'];
print_r(array_merge($byId, [30 => 'c'])); // keys 0, 1, 2
print_r($byId + [10 => 'z', 30 => 'c']);  // 10 => a, 20 => b, 30 => c

$kept = array_filter([1, 0, 2, 0, 3]);    // keys 0, 2, 4
echo json_encode($kept);                  // {"0":1,"2":2,"4":3}
echo json_encode(array_values($kept));    // [1,2,3]
Red flag to avoid:

Not knowing that array_merge renumbers integer keys, or expecting '8' and 8 to be different keys.

They may ask next:
  • How does array_merge treat string keys compared with the plus operator?
  • Why does an array of a million integers use far more memory in PHP than the raw numbers would need?
Say it in 60 seconds
Medium Coding round Fresher, Mid-level Practice question

4. Given an array of orders, return the total paid amount per customer, highest first. Use PHP's array functions.

What the interviewer is really testing:
Whether you can write clean, idiomatic array code with callbacks, and know which sort functions keep or drop keys.
Answer frame:

Filter: array_filter with an arrow function keeps only the paid orders.

Group and sum: array_reduce or a plain foreach builds a map of customer to total.

Sort: arsort sorts by value, highest first, and keeps the customer keys.

Sample spoken answer:

"I'd do it in three steps. First, array_filter with an arrow function to keep only orders whose status is paid. Arrow functions capture outer variables automatically, so they're neat for short callbacks. Second, I group and sum. I can use array_reduce with an empty array as the starting value, adding each amount under its customer's key, with the null coalescing operator so the first amount starts from zero. Honestly, a plain foreach is just as good here and some teams find it easier to read, so I'd match the codebase. Third, arsort, because I want to sort by value from highest to lowest but keep the customer names as keys. If I used rsort, I'd lose the keys. For a large data set I'd push this into SQL with GROUP BY and ORDER BY rather than pull every row into PHP."

Code:
$orders = [
    ['customer' => 'asha', 'amount' => 120, 'status' => 'paid'],
    ['customer' => 'ben',  'amount' => 80,  'status' => 'refunded'],
    ['customer' => 'asha', 'amount' => 45,  'status' => 'paid'],
    ['customer' => 'cleo', 'amount' => 200, 'status' => 'paid'],
];

$paid = array_filter($orders, fn(array $o) => $o['status'] === 'paid');

$totals = array_reduce($paid, function (array $acc, array $o) {
    $acc[$o['customer']] = ($acc[$o['customer']] ?? 0) + $o['amount'];
    return $acc;
}, []);

arsort($totals);   // by value, highest first, keys kept
print_r($totals);  // cleo => 200, asha => 165
Red flag to avoid:

Using rsort and losing the customer keys, or nesting loops for something one pass can do.

They may ask next:
  • What's the difference between sort, asort, ksort and usort?
  • How would you sort by total and then by customer name when two totals are equal?
Say it in 60 seconds
Hard Technical round Mid-level, Senior Practice question

5. Look at this snippet: a foreach by reference, then a plain foreach over the same array. Why does the last element end up changed?

What the interviewer is really testing:
Whether you understand how PHP references and loop variables really behave, and can explain a bug that looks impossible at first.
Answer frame:

The leak: after a by-reference foreach, the loop variable is still a reference to the last element.

The overwrite: the next foreach assigns each value to that same variable, so it writes into the last slot.

The fix: unset the variable right after the loop, or avoid by-reference loops altogether.

Sample spoken answer:

"In the first loop, the value variable is a reference to each element in turn, and when the loop ends it's still a reference to the last element. PHP doesn't scope loop variables to the loop, so that reference lives on. The second loop then assigns each value into that same variable, which means every pass writes into the last element. With 1, 2, 3, the last slot becomes 1, then 2, and on the final pass it's assigned to itself, so it stays 2. The array ends up as 1, 2, 2. The fix is to unset the variable straight after any by-reference loop. My own preference is to avoid by-reference loops: I either write back by key inside the loop, or I build a new array with array_map, which is much easier to reason about."

Code:
$items = [1, 2, 3];

foreach ($items as &$value) {
    // any by-reference loop
}
// unset($value); // the fix: break the reference

foreach ($items as $value) {
}

print_r($items); // [1, 2, 2]
Red flag to avoid:

Calling it a PHP bug, or saying arrays are always passed by reference.

They may ask next:
  • Are objects passed by reference in PHP? What's actually copied when you pass one to a function?
  • When does PHP really copy an array that you pass to a function?
Say it in 60 seconds
Medium Technical round Fresher, Mid-level Practice question

6. How does error handling work in modern PHP? What's the difference between an Error and an Exception?

What the interviewer is really testing:
Whether you know the Throwable hierarchy and handle failures on purpose, with safe settings for production.
Answer frame:

Hierarchy: Throwable has two branches: Exception for failures code expects, Error for engine problems like TypeError.

Handle: catch specific types, pass the original as previous when wrapping, use finally for cleanup.

Last resort: a global handler that logs the detail and returns a clean 500.

Production: display_errors off and log_errors on, so users never see stack traces.

Sample spoken answer:

"Since PHP 7, most fatal problems are thrown instead of just killing the script. At the top there's the Throwable interface, with two branches. Exception is for failures my code expects and might recover from, like a payment gateway timing out. Error is for engine-level problems, like a TypeError, calling a method on null, or dividing by zero. I catch specific exceptions where I can actually do something useful, and when I turn a low-level exception into a domain one, I pass the original as the previous argument so the log keeps the real cause. finally runs cleanup either way. I rarely catch Error. I let it reach a global handler, set with set_exception_handler or provided by the framework, which logs the full detail and returns a plain 500 page. In production display_errors is off and log_errors is on, so users never see file paths or stack traces."

Code:
final class PaymentFailed extends RuntimeException {}

function charge(Gateway $gateway, int $orderId): void
{
    try {
        $gateway->charge($orderId);
    } catch (GatewayTimeout $e) {
        throw new PaymentFailed("Order $orderId was not charged", 0, $e);
    }
}

set_exception_handler(function (Throwable $e): void {
    error_log((string) $e);      // full detail goes to the log
    http_response_code(500);
    echo 'Something went wrong.';
});
Red flag to avoid:

Wrapping everything in a catch of Throwable that quietly returns null, or leaving display_errors on in production.

They may ask next:
  • Warnings and notices aren't thrown. How do you make sure they don't go unnoticed?
  • When would you create your own exception classes, and how would you organise them?
Say it in 60 seconds

Web & APIs 3 questions

Easy Technical round Fresher Practice question

7. What are superglobals in PHP? Name the main ones and tell me which of them you can trust.

What the interviewer is really testing:
Whether you know where request data comes from in plain PHP, and whether you treat everything the client sends as untrusted.
Answer frame:

What they are: built-in arrays visible in every scope without the global keyword.

The main ones: _GET, _POST, _COOKIE, _FILES, _SERVER, _SESSION, _ENV, _REQUEST and GLOBALS.

Trust: anything the client sends, including most headers in _SERVER, is input to validate.

Habit: read once at the edge, validate, and pass clean values into the rest of the code.

Sample spoken answer:

"Superglobals are arrays PHP fills in for every request, and they're visible inside any function or class without declaring them global. The ones I use most are _GET for query string values, _POST for form bodies, _COOKIE, _FILES for uploads, _SERVER for details like the request method and URI, and _SESSION once a session has started. _REQUEST merges several sources depending on configuration, so I avoid it, because I want to know where a value came from. On trust, I treat nearly all of them as user input. Headers in _SERVER, like the host or the user agent, can be set by the client too. _SESSION is stored on the server, so its contents are mine, but the session ID pointing to it comes from a cookie. So I validate at the edge, with filter_var or a validation layer, and never pass a raw value straight into SQL or HTML."

Red flag to avoid:

Treating _SERVER headers or hidden form fields as trusted because the user 'can't see them'.

They may ask next:
  • Why is it risky to build a password reset link from the host header?
  • How is filter_input different from reading the _GET array directly?
Say it in 60 seconds
Easy Technical round Fresher, Mid-level Practice question

8. How do sessions work in PHP, and how are they different from cookies? What would you do to keep a login session secure?

What the interviewer is really testing:
Whether you know where session data actually lives, and the handful of settings that stop session hijacking and fixation.
Answer frame:

Cookie: data kept in the browser and sent back on every request; the user can read and change it.

Session: data kept on the server; the browser only holds a random session ID, usually in a cookie.

Hardening: HttpOnly, Secure and SameSite on the cookie, a new ID at login, and a destroyed session at logout.

Sample spoken answer:

"A cookie lives in the browser. The server sets it and the browser sends it back on every request, so the user can see and edit it. That's fine for a theme preference, but not for anything I need to trust. A session keeps the data on the server. When I call session_start, PHP looks for the session ID cookie and loads that session's data, or creates a new one. By default it's stored in files, but with several servers I'd move it to something shared, like Redis or the database. To keep a login safe, I set the session cookie to HttpOnly so scripts can't read it, Secure so it only travels over HTTPS, and SameSite so it isn't sent on most cross-site requests. I call session_regenerate_id right after login to stop session fixation, and on logout I clear the data and destroy the session."

Code:
session_start([
    'cookie_httponly' => true,
    'cookie_secure'   => true,
    'cookie_samesite' => 'Lax',
    'use_strict_mode' => true,
]);

if ($user && password_verify($password, $user['password_hash'])) {
    session_regenerate_id(true); // new ID, old one deleted
    $_SESSION['user_id'] = $user['id'];
}
Red flag to avoid:

Storing the user ID or role in a plain cookie and trusting it on the next request.

They may ask next:
  • Why can two requests from the same user seem to wait for each other when both use the session, and how do you fix it?
  • What is session fixation, exactly, and how does regenerating the ID stop it?
Say it in 60 seconds
Medium Coding round Mid-level Practice question

9. Without a framework, write a small PHP endpoint that accepts a JSON POST to create a user and returns proper JSON responses.

What the interviewer is really testing:
Whether you understand HTTP in PHP directly, meaning methods, request bodies, status codes and headers, which frameworks usually hide.
Answer frame:

Method: reject anything but POST with 405 and an Allow header.

Body: read php://input and decode with JSON_THROW_ON_ERROR; bad JSON gets a 400.

Validate: check the required fields and return 422 with an error per field.

Respond: 201 with the new resource, and a JSON Content-Type on every response.

Sample spoken answer:

"First I set the Content-Type header to application/json, so every response, errors included, is JSON. I check the request method in _SERVER, and if it isn't POST I return 405 with an Allow header. JSON bodies don't populate _POST, so I read the raw body from php://input and decode it with json_decode, passing JSON_THROW_ON_ERROR so bad JSON throws and I can answer 400. Then I validate: the email with filter_var, the name for length, and if anything fails I return 422 with an errors object naming each field. If it's valid, I insert with a prepared statement and return 201 with the new user's ID. I keep the status codes honest, because clients rely on them. In a real project I'd use a framework or at least a router, but this shows I understand what it does for me."

Code:
<?php
header('Content-Type: application/json');

function respond(int $status, array $body): never
{
    http_response_code($status);
    exit(json_encode($body));
}

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    header('Allow: POST');
    respond(405, ['error' => 'Method not allowed']);
}
try {
    $data = json_decode(file_get_contents('php://input'), true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
    respond(400, ['error' => 'Invalid JSON']);
}
$email = is_array($data) ? filter_var($data['email'] ?? '', FILTER_VALIDATE_EMAIL) : false;
if ($email === false) {
    respond(422, ['errors' => ['email' => 'A valid email is required']]);
}
// insert with a prepared statement, then:
respond(201, ['id' => $newId, 'email' => $email]);
Red flag to avoid:

Returning 200 with an error message in the body for every failure, or expecting a JSON body to appear in _POST.

They may ask next:
  • How would you add token authentication to this endpoint?
  • What status code would you return if the email is already registered, and why?
Say it in 60 seconds

Modern PHP 4 questions

Medium Technical round Mid-level, Senior Practice question

10. Which PHP 8 features do you actually use day to day, and why do they matter?

What the interviewer is really testing:
Whether you've written modern PHP rather than just read about it, and can explain the practical gain of each feature.
Answer frame:

Less boilerplate: constructor property promotion, named arguments and the nullsafe operator.

Safer logic: match with strict comparison and no fall-through, union types, static return types.

Later releases: enums, readonly properties and first-class callables in 8.1, readonly classes in 8.2.

Small helpers: str_contains, str_starts_with and str_ends_with instead of strpos tricks.

Sample spoken answer:

"The one I use most is constructor property promotion: I declare properties right in the constructor signature, which removes a lot of boilerplate from DTOs and services. Named arguments make calls with several optional parameters readable and let me skip the ones I don't need. The nullsafe operator lets me walk a chain like order, customer, address without a stack of null checks. I use match instead of switch almost everywhere, because it compares strictly, returns a value, doesn't fall through and throws if nothing matches. Union types make signatures honest. From 8.1, enums replaced my status constants, and readonly properties make value objects easy. Small things matter too: str_contains removes the old strpos-not-false check that people used to get wrong. The JIT is there as well, but it mostly helps CPU-heavy work, not a typical web request waiting on a database."

Code:
final class Point
{
    public function __construct(
        public readonly int $x = 0,   // promotion + readonly (8.1)
        public readonly int $y = 0,
    ) {}
}

$p = new Point(y: 5);            // named argument, x keeps its default

$size = match (true) {
    $total > 1000 => 'large',
    $total > 100  => 'medium',
    default       => 'small',
};

$city = $order?->customer?->address?->city; // null if any link is null
Red flag to avoid:

Listing features with no sense of where they help, or claiming the JIT makes every web app much faster.

They may ask next:
  • What does match do that switch doesn't, and when does it throw?
  • Has the JIT made a difference in any app you have worked on? How would you check?
Say it in 60 seconds
Medium Technical round Mid-level Practice question

11. How do enums and readonly properties from PHP 8.1 change the way you model something like an order status?

What the interviewer is really testing:
Whether you can use the type system to make invalid states hard to represent, instead of passing magic strings around.
Answer frame:

Before: a status as a string or class constant, so a typo or unknown value slips through.

Backed enum: a closed set of cases with a string or int value for storage; from and tryFrom convert back.

Behaviour on the enum: labels and rules live on the enum itself, often with match.

Readonly: a property set once, in the constructor, and never reassigned.

Sample spoken answer:

"Before 8.1 I'd store a status as a string, maybe with class constants, but any function could still receive 'payed' with a typo and nothing stopped it. Now I make a backed enum called Status, with cases like Paid and Refunded, each carrying the string I store in the database. A parameter typed as Status can only receive one of those cases. When I read a row, Status::from turns the string back into a case and throws a ValueError on an unknown value, while tryFrom returns null so I can handle it myself. I put behaviour on the enum too, like a label method using match. If I add a case and forget a branch, match throws an UnhandledMatchError, and static analysis usually flags it before that. Then readonly properties on the Order mean its ID and status are set once in the constructor and can't be quietly reassigned halfway through a request."

Code:
enum Status: string
{
    case Paid = 'paid';
    case Refunded = 'refunded';

    public function label(): string
    {
        return match ($this) {
            Status::Paid => 'Paid',
            Status::Refunded => 'Refunded',
        };
    }
}

final class Order
{
    public function __construct(
        public readonly int $id,
        public readonly Status $status,
    ) {}
}

$order = new Order(7, Status::from('paid'));
echo $order->status->label(); // Paid
Red flag to avoid:

Thinking readonly makes a nested object immutable, or storing case names instead of stable backing values in the database.

They may ask next:
  • If the order is readonly, how do you change its status, then?
  • What's the difference between a pure enum and a backed enum?
Say it in 60 seconds
Hard Technical round Mid-level, Senior Practice question

12. What does declare(strict_types=1) actually do, and whose setting counts when one file calls a function defined in another?

What the interviewer is really testing:
Whether you understand coercive versus strict typing precisely, including the scope rule that trips up mixed codebases.
Answer frame:

Default (coercive): scalar arguments are converted when possible, so '5' passes to an int parameter.

Strict: scalar types must match exactly; the only allowed widening is int to float.

Whose file: for arguments, the calling file decides; for return values, the file where the function is defined.

Scope: first statement of the file, per file, never inherited by other files.

Sample spoken answer:

"By default PHP uses coercive mode for scalar types. If a function takes an int and I pass the string '5', PHP converts it and carries on. With strict_types set to 1, that same call throws a TypeError, because scalar types have to match exactly. The one exception is passing an int where a float is expected, which is still allowed. The part people get wrong is scope. It's a per-file setting, it has to be the first statement, and for arguments what matters is the file that makes the call, not the file where the function lives. So a strict library called from a non-strict file still receives coerced arguments. Return types are checked using the mode of the file where the function is defined. I turn it on in every new file, because silent conversion hides bugs, and I add it to older files gradually, with tests."

Code:
<?php
declare(strict_types=1);

function twice(int $n): int
{
    return $n * 2;
}

echo twice(5);    // 10
echo twice('5');  // TypeError here; without strict_types it prints 10
Red flag to avoid:

Believing strict_types makes the whole application strict, or that it changes how == compares values.

They may ask next:
  • Does strict_types change how PHP compares values with == ?
  • Would you turn it on across a large legacy codebase in one go? How would you roll it out?
Say it in 60 seconds
Medium Technical round Fresher, Mid-level Practice question

13. How do namespaces and Composer autoloading work together? What happens when your code first uses App\Billing\Invoice?

What the interviewer is really testing:
Whether you understand how a class name becomes a file on disk, which is what you need to debug 'class not found' errors.
Answer frame:

Namespaces: group code and avoid name clashes; use statements import names.

PSR-4: composer.json maps a namespace prefix to a folder, so App\Billing\Invoice maps to src/Billing/Invoice.php.

Autoloader: vendor/autoload.php registers a loader with spl_autoload_register that requires the file on first use.

Production: an optimised autoloader uses a class map instead of checking the disk.

Sample spoken answer:

"Namespaces let two libraries both have a class called Client without clashing. At the top of a file I declare its namespace and import others with use statements. Autoloading connects that name to a file. In composer.json I add a PSR-4 rule saying the App prefix lives in the src folder. The first time my code touches App\Billing\Invoice, PHP calls the autoloader Composer registered with spl_autoload_register. It strips the prefix, turns the rest into a path, src/Billing/Invoice.php, and requires it. If the file isn't there, the namespace inside doesn't match, or a folder name has the wrong case on Linux, I get 'class not found'. After changing the autoload section I run composer dump-autoload. In production I install with the optimised autoloader, which builds a class map so lookups skip the file system checks."

Code:
// composer.json: "autoload": { "psr-4": { "App\\": "src/" } }
// then run: composer dump-autoload

// src/Billing/Invoice.php
namespace App\Billing;

final class Invoice
{
}

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

use App\Billing\Invoice;

$invoice = new Invoice(); // file is loaded here, on first use
Red flag to avoid:

Not knowing what PSR-4 maps, or fixing a 'class not found' error by adding a manual require.

They may ask next:
  • What's the difference between require and require-dev in composer.json?
  • Why should composer.lock be committed for an application?
Say it in 60 seconds

OOP 2 questions

Easy Technical round Fresher, Mid-level Practice question

14. When would you use an interface, an abstract class, or a trait in PHP? Give me an example of each.

What the interviewer is really testing:
Whether you can pick the right tool for sharing a contract versus sharing code, and know the limits of traits.
Answer frame:

Interface: a contract of public methods; a class can implement many, and you type-hint against it.

Abstract class: a partial base with shared state and code; a class can extend only one.

Trait: methods and properties pulled into unrelated classes, like language-level copy and paste; it isn't a type.

Sample spoken answer:

"I use an interface when I care about what something can do, not how. A PaymentGateway interface with a charge method lets me swap providers or pass a fake in tests, and a class can implement as many interfaces as it needs. I use an abstract class when several classes share real state and code and I want each one to fill in a gap, like a base report with a shared render method and an abstract query method. A class can only extend one parent, so I keep those hierarchies shallow. A trait is for a small piece of behaviour shared by classes that aren't related, like a timestamps helper. PHP pulls the trait's methods and properties into the class, but a trait isn't a type, so I can't type-hint it. If two traits define the same method, I resolve it with insteadof and as. My rule: type-hint interfaces, keep abstract classes small, use traits sparingly."

Code:
interface Exportable
{
    public function toArray(): array;
}

trait HasTimestamps
{
    public ?DateTimeImmutable $createdAt = null;

    public function touch(): void
    {
        $this->createdAt = new DateTimeImmutable();
    }
}

abstract class Report implements Exportable
{
    abstract protected function query(): array;

    public function toArray(): array
    {
        return ['rows' => $this->query()];
    }
}
Red flag to avoid:

Using traits everywhere as a way to fake multiple inheritance, or not knowing a class can implement several interfaces.

They may ask next:
  • Can a trait declare abstract methods or static properties?
  • Why is 'favour composition over inheritance' good advice in PHP, and when would you ignore it?
Say it in 60 seconds
Hard Technical round Mid-level, Senior Practice question

15. What's the difference between self:: and static:: in PHP? When does late static binding actually matter?

What the interviewer is really testing:
Whether you understand how PHP resolves class references at runtime, which shows up in factories, ORMs and fluent base classes.
Answer frame:

self:: always means the class where the method is written.

static:: means the class that was actually called at runtime, so the child when called on a child.

Where it matters: static factory methods, and base classes that create instances or read child constants.

Sample spoken answer:

"self is resolved to the class where the code is written. static is resolved to the class that was actually used for the call, which is why it's called late static binding. Say I have a base Model with a static create method. If it does new self, then User::create gives me a Model, which is wrong. If it does new static, User::create gives me a User. The same goes for constants and static properties: static::TABLE reads the child's constant, while self::TABLE always reads the base class's own. That's how ORMs let you call a finder on a child class and get child objects back. Since PHP 8, I can also declare static as a return type, so the signature says it returns whatever class I called it on, which helps IDEs and static analysis."

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

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

class User extends Model {}

var_dump(User::create());      // object(User)
var_dump(User::createSelf());  // object(Model)
Red flag to avoid:

Saying self and static are the same, or confusing static:: with static properties.

They may ask next:
  • What does parent:: refer to, and does a call through it keep the original called class?
  • Why can heavy use of static methods make PHP code harder to test?
Say it in 60 seconds

Databases 2 questions

Easy Coding round Fresher, Mid-level Practice question

16. Write the PHP code to fetch a user by email with PDO, safely. Which options do you set on the connection?

What the interviewer is really testing:
Whether you reach for prepared statements and a sensible PDO setup by default, not string-built queries.
Answer frame:

Connect: a DSN with the charset, errors as exceptions, a default fetch mode.

Prepare: placeholders in the SQL, values passed separately to execute.

Fetch: fetch returns one row, or false when nothing matches.

Sample spoken answer:

"I create the PDO connection once, with a DSN that includes the charset, utf8mb4 for MySQL, so encoding is set at the connection level. I set the error mode to exceptions so a failed query can't fail quietly. That's the default since PHP 8, but I still set it explicitly so the intent is clear. I set the default fetch mode to associative arrays, and I turn off emulated prepares so the database does real prepared statements. Then the query has a named placeholder for the email, and I pass the value to execute. The value never becomes part of the SQL text, so it can't change the structure of the query. fetch returns one row as an array, or false if nothing matched, so I check for that before using it."

Code:
$pdo = new PDO(
    'mysql:host=localhost;dbname=shop;charset=utf8mb4',
    $dbUser,
    $dbPass,
    [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        PDO::ATTR_EMULATE_PREPARES => false,
    ]
);

$stmt = $pdo->prepare('SELECT id, name, email FROM users WHERE email = :email');
$stmt->execute(['email' => $email]);
$user = $stmt->fetch(); // array, or false if no match

if ($user === false) {
    // not found
}
Red flag to avoid:

Concatenating the email into the SQL string, even 'just for a quick script'.

They may ask next:
  • Can you bind a table name or a column name as a parameter?
  • How would you insert a thousand rows efficiently with PDO?
Say it in 60 seconds
Medium Situational round Fresher, Mid-level Practice question

17. In code review you find a query built by concatenating a request parameter. The author says it's fine because only admins can reach that page. What do you do?

What the interviewer is really testing:
Whether you hold the line on security basics while staying constructive and specific with a teammate.
Answer frame:

Name the risk: admin accounts get phished, CSRF can make an admin send the request, and code gets copied.

Offer the fix: it's a small change to a prepared statement, so suggest the exact code.

Kind but firm: request changes, and if it keeps happening, add a team rule or a static analysis check.

Sample spoken answer:

"I'd block the merge, but I'd do it kindly and make the fix easy. I'd explain why admin-only isn't a safety net. Admin accounts get phished. A CSRF gap somewhere else could make an admin's browser send that request without them knowing. And code written for an admin page tends to get copied into public pages later. Even an honest admin can paste a value with a quote in it and break the query. Then I'd show the fix, which is usually a few minutes of work: a placeholder in the SQL and the value passed to execute, or the query builder if we use a framework, and an allowlist if it's a column name. If I see this pattern more than once, I'd suggest a rule in our static analysis or review checklist, so it isn't about one person getting it wrong."

Red flag to avoid:

Approving it because the page is internal, or silently rewriting their code without explaining why.

They may ask next:
  • What if the author is more senior than you and pushes back?
  • How would you check whether the same pattern exists elsewhere in the codebase?
Say it in 60 seconds

Security 4 questions

Medium Technical round Mid-level Practice question

18. Prepared statements stop SQL injection, but users can pick the sort column in a product list. How do you keep that part safe?

What the interviewer is really testing:
Whether you know the limits of prepared statements, since identifiers and SQL keywords can't be bound as parameters.
Answer frame:

The limit: placeholders carry values only; table names, column names and ASC or DESC can't be bound.

Allowlist: map the user's choice to a fixed set of real column names, with a safe default.

Keep binding values: everything else in the query still goes through placeholders.

Sample spoken answer:

"Placeholders only work for values. The database parses the SQL first and then plugs the values in, so a table name, a column name or the sort direction has to be in the SQL text itself. That's exactly where injection sneaks back in, when people concatenate a sort parameter straight into ORDER BY. So I use an allowlist. The request can say price or name, and I look that up in a small array that maps those words to real column names. Anything not in the map falls back to a default. The direction is ASC or DESC, chosen by my code, never copied from the request. Escaping isn't the answer here, because a column name isn't wrapped in quotes, so there's nothing for escaping to protect. The category filter in the same query still goes through a placeholder as usual."

Code:
$sortable = ['name' => 'name', 'price' => 'price', 'newest' => 'created_at'];
$sort = $_GET['sort'] ?? '';
$column = is_string($sort) ? ($sortable[$sort] ?? 'created_at') : 'created_at';
$direction = (($_GET['dir'] ?? '') === 'asc') ? 'ASC' : 'DESC';

$sql = "SELECT id, name, price FROM products
        WHERE category_id = :category
        ORDER BY $column $direction";

$stmt = $pdo->prepare($sql);
$stmt->execute(['category' => $categoryId]);
Red flag to avoid:

Saying prepared statements make every query safe, or trying to escape a column name instead of allowlisting it.

They may ask next:
  • Why isn't escaping input with a quoting function enough on its own?
  • Apart from ORDER BY, where else can injection hide in a query that uses placeholders?
Say it in 60 seconds
Medium Technical round Fresher, Mid-level Practice question

19. How do you prevent cross-site scripting in a PHP app that renders HTML with plain PHP templates?

What the interviewer is really testing:
Whether you escape on output for the right context, rather than cleaning input once and hoping for the best.
Answer frame:

Escape on output: htmlspecialchars with ENT_QUOTES and UTF-8 for anything printed into HTML.

Context matters: HTML text, attributes, URLs and JavaScript each need their own encoding.

Defence in depth: a templating engine that escapes by default, a Content Security Policy, HttpOnly cookies.

Sample spoken answer:

"XSS happens when user data ends up in a page as markup or script. My main rule is to escape at the moment of output, not when saving, because the same value might go into HTML, JSON or an email. In a plain PHP template I wrap every echo in a small helper that calls htmlspecialchars with ENT_QUOTES and UTF-8, so quotes are encoded too and nobody can break out of an attribute. Context matters, though. For a value inside a URL I use urlencode. If a user supplies a whole link, I also check it starts with http or https, because a javascript: link survives HTML escaping untouched. For data going into a script block I use json_encode with the hex flags instead of building strings by hand. On top of that I prefer a templating engine that escapes by default, and I add a Content Security Policy so an injected script has a much harder time running."

Code:
function e(string $value): string
{
    return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
?>
<p>Hello, <?= e($name) ?></p>
<input type="text" name="city" value="<?= e($city) ?>">
<a href="/search?q=<?= urlencode($query) ?>">Search again</a>
Red flag to avoid:

Sanitising input once on the way in and then echoing it anywhere, or forgetting that quotes matter inside attributes.

They may ask next:
  • An admin editor needs to allow bold text and links in product descriptions. How do you allow some HTML safely?
  • Why doesn't strip_tags count as proper XSS protection?
Say it in 60 seconds
Easy Technical round Fresher, Mid-level Practice question

20. How should a PHP app store user passwords, and how do you check one when the user logs in?

What the interviewer is really testing:
Whether you know the built-in password API, and why fast hashes like md5 or sha1 are the wrong tool for passwords.
Answer frame:

Hash: password_hash with PASSWORD_DEFAULT; it adds a random salt and uses a deliberately slow algorithm.

Verify: password_verify compares a login attempt with the stored hash.

Upgrade: password_needs_rehash moves old hashes to stronger settings at the next login.

Storage: a column wide enough, like VARCHAR(255), because the hash length can grow.

Sample spoken answer:

"I never store the password itself, and I never use md5 or sha1 for it. Those are built to be fast, and fast is exactly what an attacker wants when trying billions of guesses. PHP has a password API for this. At sign-up I call password_hash with PASSWORD_DEFAULT. It generates a random salt, uses a slow algorithm, currently bcrypt, and returns one string holding the algorithm, the cost, the salt and the hash. I store that in a VARCHAR(255) column, because the default may change in later versions. At login I fetch the hash by email and call password_verify with what the user typed. If it passes, I also call password_needs_rehash, and if the settings have changed, I hash again and save it, so the whole user base upgrades itself over time. I also show the same error for a wrong email and a wrong password."

Code:
// sign-up
$hash = password_hash($plain, PASSWORD_DEFAULT); // store in VARCHAR(255)

// login
if ($row && password_verify($plain, $row['password_hash'])) {
    if (password_needs_rehash($row['password_hash'], PASSWORD_DEFAULT)) {
        $newHash = password_hash($plain, PASSWORD_DEFAULT);
        // save $newHash for this user
    }
    // log the user in
} else {
    // same message for unknown email and wrong password
}
Red flag to avoid:

Suggesting md5 or sha256 with a salt, or inventing your own hashing scheme.

They may ask next:
  • Bcrypt only uses the first 72 bytes of a password. Does that matter, and what would you do about it?
  • How would you slow down someone trying thousands of passwords against one account?
Say it in 60 seconds
Hard Situational round Mid-level, Senior Practice question

21. You inherit an app that stores passwords as unsalted md5 hashes. How do you move to proper hashing without forcing everyone to reset?

What the interviewer is really testing:
Whether you can plan a real security migration that protects every user now, not only the ones who happen to log in again.
Answer frame:

Protect everyone now: run password_hash over each stored md5 value at once and flag it as legacy.

Upgrade on login: check md5 of the typed password against the wrapped hash, then store a normal hash of the real password.

Clean up: after a set time, force resets for accounts still flagged and delete the legacy path.

Sample spoken answer:

"Waiting for people to log in isn't enough, because anyone who never comes back keeps a weak md5 hash in the table. So I'd do it in two steps. First, a one-off migration that runs password_hash over every stored md5 value and saves the result with a flag saying it's a wrapped legacy hash. After that, nothing in the table is a bare md5. Second, at login, if the flag is set, I take md5 of the password the user typed and check it with password_verify against the stored hash. If it matches, I immediately hash the real password with password_hash, save it and clear the flag. After a few months I'd force a reset for accounts still flagged, then delete the legacy code. I'd also ask whether the old hashes might already have leaked, because if they did, users need to be told and every password reset."

Red flag to avoid:

Forcing every user to reset overnight with no plan, or leaving bare md5 hashes in place until each user happens to log in.

They may ask next:
  • Why is wrapping the old hashes better than only upgrading at login?
  • How would you test this migration before running it on the real user table?
Say it in 60 seconds

Performance 4 questions

Hard Technical round Mid-level, Senior Practice question

22. Walk me through what happens when a request hits a PHP app behind Nginx and PHP-FPM. What does 'shared nothing' mean for your code?

What the interviewer is really testing:
Whether you understand PHP's execution model, which explains why static variables don't persist between requests and where caches must live.
Answer frame:

Path: the web server passes the request over FastCGI to a free FPM worker, which runs the front controller.

Per request: the app boots, handles one request, responds, and then everything it created is freed.

What survives: OPcache's compiled code, and anything outside PHP like the database, Redis or files.

Design impact: no in-memory state between requests; the worker pool size caps how many run at once.

Sample spoken answer:

"Nginx handles the connection and serves static files itself. For a PHP route it forwards the request over FastCGI to PHP-FPM, which keeps a pool of worker processes. A free worker takes the request and runs index.php, which loads the autoloader, boots the app, routes, runs the controller and sends the response. Then everything that request created is thrown away. That's shared nothing: two requests never share variables, not even static ones. It's a big reason PHP scales out so easily across servers. The costs are that the app boots on every request, which is why OPcache matters so much, and that any state I want to keep, like cached values, sessions or rate-limit counters, has to live in Redis, the database or somewhere outside the process. The FPM pool size also caps how many requests run at once, so one slow endpoint can tie up workers and starve the rest."

Red flag to avoid:

Thinking a static property or a singleton keeps data between different users' requests under normal PHP-FPM.

They may ask next:
  • How would you decide how many FPM workers to run on a server?
  • Some runtimes keep a PHP app in memory between requests. What changes in how you write code then?
Say it in 60 seconds
Medium Technical round Mid-level, Senior Practice question

23. What does OPcache do, and how would you configure it on a production server?

What the interviewer is really testing:
Whether you know the biggest free performance win in PHP, and the deployment catch that comes with it.
Answer frame:

What it caches: the compiled opcodes of each script, in shared memory, so PHP skips parsing and compiling.

Production settings: enough memory and file slots for the whole codebase, and timestamp checks off.

Deploy catch: with timestamp checks off, reload PHP-FPM on every release.

Built on top: preloading and the JIT both rely on OPcache.

Sample spoken answer:

"Every time PHP runs a file, it has to parse it and compile it into opcodes before executing it. OPcache keeps those compiled opcodes in shared memory, so every worker in the pool reuses them and the compile step disappears after the first request. It's usually the biggest single speed-up you get for free. In production I make sure it has enough memory and enough file slots for the whole codebase, including the vendor folder, otherwise it fills up and stops helping. I also turn off timestamp validation, so PHP doesn't check every file for changes on every request. The catch is that new code isn't picked up until the cache is cleared, so the deploy script reloads PHP-FPM. I check the hit rate and memory use with opcache_get_status. Preloading and the JIT build on top of it, but plain OPcache is the part every app should have."

Code:
; production php.ini
opcache.enable=1
opcache.memory_consumption=256
opcache.max_accelerated_files=20000
; code only changes on deploy, so stop checking file times
opcache.validate_timestamps=0
Red flag to avoid:

Confusing OPcache with caching query results, or expecting deploys to 'eventually' pick up new code.

They may ask next:
  • How is OPcache different from a data cache like APCu or Redis?
  • When would the JIT actually help a PHP application?
Say it in 60 seconds
Hard Coding round Mid-level, Senior Practice question

24. You need to process a log file of several gigabytes in PHP without running out of memory. How would you do it?

What the interviewer is really testing:
Whether you can process large data by streaming it, and understand how generators keep memory flat.
Answer frame:

Avoid: file or file_get_contents, which load the whole file into memory.

Stream: open the file and read it one line at a time with fgets.

Generator: wrap the loop in a function that yields each line, so callers use a normal foreach.

Output in batches: aggregate as you go or write in chunks, never one giant array.

Sample spoken answer:

"The mistake is calling file or file_get_contents, which loads everything into memory and hits memory_limit straight away. I'd open the file with fopen and read it one line at a time with fgets. To keep that tidy, I wrap it in a generator: a function that opens the file, loops, and yields each line. The caller just writes a foreach, but only one line is in memory at a time, because the generator pauses at each yield and resumes when the loop asks for the next value. I close the handle in a finally block, so it's released even if the caller stops early. The other half is what I do with the results. If I collect them into one huge array, I've recreated the problem, so I aggregate as I go, or insert in batches of a few hundred rows inside transactions. I'd run it as a CLI job, not a web request, and log progress."

Code:
function readLines(string $path): Generator
{
    $handle = fopen($path, 'r');
    if ($handle === false) {
        throw new RuntimeException("Cannot open $path");
    }
    try {
        while (($line = fgets($handle)) !== false) {
            yield rtrim($line, "\r\n");
        }
    } finally {
        fclose($handle);
    }
}

$errors = 0;
foreach (readLines('/var/log/app/big.log') as $line) {
    if (str_contains($line, 'ERROR')) {
        $errors++;
    }
}
echo $errors;
Red flag to avoid:

Raising memory_limit as the fix, or reading the whole file first and then looping over it.

They may ask next:
  • How would you make the job safe to restart if it dies halfway through?
  • From the caller's side, what changes when a function yields values instead of returning an array?
Say it in 60 seconds
Medium Situational round Mid-level, Senior Practice question

25. You deploy a fix, the files on the server are new, but users still see the old behaviour. Where do you look first?

What the interviewer is really testing:
Whether you can reason about the caching layers in a PHP stack instead of redeploying and hoping.
Answer frame:

Confirm what's running: a version endpoint or log line that shows which release is executing.

OPcache: with timestamp checks off, PHP-FPM keeps serving old compiled code until it's reloaded.

Other layers: framework caches, a CDN or proxy, the browser, and servers that missed the deploy.

Prevent it: make the reload and cache clearing part of the deploy script.

Sample spoken answer:

"First I'd confirm what's really executing, not what's on disk. A version endpoint or a log line with the release number tells me quickly. If the old code is running, my first suspect is OPcache. In production we usually turn off timestamp validation, so PHP-FPM keeps the old compiled files in memory until it's reloaded. Calling opcache_reset from the command line doesn't help, because the CLI has its own cache, separate from FPM's. So I reload PHP-FPM gracefully, which lets requests in flight finish. If we deploy by switching a symlink, PHP's realpath cache can also keep pointing at the old folder, and the reload clears that too. If it still isn't fixed, I check the other layers: framework config or route caches that need rebuilding, a CDN or proxy serving cached responses, and whether every server behind the load balancer really got the release. Then the reload goes into the deploy script."

Red flag to avoid:

Redeploying the same files again and again, or restarting everything without checking what's actually running.

They may ask next:
  • How would you structure deploys so a release can be rolled back in seconds?
  • Why is a graceful reload better than a hard restart during busy hours?
Say it in 60 seconds

Laravel 2 questions

Easy Technical round Fresher, Mid-level Practice question

26. In Laravel, how does a request get from a URL to a controller, and where does middleware fit in?

What the interviewer is really testing:
Whether you have hands-on Laravel basics, meaning routes, controllers, middleware and route model binding, without needing framework internals.
Answer frame:

Route: a routes file maps an HTTP method and URI to a controller action.

Middleware: layers that run before and after the controller, like auth, CSRF checks or rate limits.

Binding: a typed model parameter in the action loads the record from the URI, or returns 404.

Response: the controller returns a view, a redirect or JSON, which goes back out through the middleware.

Sample spoken answer:

"The request comes in through public/index.php, Laravel boots, and the router matches the method and URL against the routes files, web routes for pages and api routes for JSON if the project has them. A route points at a controller action. Before the controller runs, the request passes through middleware. Some runs on every request, some belongs to the web or api group, and some I attach to specific routes, like auth to make sure the user is logged in. Each middleware can stop the request, for example redirecting a guest to the login page, or let it through and even change the response on the way back. In the controller, if I type-hint a model like Order and the route has an order parameter, route model binding fetches it by ID and returns a 404 on its own when it doesn't exist. The controller then returns a view, a redirect or JSON."

Code:
// routes/web.php
use App\Http\Controllers\OrderController;
use Illuminate\Support\Facades\Route;

Route::middleware('auth')->group(function () {
    Route::get('/orders', [OrderController::class, 'index']);
    Route::get('/orders/{order}', [OrderController::class, 'show']);
});

// app/Http/Controllers/OrderController.php
public function show(Order $order)
{
    return view('orders.show', ['order' => $order]); // 404 if not found
}
Red flag to avoid:

Checking login by hand inside every controller method instead of using middleware.

They may ask next:
  • How would you write a middleware that blocks users whose account is suspended?
  • What's the difference between a route parameter and a query string value inside a controller?
Say it in 60 seconds
Medium Technical round Mid-level Practice question

27. What is the N+1 query problem in Eloquent, and how do you spot it and fix it?

What the interviewer is really testing:
Whether you understand what the ORM does underneath, since lazy loading in loops is the most common Laravel performance bug.
Answer frame:

The problem: one query for the list, then one more per row when you touch a relation.

Fix: eager load with with(), or load() on a collection you already have; withCount for counts.

Spot it: watch the query count per page, and make lazy loading throw in development.

Sample spoken answer:

"Eloquent loads relations lazily. If I fetch 50 posts and then print each post's author name in a loop or a template, Eloquent runs one query for the posts and then one query per post for its author. That's 51 queries where two would do, and it gets worse with nested relations. The fix is eager loading. Post::with('author') runs the posts query plus one more that fetches all the needed authors with a WHERE IN, then matches them up in memory. If I already have the collection, I call load on it. For counts I use withCount instead of loading whole relations. To find the problem, I look at the number of queries per page in a debug toolbar or the query log. In development I also turn on Model::preventLazyLoading, so lazy loading throws an error and the problem shows up in tests instead of in production."

Code:
// N+1: one query for posts, then one per post
$posts = Post::latest()->take(50)->get();
foreach ($posts as $post) {
    echo $post->author->name;
}

// Eager loaded: two queries in total
$posts = Post::with('author')->latest()->take(50)->get();

// AppServiceProvider::boot(), outside production
Model::preventLazyLoading(! app()->isProduction());
Red flag to avoid:

Not knowing that touching a relation inside a loop runs a query each time.

They may ask next:
  • What is mass assignment protection in Eloquent, and what goes wrong without it?
  • When would you drop down to the query builder or raw SQL instead of Eloquent?
Say it in 60 seconds

Real Work 3 questions

Hard Behavioral round Mid-level, Senior Practice question

28. Tell me about a production bug in a PHP application that was hard to reproduce. How did you track it down?

What the interviewer is really testing:
Whether you debug a PHP stack methodically with logs and evidence, and fix both the cause and the gap that let it through.
Answer frame:

Situation: the symptom, who was affected, and why it was hard to reproduce.

Evidence: logs, request IDs and what the failing requests had in common.

Fix: the root cause, plus a test or guard so it can't come back.

Lesson: what you changed in process or monitoring afterwards.

Sample spoken answer:

"At my last company, some customers were getting logged out at random in the middle of checkout, and it never happened for us locally. I started with the logs and added the session ID and the server name to each log line. The pattern showed up within a day: every failed request had landed on a different app server from the one before it. We'd recently added a second server behind the load balancer, but sessions were still stored in local files, so the second server didn't know the user. The quick fix that afternoon was sticky sessions on the load balancer. The real fix was moving sessions to Redis, which I tested in staging by forcing requests to alternate between servers. Afterwards I added a checklist for new servers covering anything kept on local disk, like sessions, uploads and caches, because that was the real lesson."

Red flag to avoid:

A story that ends with 'we restarted the server and it went away', with no root cause and no prevention.

They may ask next:
  • What would you log on every request by default, and what would you keep out of the logs?
  • Looking back, how could this have been caught before it reached customers?
Say it in 60 seconds
Medium Behavioral round Mid-level, Senior Practice question

29. Have you upgraded an application to a newer major version of PHP? What broke, and how did you manage the risk?

What the interviewer is really testing:
Whether you can plan a risky platform change: find breakage early, keep releases safe and bring the team along.
Answer frame:

Inventory: the PHP version, extensions and Composer packages, and which versions each supports.

Find breakage early: static analysis, automated refactoring tools, and the test suite on the new version in CI.

Roll out safely: staging first, then one server, with an easy rollback.

Learn: the surprises, and what you would do first next time.

Sample spoken answer:

"At my last company I moved a large app from PHP 7.4 to 8.1. I started with Composer, because several packages had no release that supported PHP 8, so some had to be upgraded first and one had to be replaced. Then I ran a static analyser and an automated refactoring tool to catch the obvious problems, and added the new version to CI so the tests ran on both. What actually broke was code the tests didn't cover. The PHP 8 change to comparing numbers with strings broke a check that compared a status code string with 0. And in 8.1, passing null to built-in string functions started raising deprecation notices, which flooded our logs until we fixed them. We rolled out to one server behind the load balancer, watched the error logs for a few days, then did the rest. Next time I'd add static analysis to CI much earlier."

Red flag to avoid:

Upgrading straight in production, or claiming nothing broke without saying how you checked.

They may ask next:
  • How did you decide which deprecation warnings to fix now and which could wait?
  • How would you convince a manager to spend time on an upgrade that adds no new features?
Say it in 60 seconds
Medium Behavioral round Mid-level, Senior Practice question

30. Describe a PHP endpoint you sped up in production. What did profiling show, and what change made the biggest difference?

What the interviewer is really testing:
Whether you measure before optimising and can tell database, network and PHP-level bottlenecks apart.
Answer frame:

Measure: a baseline response time and a profile, not a guess.

Find the cause: query counts, slow queries, external calls or heavy PHP loops.

Fix and prove: the change, the before and after numbers, and a way to notice if it slips back.

Sample spoken answer:

"At my last company our checkout summary API took about four seconds, and it got worse with bigger carts. Before changing anything I measured it. I profiled a request on staging with Xdebug's profiler and added timing logs around the external calls. The PHP code itself was quick. Nearly all the time went on waiting for a shipping rates service that we called once per cart item, one after another. The service had a batch endpoint, so I switched to a single call for the whole cart, cached the rates in Redis for a few minutes keyed by the cart contents, and set a short timeout with a fallback message so a slow partner couldn't hang our checkout. Response time dropped to well under a second for normal carts. I also added that timing to our monitoring, so if the service slows down we see it on a dashboard before customers complain."

Red flag to avoid:

Optimising code by gut feeling, with no before and after measurement.

They may ask next:
  • How do you decide how long something can safely be cached?
  • What would you use to profile PHP in production, where a debugger-based profiler is too heavy?
Say it in 60 seconds
Were you asked something else? Share it A person checks every question before it goes on the site. No name is shown.
For the call itself

The questions above are the prep. The call has ten more.

ClapAssist is an AI interview assistant for Mac and Windows. It listens to the interview on your computer and shows you what to say, in short lines you can read while you talk. Your resume and notes are never stored on our servers. It stays out of screen share on every plan; only you can see it.

Download ClapAssist with 10 free minutes
Mac and Windows · Stays out of screen share · No card