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.
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.
"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."
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
Saying == and === differ only in speed, or not knowing that loose comparison converts types before it compares.
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.
"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."
Getting the warning versus fatal error behaviour backwards, or building an include path from user input.
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.
"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."
$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]
Not knowing that array_merge renumbers integer keys, or expecting '8' and 8 to be different keys.
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.
"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."
$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
Using rsort and losing the customer keys, or nesting loops for something one pass can do.
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.
"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."
$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]
Calling it a PHP bug, or saying arrays are always passed by reference.
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.
"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."
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.';
});
Wrapping everything in a catch of Throwable that quietly returns null, or leaving display_errors on in production.
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.
"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."
Treating _SERVER headers or hidden form fields as trusted because the user 'can't see them'.
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.
"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."
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'];
}
Storing the user ID or role in a plain cookie and trusting it on the next request.
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.
"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."
<?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]);
Returning 200 with an error message in the body for every failure, or expecting a JSON body to appear in _POST.
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.
"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."
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
Listing features with no sense of where they help, or claiming the JIT makes every web app much faster.
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.
"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."
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
Thinking readonly makes a nested object immutable, or storing case names instead of stable backing values in the database.
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.
"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."
<?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
Believing strict_types makes the whole application strict, or that it changes how == compares values.
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.
"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."
// 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
Not knowing what PSR-4 maps, or fixing a 'class not found' error by adding a manual require.
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.
"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."
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()];
}
}
Using traits everywhere as a way to fake multiple inheritance, or not knowing a class can implement several interfaces.
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.
"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."
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)
Saying self and static are the same, or confusing static:: with static properties.
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.
"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."
$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
}
Concatenating the email into the SQL string, even 'just for a quick script'.
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.
"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."
Approving it because the page is internal, or silently rewriting their code without explaining why.
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.
"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."
$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]);
Saying prepared statements make every query safe, or trying to escape a column name instead of allowlisting it.
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.
"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."
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>
Sanitising input once on the way in and then echoing it anywhere, or forgetting that quotes matter inside attributes.
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.
"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."
// 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
}
Suggesting md5 or sha256 with a salt, or inventing your own hashing scheme.
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.
"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."
Forcing every user to reset overnight with no plan, or leaving bare md5 hashes in place until each user happens to log in.
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.
"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."
Thinking a static property or a singleton keeps data between different users' requests under normal PHP-FPM.
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.
"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."
; 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
Confusing OPcache with caching query results, or expecting deploys to 'eventually' pick up new code.
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.
"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."
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;
Raising memory_limit as the fix, or reading the whole file first and then looping over it.
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.
"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."
Redeploying the same files again and again, or restarting everything without checking what's actually running.
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.
"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."
// 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
}
Checking login by hand inside every controller method instead of using middleware.
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.
"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."
// 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());
Not knowing that touching a relation inside a loop runs a query each time.
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.
"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."
A story that ends with 'we restarted the server and it went away', with no root cause and no prevention.
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.
"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."
Upgrading straight in production, or claiming nothing broke without saying how you checked.
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.
"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."
Optimising code by gut feeling, with no before and after measurement.
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.