PHP interviews often begin by establishing which PHP you have written, because the language changed substantially between version 5 and version 8. Expect questions on loose versus strict comparison, prepared statements and injection defence, session handling behind a load balancer, interfaces, abstract classes and traits, Composer and PSR-4 autoloading, and the PHP 8 features that changed how modern code is written. Security and performance judgement feature throughout. The questions below cover all of it.
Behavioural Questions
1. Tell me about a PHP project you have worked on. What was your role and what was challenging?
Note: PHP interviews often start here because the ecosystem spans everything from a 2005 procedural codebase to modern Laravel. The interviewer is placing you on that spectrum.
Cover:
- What it was and its scale. Traffic, database size, number of developers. Say whether it was greenfield or an existing system, because they are entirely different skills.
- The stack, honestly. PHP version, framework or none, database, and how it was deployed. If it was legacy procedural PHP, say so — having survived that is a credential, not an embarrassment.
- The hard part. Good candidates: an upgrade from PHP 5 to 7 or 8, N+1 queries under load, a session or caching problem that only appeared on multiple servers, or introducing tests to a codebase that had none.
- The outcome, with a number. Response time, error rate, or deploy frequency before and after.
2. How do you approach working on a legacy PHP codebase with no tests and outdated practices?
The answer they are screening against is "rewrite it". Show you can improve incrementally.
- Get it running locally and under version control first. Surprisingly often that is the real first task.
- Add characterisation tests around what you must touch. Not a full suite — just enough to know whether your change altered behaviour. These tests document what the code does, which may differ from what anyone thinks it does.
- Fix security before style. SQL built by string concatenation, unescaped output, and an unsupported PHP version are urgent. Inconsistent brace placement is not.
- Upgrade PHP one version at a time, using a static analyser such as PHPStan or Rector to find what will break before it does.
- Introduce structure at the edges. Composer for autoloading, then move logic into classes as you touch it, rather than in a dedicated refactor nobody will approve.
Note: The strangler pattern is worth naming: route new features through a modern framework alongside the legacy application and migrate pages gradually, so the old system shrinks instead of being replaced in one risky push.
3. Describe a time you found and fixed a performance problem in a PHP application.
Tell it as measurement, diagnosis, fix, verification — and be specific about which layer was slow, because the causes are different.
- How you found it. Slow request logs, an APM tool such as New Relic or Blackfire, or the MySQL slow query log. Say what the actual number was.
- Where the time was going. In most PHP applications it is the database, not PHP. Profiling with Xdebug or Blackfire tells you rather than leaving you to guess.
- The fix. Common high-value ones: eliminating an N+1 query by eager loading, adding a missing index, caching an expensive computation in Redis, enabling OPcache, or moving a slow third-party call into a queued job.
- What it bought you, and the monitoring you added so it would not silently regress.
Note: The most impressive version of this story involves resisting a premature optimisation — measuring first and discovering the bottleneck was somewhere nobody expected. Interviewers hear "I optimised the loop" constantly and "I measured and the loop was irrelevant" rarely.
4. How do you handle code review and maintain quality standards in a PHP team?
Split it into what machines enforce and what humans discuss — the distinction is the point.
Automated, in CI:
- PHP-CS-Fixer or PHP_CodeSniffer against PSR-12, so formatting is never a review comment.
- PHPStan or Psalm at a level the team agrees, raised over time. Static analysis catches a large class of bug in a dynamically typed language.
- PHPUnit with the suite required to pass before merge.
Human review then focuses on what tools cannot judge: whether the approach fits the system, whether edge cases are handled, whether the naming will make sense in a year, and whether there is a security implication.
How to give review well: mark clearly which comments block the merge and which are suggestions; ask questions where you might lack context; keep pull requests small enough to actually read.
Note: If you have introduced any of this to a team that lacked it, lead with that. Raising a PHPStan level from 0 to 5 across a codebase is a concrete, credible achievement.
5. PHP has a mixed reputation. How do you decide when it is the right tool, and how do you keep your skills current?
Answer this directly rather than getting defensive — the interviewer usually wants to see whether you evaluate tools honestly.
Where PHP is genuinely a strong choice: request-response web applications, content-driven sites, and anything where a mature framework and a huge hosting ecosystem matter. PHP 8 with JIT, real type declarations, enums, and readonly properties is a very different language from PHP 5, and Laravel and Symfony are as capable as their equivalents elsewhere.
Where you would pick something else: long-lived connections and real-time work, where Node or Go fit the model better; heavy numerical or machine-learning work, where the Python ecosystem is decisive; CPU-bound services where a compiled language pays off.
How you stay current: the RFCs on the PHP wiki, which show where the language is going before it arrives; release notes each version; and the Laravel or Symfony changelogs. Most usefully, running a static analyser at a high level teaches you a lot about your own assumptions.
Note: Saying "the PHP of 2012 deserved its reputation and the PHP of today does not" is a fair, credible position — and demonstrates you know what changed.
6. Tell me about a time you discovered a security vulnerability in a PHP application. How did you handle it?
The interviewer is checking three things: whether you recognise real vulnerabilities, whether you act responsibly under pressure, and whether you fix the cause rather than one symptom. Use STAR, and keep the technical detail precise.
- Situation. Name the flaw and where it was. For example: ‘While adding a filter to the admin reports page, I noticed the date range was concatenated straight into the SQL string, which was a SQL injection reachable by any logged-in staff user.’ Other good examples are an insecure direct object reference on an invoice download URL, or user input echoed without escaping.
- Assess severity quickly. Who could exploit it, what data was exposed, and was there any sign it had been used? Checking access logs for suspicious patterns shows maturity.
- Contain first. Tell your lead straight away, rather than quietly fixing it and moving on. If it was serious, disable the feature or add a temporary block while you work on the fix.
- Fix the root cause. Move to prepared statements with bound parameters, add an authorisation check on the resource, or escape output by context. Add a regression test that proves the attack no longer works.
- Look for siblings. The same pattern usually exists elsewhere. Say how you searched, such as grepping for string-built queries or running Psalm’s taint analysis, and how many other instances you fixed.
- Prevent recurrence. A code review checklist item, a static analysis rule in CI, or a short knowledge-sharing session for the team.
If customer data may have been exposed, mention involving management and security early, because Indian rules such as CERT-In’s six-hour incident reporting direction and the DPDP Act can create legal obligations.
Note: Never describe testing an exploit against production data without permission. Responsible handling is part of what is being assessed.
7. Describe how you upgraded an application to a new major PHP version, such as 7.4 to 8.x. How did you reduce the risk?
This question separates developers who have done a real upgrade from those who have only read about one. Show a plan with clear phases and numbers.
- Inventory. Start with dependencies, because they usually block the upgrade. Commands such as
composer why-not php 8.2show which packages need newer versions, and some may need replacing if they are abandoned. - Automated analysis. Run PHPCompatibility (a PHP_CodeSniffer standard) to flag removed and changed features, and PHPStan at a sensible level. Use Rector to apply mechanical changes, such as converting to constructor promotion or fixing deprecated calls, in reviewable commits.
- Know the behaviour changes. Mention a few specific ones: the PHP 8.0 change to comparisons between numbers and non-numeric strings, internal functions throwing
TypeErrorinstead of returning null with a warning, null passed to non-nullable internal parameters being deprecated in 8.1, and dynamic properties deprecated in 8.2. - CI on both versions. Run the test suite against the old and the new version in parallel until everything is green on both. If coverage was thin, say you added characterisation tests around the riskiest flows, such as checkout and login, first.
- Staged rollout. Staging with production-like data, then one production server or a small percentage of traffic, while watching error logs and deprecation notices, then the rest. Keep the old runtime ready for a quick rollback.
Finish with results: ‘We moved 180,000 lines from 7.4 to 8.2 in six weeks with no customer-facing incidents. Response times improved by about 15%, and we removed two abandoned packages along the way.’
Note: Mentioning that you enabled deprecation logging in production beforehand, so the next upgrade would be easier, shows you think beyond the immediate task.
8. A product manager wants a feature shipped by Friday, but building it properly in your PHP codebase would take two weeks. How do you handle it?
The interviewer wants to see that you can negotiate trade-offs openly, not that you always say yes or always insist on perfection. A strong answer has four parts.
- Understand the real deadline. Ask what Friday is for. Is it a client demo, a marketing campaign, or a regulatory date? A demo might only need the happy path for one customer, while a sale launching to every user needs much more.
- Offer options, with costs. Present two or three concrete choices instead of one estimate. For example:
- Ship a reduced scope by Friday: the core flow behind a feature flag, with admin editing done directly in the database for now.
- Ship the full version in two weeks.
- Ship the reduced scope now and finish the rest in the following sprint.
- Be clear about what is not negotiable. Shortcuts on polish, admin tooling or refactoring are fine. Shortcuts on security, data integrity and payments are not. Say this plainly: ‘I can skip the bulk upload screen, but I will not skip CSRF protection or server-side validation on the payment form.’
- Make the debt visible. Write down what was deferred, create tickets with owners and dates, and agree when the clean-up happens. Hidden shortcuts become permanent.
Then give an outcome, for example: ‘We launched the referral feature for the campaign with a simplified reward rule behind a flag. It brought in 4,000 signups that week, and the full rules engine shipped eight days later from the ticket we had agreed on.’
Note: Avoid framing the product manager as the obstacle. Interviewers want someone who treats the deadline as a shared problem and brings engineering judgement to it.
9. Tell me about a production bug in a PHP application that you could not reproduce locally. How did you track it down?
This tests your debugging method more than your knowledge of any single bug. Walk through how you narrowed it down, and show that you treated ‘works on my machine’ as a clue rather than a dead end.
- Gather evidence. Error tracking such as Sentry, application logs, request IDs, timestamps, and which users, servers or browsers were affected. A pattern such as ‘only after deploys’ or ‘only for large carts’ is often the breakthrough.
- List the environment differences. Compare PHP version and extensions,
php.inisettings (memory limit, upload sizes, time zone), OPcache configuration, locale, real data volumes, concurrency, the load balancer and session storage, and third-party services running in sandbox mode. - Form a hypothesis and test it cheaply. Add targeted logging around the suspect code, reproduce with a copy of production data that has personal details masked, or replay the exact request.
- Fix it and prove it. Write a regression test that fails without the fix.
Example stories that land well:
- A race condition. Duplicate orders appeared a few times a day. Locally, one browser tab never triggered it. Logs showed two requests 80 ms apart from a double-clicked button. The fix was a unique constraint on an idempotency key, plus disabling the button after the first click.
- Stale OPcache. After a symlink-swap deploy, some servers ran old code because
opcache.validate_timestamps=0and PHP-FPM was not reloaded. The fix was adding a graceful FPM reload to the deploy script. - Time zones. A report was wrong only between midnight and 5:30 a.m. IST, because the server used UTC and the code used local dates.
Close with the prevention step: a new alert, a deploy check, or better logging with correlation IDs.
Note: Quantify the impact and the time to resolution. ‘Found in two days after three weeks of intermittent reports’ tells the interviewer a lot about your persistence.
10. How would you mentor a junior developer whose PHP code works but is insecure or hard to maintain?
The interviewer wants to see that you can raise standards without crushing confidence. Show a method, not just ‘I would review their code’.
- Start privately and specifically. Pick the most important issue, not all twenty. ‘This query builds SQL from
$_GET, so here is how it can be exploited’ teaches more than a review with forty comments. - Show why, not just what. Demonstrate the risk safely on a local or staging copy: inject a payload into the unsafe query, or show a stored XSS popping an alert. Seeing it happen makes the rule stick far better than being told.
- Pair on the fix. Rewrite one function together using prepared statements, output escaping with
htmlspecialchars(), or smaller classes with clear names. Then let them do the next one alone and review it. - Give them tools and references. A short team checklist covering input validation, output escaping, CSRF, authorisation and error handling. Point them to the OWASP Top 10 and PHP The Right Way. Set up PHP-CS-Fixer and PHPStan so style and obvious bugs are caught automatically, which keeps reviews focused on design.
- Increase responsibility step by step. Ask them to review someone else’s pull request against the checklist. Explaining a concept to others is how they really learn it.
- Track progress. Notice improvement and say so, both to them and in their review.
A concrete example helps: ‘A junior on my team was building admin pages with inline SQL. After two pairing sessions and a checklist, their next module passed review with only minor comments, and three months later they were the one flagging a missing CSRF token in someone else’s PR.’
Note: Mention that you keep feedback about the code, not the person. Interviewers listen for patience and for mentoring that scales beyond one conversation.
Technical Questions
11. What is the difference between == and === in PHP, and how does type juggling work?
=== compares type and value. == converts the operands to a common type first, following rules that produce genuinely surprising results.
PHP 8 fixed the worst of it. Comparing a number to a non-numeric string used to cast the string to a number, so 0 == "foo" was true — a real authentication bypass in the wild. Since PHP 8, the number is cast to a string instead, so that comparison is now false.
What still catches people:
"1" == "01"istrue— two numeric strings are compared as numbers."10" == "1e1"istrue, for the same reason.null == false,0 == false, and"" == falseare alltrue.[] == falseistrue.
The practical rule: use === by default. It is not a style preference — loose comparison against user input has caused real security vulnerabilities, particularly when comparing tokens or hashes. For those, use hash_equals(), which is also timing-safe.
Note: Be ready for in_array() as a follow-up. Its third parameter enables strict comparison, and without it in_array(0, ['a','b']) behaved surprisingly on older versions.
12. How do you prevent SQL injection in PHP, and what is the difference between PDO and MySQLi?
Prepared statements with bound parameters are the answer. The SQL structure is sent to the database separately from the values, so a value can never be interpreted as SQL.
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = ? AND status = ?');
$stmt->execute([$email, $status]);
$user = $stmt->fetch();Escaping functions such as mysqli_real_escape_string are a weaker fallback — they depend on the correct connection charset and are easy to apply inconsistently. Never build SQL with string concatenation or interpolation of user input.
PDO versus MySQLi:
- PDO supports twelve database drivers, so your data layer is portable. It has a consistent object API, named placeholders, and can throw exceptions on error.
- MySQLi is MySQL-only but exposes MySQL-specific features such as asynchronous queries.
PDO is the usual recommendation unless you need something MySQL-specific.
Note: Two configuration points matter. Set PDO::ATTR_ERRMODE to ERRMODE_EXCEPTION so failures are not silently ignored, and set PDO::ATTR_EMULATE_PREPARES to false so the database does the preparing rather than the driver — emulated prepares are not equivalent protection.
13. What is the difference between include, require, include_once and require_once?
All four pull in another PHP file. They differ on two axes: what happens on failure, and whether repetition is allowed.
include— emits a warning if the file is missing and continues execution.require— emits a fatal error and halts.include_once/require_once— the same, but PHP tracks which files it has already loaded and skips a repeat.
Which to use: require for anything the script cannot run without — a configuration file, a class definition, a database connection. include only for genuinely optional content, such as a sidebar template. Continuing after a missing class definition produces a cascade of confusing errors instead of one clear one.
The _once variants prevent "cannot redeclare function" fatal errors when a file is reachable through more than one path. They carry a small overhead because PHP must check the loaded-file list.
Note: The right answer in modern PHP is that you rarely write any of these. Composer's PSR-4 autoloader loads classes on demand, so a single require of vendor/autoload.php replaces hundreds of manual includes.
14. How do sessions work in PHP, and what problems appear when you run multiple servers?
session_start() looks for a session id in a cookie. If there is none, PHP generates one, sets the cookie, and creates a store; if there is, PHP loads the matching data into $_SESSION. The data lives on the server; only the id travels to the browser.
By default the store is a file in the server's temp directory. That is precisely what breaks behind a load balancer: a user's first request writes a session file on server A, their second request goes to server B, which finds nothing, and they appear logged out at random.
The fixes, in order of preference:
- A shared session store — Redis or Memcached via
session.save_handler. Fast, and scales to any number of servers. This is the standard answer. - A database handler — slower but durable and easy to inspect.
- Sticky sessions at the load balancer — the least good option, because it undermines even load distribution and loses sessions when an instance is replaced.
Security points worth adding: call session_regenerate_id(true) immediately after login to prevent session fixation, and set the cookie httponly, secure, and samesite.
15. Explain object-oriented programming in PHP: interfaces, abstract classes and traits.
PHP gives you three ways to share structure, and the interview question is usually about choosing between them.
- Interface — a contract of method signatures with no implementation. A class may implement many. Use it to say what a class can do, so unrelated classes can be used interchangeably.
- Abstract class — a partial implementation that cannot be instantiated. It may hold state, constructors, and concrete methods alongside abstract ones. A class may extend only one. Use it for an "is-a" relationship where subclasses genuinely share behaviour.
- Trait — a block of methods copied into a class at compile time. It is PHP's answer to the lack of multiple inheritance. Use it for behaviour reused across classes with no natural common ancestor — a logging helper, a timestamp helper.
How to choose: prefer an interface for the contract and a trait for shared implementation, and reach for an abstract class when there is a real hierarchy. "Program to an interface" is what makes code testable, because you can substitute a fake.
Note: Traits have a real cost — they hide dependencies, since a trait method may rely on a property the using class must provide. Overusing them produces classes whose behaviour is scattered across five files.
16. How do you handle errors and exceptions in PHP 8, and what changed from earlier versions?
PHP historically had two parallel systems — traditional errors and exceptions — and PHP 7 unified them. Most fatal errors now throw Error, and both Error and Exception implement Throwable.
try {
$result = $service->process($input);
} catch (ValidationException $e) {
return $this->badRequest($e->getMessage());
} catch (Throwable $e) {
$this->logger->error($e->getMessage(), ['exception' => $e]);
return $this->serverError();
} finally {
$this->cleanup();
}What matters in practice:
- Catch
Throwable, notException, when you want a genuine catch-all —TypeErrorandDivisionByZeroErrorareErrors and will slip pastException. - Define your own exception types.
PaymentDeclinedExceptionlets callers handle one failure differently from another; throwing a genericExceptionforces string matching on messages. - Set an exception handler and an error handler at the application's entry point so nothing escapes unlogged, and convert warnings into
ErrorExceptionso they cannot be ignored. - Never show a stack trace to a user.
display_errorsoff in production,log_errorson.
Note: PHP 8 also made many previously-silent warnings into errors — accessing an undefined array key, for example — which is why an upgrade surfaces bugs that were always there.
17. What is the difference between GET and POST, and how do you handle form input securely in PHP?
GET sends data in the URL query string. It is visible, bookmarkable, logged by servers and proxies, cached, and length-limited. Use it for retrieving — searches, filters, pagination — because repeating it must be safe.
POST sends data in the request body. It is not in the URL, not cached by default, and effectively unlimited in size. Use it for anything that changes state.
Note: POST is not secure by itself. Both are plaintext without HTTPS. The reason not to put a password in a GET request is that it lands in browser history and server logs, not that POST is encrypted.
Handling input securely — the layers:
- Validate on the server, always. Client-side validation is a convenience, not a control. Check type, length, format, and range with
filter_varor a validation library. - Escape at the point of use, differently for each context. Prepared statements for SQL;
htmlspecialchars($v, ENT_QUOTES, 'UTF-8')for HTML output;json_encodefor JavaScript. There is no single sanitising function that makes input universally safe. - Use a CSRF token on every state-changing form, compared with
hash_equals. - For file uploads, verify the real MIME type rather than trusting the extension, cap the size, rename the file, and store it outside the web root.
18. What is Composer and how does autoloading work in PHP?
Composer is PHP's dependency manager. composer.json declares what you need with version constraints; composer.lock records the exact versions actually installed, so every environment gets an identical dependency tree.
Note: Commit composer.lock for an application; do not commit it for a library. And composer install honours the lock file while composer update resolves fresh versions and rewrites it — using update on a deploy is a common and dangerous mistake.
Autoloading means classes load on demand instead of being manually included. When PHP encounters an unknown class, it calls the registered autoloader, which maps the class name to a file path and includes it.
{
"autoload": {
"psr-4": { "App\\": "src/" }
}
}Under PSR-4, App\Service\Payment resolves to src/Service/Payment.php. Namespace separators become directory separators, and the class name must match the filename exactly — including case, which is why code works on a case-insensitive Mac and breaks on a Linux server.
In production, run composer install --no-dev --optimize-autoloader, which builds a static class map so no filesystem lookup is needed per class.
19. How would you improve the performance of a slow PHP application?
Measure first — the bottleneck is rarely where people assume. Profile with Blackfire or Xdebug, and check the database's slow query log before touching PHP at all.
Then work through the layers in order of typical payoff:
- The database, almost always first. Fix N+1 queries by eager loading, add the missing index that
EXPLAINreveals, and select only the columns you need rather thanSELECT *. - OPcache. It caches compiled bytecode so PHP does not re-parse every file on every request. Enabling it is often the single largest win, and it is off by default in some configurations.
- Application caching. Redis or Memcached for expensive computations, rendered fragments, and configuration. Have a clear invalidation strategy before you add it.
- Move slow work out of the request. Emails, report generation, image processing, and third-party API calls belong in a queue.
- HTTP-level caching. Correct
Cache-ControlandETagheaders, a CDN for static assets, and gzip or brotli compression. - PHP itself. Upgrading from PHP 7 to 8 is a meaningful free improvement, and preloading and JIT help specific workloads.
Note: Micro-optimisations such as single versus double quotes are noise. Say so — it demonstrates a sense of proportion.
20. What new features did PHP 7 and PHP 8 introduce that changed how you write code?
The language changed substantially, and interviewers use this to date your experience.
PHP 7 brought:
- Scalar type declarations and return types, plus
declare(strict_types=1)to make them enforced rather than coerced. - The null coalescing operator
??, replacing theisset() ? :dance. - The spaceship operator
<=>for comparison functions. - Errors as exceptions, so fatal errors became catchable.
- Roughly double the performance of PHP 5.6, which is why the upgrade was worth doing on its own.
PHP 8 brought:
- Constructor property promotion — declaring and assigning properties in the signature, which removes a great deal of boilerplate.
- Named arguments, so
createUser(admin: true)is readable at the call site. - Union types (
int|string), andreadonlyproperties in 8.1. - Enums in 8.1 — real type-safe enumerations instead of class constants.
- Match expressions, which return a value and use strict comparison with no fall-through.
- The nullsafe operator
?->, and attributes for structured metadata instead of docblock parsing. - JIT compilation, which helps CPU-bound work more than typical web requests.
Note: The most useful thing to say is which of these changed your habits. Promoted constructors, enums, and readonly properties together make immutable value objects genuinely pleasant, which was awkward before.
21. What is late static binding in PHP, and how does static:: differ from self::?
self:: refers to the class where the method was written, and it is fixed when the code is compiled. static:: refers to the class that was actually called at runtime. Resolving it late is what ‘late static binding’ means, and it was added in PHP 5.3.
class Model
{
protected static string $table = 'models';
public static function tableSelf(): string
{
return self::$table;
}
public static function tableStatic(): string
{
return static::$table;
}
public static function create(): static
{
return new static();
}
}
class User extends Model
{
protected static string $table = 'users';
}
echo User::tableSelf(); // models
echo User::tableStatic(); // users
var_dump(User::create()); // object(User)Where you see it:
- Factory methods and fluent APIs.
new static()creates an instance of the subclass, and thestaticreturn type (PHP 8.0) tells static analysers thatUser::create()returns aUser, not just aModel. - Active Record ORMs. Eloquent calls such as
User::query()andUser::find(1)are defined once in the base model and rely on late static binding to know which model and table they are working with. static::classgives the called class name, replacing the olderget_called_class().
Pitfalls:
new static()breaks if a subclass changes its constructor’s required parameters. Mark the constructorfinal, or define it in an interface, if a base class relies on it.- Calls through
parent::andself::are ‘forwarding’ calls: they keep the original called class. A call made by class name, such asModel::method(), resets it.
Note: A quick way to explain it in an interview: self means ‘this file’s class’, static means ‘whoever you called me on’.
22. How are method conflicts between two traits resolved in PHP, and what can a trait contain besides methods?
If a class uses two traits that define a method with the same name, PHP raises a fatal error unless you resolve the conflict explicitly with insteadof. You can keep the other implementation under a new name with as, which can also change visibility.
trait FileLogger
{
public function log(string $msg): void
{
file_put_contents('/var/log/app.log', $msg . PHP_EOL, FILE_APPEND);
}
}
trait AuditTrail
{
public function log(string $msg): void
{
$this->auditRows[] = $msg;
}
}
final class OrderService
{
use FileLogger, AuditTrail {
FileLogger::log insteadof AuditTrail;
AuditTrail::log as protected audit;
}
private array $auditRows = [];
}Precedence rules: a method defined in the class overrides a trait method, and a trait method overrides one inherited from a parent class.
What else a trait can hold:
- Properties. If the class declares a property with the same name, it must be compatible: same visibility, type and initial value, otherwise it is a fatal error.
- Abstract methods. These force the using class to provide something the trait needs, such as
abstract protected function tableName(): string; - Static methods and static properties. Each class that uses the trait gets its own copy of a static property, so counters are not shared between them.
- Constants, from PHP 8.2 onwards.
What a trait is not: it is not a type. You cannot type-hint against a trait or use instanceof with one. The common pattern is to pair a trait with an interface: the interface defines the contract, and the trait provides a default implementation, as in class Invoice implements HasUuid { use GeneratesUuid; }. class_uses() lists the traits a class uses directly.
Note: Traits are a form of compile-time copy and paste. They are useful for small cross-cutting behaviour, but a class with many traits hides its real dependencies, so composition is often clearer.
23. How does PHP resolve class, function and constant names inside a namespace, and how do use imports and aliases work?
PHP recognises three forms of name:
- Unqualified:
User. Resolved relative to the current namespace and anyuseimports. - Qualified:
Models\User. The current namespace (or an import ofModels) is prefixed. - Fully qualified:
\App\Models\User. The leading backslash means ‘from the global root’, exactly as written.
namespace App\Services;
use App\Models\User;
use App\Models\Invoice as InvoiceModel;
use function App\Support\format_inr;
use const App\Support\GST_RATE;
final class Billing
{
public function run(User $user): InvoiceModel
{
$now = new \DateTimeImmutable(); // global class
$n = strlen($user->name); // falls back to global
echo format_inr(100 * GST_RATE);
return new InvoiceModel();
}
}The rule that trips people up: for classes, there is no fallback. Inside App\Services, new DateTime() means App\Services\DateTime and fails with ‘Class not found’. You must write \DateTime or add use DateTime;. For unqualified functions and constants, PHP first looks in the current namespace and then falls back to the global one, which is why strlen() works without a backslash.
Other points worth mentioning:
useis resolved at compile time and does not load anything. Autoloading happens only when the class is actually used.User::classproduces the fully qualified name as a string, with no autoloading. It is ideal for container bindings and route definitions.- Group imports keep files tidy:
use App\Models\{User, Invoice, Order}; - Namespaces are a language feature. Mapping them to folders is a convention implemented by PSR-4 autoloading, not something PHP enforces.
- Fully qualifying or importing some core functions, such as
\strlen()and\count(), lets the compiler replace them with faster special opcodes, which is why some coding standards require it.
Note: If asked why namespaces exist, say they prevent name collisions between your code and Composer packages, and they replaced long names such as Zend_Db_Table_Abstract.
24. What are the most important PSR standards, and how do they help PHP projects and packages work together?
PSRs (PHP Standards Recommendations) are published by the PHP-FIG, the Framework Interoperability Group. Some standardise coding style. The more important ones define shared interfaces, so a library can depend on an interface and work with any framework or implementation.
| PSR | What it standardises |
|---|---|
| PSR-1 and PSR-12 | Basic and extended coding style. PSR-12 has since been succeeded by the PER Coding Style. |
| PSR-4 | Autoloading: mapping a namespace prefix to a base directory |
| PSR-3 | Logger interface (Psr\Log\LoggerInterface) |
| PSR-7 and PSR-17 | Immutable HTTP request and response objects, and factories to create them |
| PSR-15 | HTTP server request handlers and middleware |
| PSR-18 | HTTP client interface |
| PSR-11 | Dependency injection container interface (get and has) |
| PSR-6 and PSR-16 | Caching: a detailed pool API, and a simple key-value API |
| PSR-14 | Event dispatcher |
| PSR-20 | Clock interface, which makes time easy to fake in tests |
Why they matter in practice:
use Psr\Log\LoggerInterface;
final class PaymentService
{
public function __construct(private LoggerInterface $logger) {}
public function charge(int $paise): void
{
$this->logger->info('Charging', ['paise' => $paise]);
}
}This class works with Monolog, Laravel’s logger or a test double, because it depends only on the PSR-3 interface. The same goes for an SDK that accepts any PSR-18 client, so you can plug in Guzzle or Symfony’s HTTP client.
Style standards matter for teams: consistent formatting makes diffs smaller and reviews focus on logic. Enforce them automatically with PHP-CS-Fixer or PHP_CodeSniffer in CI rather than by hand.
Note: Knowing that PSR-0 (the old autoloading standard) and PSR-2 are deprecated, replaced by PSR-4 and PSR-12, is a small detail that signals you keep up to date.
25. How do you run several database writes atomically with PDO, and how should you handle a failure halfway through?
Wrap the writes in a transaction: beginTransaction(), then commit() if every step succeeds, or rollBack() if any step fails. With PDO::ERRMODE_EXCEPTION set, any failed query throws, so a try and catch block is the natural structure.
function transfer(PDO $pdo, int $from, int $to, int $paise): void
{
$pdo->beginTransaction();
try {
$debit = $pdo->prepare(
'UPDATE wallets SET balance = balance - ?
WHERE user_id = ? AND balance >= ?'
);
$debit->execute([$paise, $from, $paise]);
if ($debit->rowCount() !== 1) {
throw new RuntimeException('Insufficient balance');
}
$pdo->prepare('UPDATE wallets SET balance = balance + ? WHERE user_id = ?')
->execute([$paise, $to]);
$pdo->prepare('INSERT INTO ledger (from_id, to_id, paise) VALUES (?, ?, ?)')
->execute([$from, $to, $paise]);
$pdo->commit();
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e; // let the caller log it or show an error
}
}Points a strong answer includes:
- Catch
Throwable, roll back, rethrow. Swallowing the exception hides the failure. Rolling back only onPDOExceptionmisses your own business-rule exceptions. - The conditional debit (
balance >= ?) plus therowCount()check prevents overdrafts even with concurrent requests, without a separate SELECT. - Deadlocks and serialisation failures (SQLSTATE 40001, MySQL error 1213) roll back the whole transaction. The correct response is to retry the entire function a few times, not just the failed statement.
- Keep transactions short. Never call a payment gateway or send an email while the transaction is open, because you hold row locks the whole time.
- Engine and DDL caveats. MyISAM tables ignore transactions, and DDL statements commit implicitly, after which
rollBack()can do nothing.
Note: In Laravel, DB::transaction(function () { ... }, 3) does the begin, commit, rollback and deadlock retry for you. It is worth mentioning, along with what it does under the hood.
26. How do you bind a variable-length list for an IN clause with PDO, and how do you safely handle dynamic column names or ORDER BY?
Placeholders stand for values only. They cannot stand for a list, a table name, a column name or a keyword such as ASC. Each case needs its own technique.
1. A variable-length IN list: generate one placeholder per value.
function findUsers(PDO $pdo, array $ids): array
{
$ids = array_values(array_filter($ids, 'is_int'));
if ($ids === []) {
return []; // 'IN ()' is a syntax error
}
$marks = implode(',', array_fill(0, count($ids), '?'));
$stmt = $pdo->prepare("SELECT id, name FROM users WHERE id IN ($marks)");
$stmt->execute($ids);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}Only question marks are interpolated into the SQL, never the values themselves, so this remains fully parameterised.
2. Dynamic column names, sort direction or table names: use an allowlist. Map what the user sends to identifiers you control, and fall back to a safe default.
$sortable = [
'name' => 'u.name',
'joined' => 'u.created_at',
'city' => 'u.city',
];
$column = $sortable[$_GET['sort'] ?? ''] ?? 'u.created_at';
$dir = strtolower($_GET['dir'] ?? '') === 'asc' ? 'ASC' : 'DESC';
$sql = "SELECT u.id, u.name FROM users u ORDER BY $column $dir LIMIT :lim";
$stmt = $pdo->prepare($sql);
$stmt->bindValue(':lim', min((int) ($_GET['per'] ?? 20), 100), PDO::PARAM_INT);
$stmt->execute();Escaping identifiers with backticks is not a real defence on its own. The allowlist is what makes it safe.
3. LIMIT and OFFSET: bind them as integers with PDO::PARAM_INT. With emulated prepares switched on, passing them through execute([...]) sends quoted strings, giving invalid SQL such as LIMIT '20'.
4. LIKE searches: bind the whole pattern, as in $stmt->execute(['%' . $term . '%']). If users must not be able to use wildcards, escape % and _ in $term first.
Note: Query builders such as Laravel’s whereIn() generate the placeholders for you, but orderBy() with raw user input still needs an allowlist. Frameworks do not make identifiers safe automatically.
27. How do cookies work in PHP, and which attributes should you set on a session or authentication cookie?
setcookie() adds a Set-Cookie header to the response. The browser stores the cookie and sends it back on later requests, where PHP exposes it in $_COOKIE. Two consequences follow:
- It is a header, so it must be sent before any output, or you get ‘headers already sent’.
- A cookie you set is not in
$_COOKIEuntil the next request.
setcookie('remember_token', $token, [
'expires' => time() + 60 * 60 * 24 * 30,
'path' => '/',
'secure' => true,
'httponly' => true,
'samesite' => 'Lax',
]);The options-array form has existed since PHP 7.3 and is the only clean way to set SameSite.
Attributes for anything sensitive:
- HttpOnly: JavaScript cannot read the cookie, so an XSS bug cannot simply steal the session ID.
- Secure: only sent over HTTPS.
- SameSite=Lax (or
Strict): not sent on most cross-site requests, which is strong protection against CSRF.Noneis required for genuine cross-site use, such as an embedded widget, and must be combined withSecure. - Domain and Path: leave
domainunset for a host-only cookie. Setting.example.comshares it with every subdomain. A parent-domain cookie with the same name can also shadow your own, which causes confusing login bugs. The__Host-name prefix forces Secure, Path=/ and no Domain.
Session cookies are configured through php.ini or session_set_cookie_params(): session.cookie_httponly=1, session.cookie_secure=1, session.cookie_samesite=Lax and session.use_strict_mode=1. A custom session_name() avoids clashes with other apps on the same domain.
Security rules: cookies are user-controlled input. Never store roles, prices or user IDs in them unsigned. Store a random token and look it up on the server, or sign and encrypt the value, which Laravel does by default. Browsers limit cookies to about 4 KB each. To delete a cookie, set it again with a past expiry and the same path and domain.
Note: For ‘remember me’, store only a hash of the token in the database, the same way you store passwords, so a database leak does not hand out valid login cookies.
28. How do you prevent cross-site scripting in PHP, and why does the output context change how you escape data?
XSS happens when untrusted data reaches a page and the browser interprets it as code. The core rule is to escape on output, for the context you are writing into. Validating input helps, but it cannot replace output escaping, because the same value may be safe in one place and dangerous in another.
function e(?string $s): string
{
return htmlspecialchars($s ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
<p>Hello, <?= e($name) ?></p>
<input value='<?= e($city) ?>'>
<script>
const user = <?= json_encode($user, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
</script>
<a href='/search?q=<?= e(rawurlencode($q)) ?>'>Search</a>Contexts and what each needs:
- HTML body and quoted attributes:
htmlspecialchars()withENT_QUOTES, so both quote styles are encoded, and an explicit UTF-8 charset. Always quote attributes, because an unquoted attribute can be broken out of with a single space. - Inside a script block: HTML escaping is the wrong tool. Emit data with
json_encode()and the HEX flags, so a value containing a closing script tag cannot end the block. - URLs:
rawurlencode()each component. For user-supplied links, also check the scheme ishttporhttps, becausejavascript:URLs survive HTML escaping. - CSS and inline event handlers: avoid placing user data there at all.
When users are allowed to submit HTML, such as a rich-text job description, escaping would destroy it. Use a proper sanitiser such as HTML Purifier with an allowlist of tags. strip_tags() is not a security control, because it leaves attributes such as onerror on allowed tags.
Defence in depth:
- Template engines that escape by default: Blade’s
{{ }}and Twig both do. Treat every use of Blade’s raw output syntax as something to justify in code review. - A
Content-Security-Policyheader that blocks inline scripts. HttpOnlysession cookies, so XSS cannot read them directly.
Note: From PHP 8.1, htmlspecialchars() uses ENT_QUOTES and ENT_SUBSTITUTE by default, but passing them explicitly keeps older code and reviewers clear about intent.
29. How does a CSRF attack work, and how would you implement CSRF protection in plain PHP without a framework?
The attack. A user is logged in to your site. They visit a malicious page, which silently submits a form to your site, such as POST /account/email. The browser attaches your session cookie automatically, so your code sees an authenticated request and changes the email address. The attacker never sees the response and does not need to. They only need the side effect.
The standard defence is a synchroniser token: a random secret stored in the session, embedded in every form, and checked on every state-changing request. A different site cannot read your pages, so it cannot learn the token.
// Once per session, for example after login
if (empty($_SESSION['csrf'])) {
$_SESSION['csrf'] = bin2hex(random_bytes(32));
}
$token = $_SESSION['csrf'];
// In every form
<input type='hidden' name='csrf' value='<?= htmlspecialchars($token) ?>'>
// Before handling any POST, PUT, PATCH or DELETE
function verify_csrf(): void
{
$sent = $_POST['csrf'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!is_string($sent) || !hash_equals($_SESSION['csrf'] ?? '', $sent)) {
http_response_code(419);
exit('Session expired. Please reload the page.');
}
}Details that matter:
random_bytes(), notrand()oruniqid(), which are predictable.hash_equals()for a timing-safe comparison.- Fail closed. A missing token is a failure, not a skip.
- Never change state on GET. Tokens protect forms, but a GET link that deletes something can be triggered by an image tag.
- AJAX: put the token in a meta tag and send it in an
X-CSRF-Tokenheader. - Regenerate the token at login, together with
session_regenerate_id(true).
Extra layers: SameSite=Lax session cookies stop most cross-site POSTs in modern browsers, and checking the Origin header against your own host adds another barrier. Treat both as defence in depth, not replacements, because older browsers and some edge cases do not honour them.
Note: Stateless APIs that authenticate with an Authorization header rather than cookies are not exposed to CSRF in the same way, because browsers do not attach that header automatically.
30. How should a PHP application store and verify user passwords, and how do you upgrade old MD5 or SHA-1 hashes?
Use the built-in password API. It handles salting, algorithm choice and timing-safe comparison for you.
// Registration
$hash = password_hash($password, PASSWORD_DEFAULT);
// store $hash in a VARCHAR(255) column
// Login
if (!password_verify($password, $user['password_hash'])) {
// same generic message whether the email or the password was wrong
throw new AuthException('Invalid credentials');
}
if (password_needs_rehash($user['password_hash'], PASSWORD_DEFAULT)) {
$newHash = password_hash($password, PASSWORD_DEFAULT);
// UPDATE users SET password_hash = ? WHERE id = ?
}
session_regenerate_id(true);Why this is right:
PASSWORD_DEFAULTis currently bcrypt. The resulting string contains the algorithm, the cost and a random salt, so you store nothing else.PASSWORD_ARGON2IDis available where PHP was built with Argon2 support.- Hashes are deliberately slow. MD5 and SHA-256 are fast, which lets attackers try billions of guesses per second on a GPU. Salted SHA-256 is still far too fast.
- The column must be
VARCHAR(255), because future algorithms produce longer strings. password_needs_rehash()lets you raise the cost or change the algorithm over time: users are rehashed as they log in. PHP 8.4 raised bcrypt’s default cost from 10 to 12.
Upgrading a legacy MD5 or SHA-1 table:
- Wrap every old hash immediately, without waiting for logins:
password_hash($oldMd5Hash, PASSWORD_DEFAULT), plus a flag column recording that it is wrapped. No weak hash is left at rest. - At login, for flagged users verify with
password_verify(md5($password), $stored). On success, store a normalpassword_hash($password)and clear the flag. - After a few months, force a password reset for any accounts that never logged in.
Other good practice: rate-limit login attempts, never log or email passwords, never encrypt passwords (encryption is reversible), and remember that bcrypt uses only the first 72 bytes of input.
Note: If asked about comparing hashes yourself with ===, explain that password_verify() already does a timing-safe comparison, and the only manual case is hash_equals() for tokens.
31. How do enums work in PHP 8.1, and what is the difference between pure enums and backed enums?
An enum is a type with a fixed set of possible values, called cases. Before 8.1, PHP developers used class constants, which allowed any string to slip through. An enum makes invalid states impossible to pass around, and type declarations enforce it.
Pure enums have cases with no underlying value:
enum Suit
{
case Hearts;
case Spades;
}
function colour(Suit $s): string { return $s === Suit::Hearts ? 'Red' : 'Black'; }Backed enums give each case an int or string value, which is what you need for databases, JSON and forms:
enum OrderStatus: string
{
case Pending = 'pending';
case Paid = 'paid';
case Shipped = 'shipped';
public function label(): string
{
return match ($this) {
self::Pending => 'Awaiting payment',
self::Paid => 'Payment received',
self::Shipped => 'On the way',
};
}
}
$status = OrderStatus::from($row['status']); // throws ValueError if unknown
$maybe = OrderStatus::tryFrom('refunded'); // returns null
echo $status->value; // 'paid'
echo $status->label();
$all = OrderStatus::cases(); // array of every caseWhat enums can and cannot do:
- They can have methods, static methods and constants, and they can implement interfaces.
- They cannot have properties (state), be created with
new, or extend or be extended. - Each case is a singleton object, so compare with
===. Every case has a read-onlyname, and backed cases also have avalue. - A
matchon an enum with no default arm throwsUnhandledMatchErrorif a new case is added and not handled. Static analysers such as PHPStan flag this even earlier.
In practice: store the backed value in a VARCHAR or TINYINT column and convert at the boundary. Laravel supports this directly with model attribute casts, such as 'status' => OrderStatus::class, and with enum validation rules.
Note: Use tryFrom() for untrusted input such as query strings, and from() for data your own system wrote, where an unknown value really is a bug.
32. What do readonly properties and readonly classes do in PHP, and how do you change a value on an immutable object?
Readonly properties (PHP 8.1) can be assigned once, from inside the class, and never changed afterwards. Any later write throws an Error: ‘Cannot modify readonly property’.
- They must have a type declaration.
- They cannot have a default value, because initialisation is the one allowed write. Promoted constructor parameters are the usual way to set them.
Readonly classes (PHP 8.2) make every declared property readonly and forbid dynamic properties. They are ideal for value objects and DTOs.
final readonly class Money
{
public function __construct(
public int $paise,
public string $currency = 'INR',
) {
if ($paise < 0) {
throw new InvalidArgumentException('Amount cannot be negative');
}
}
public function add(Money $other): self
{
if ($other->currency !== $this->currency) {
throw new LogicException('Currency mismatch');
}
return new self($this->paise + $other->paise, $this->currency);
}
public function withCurrency(string $currency): self
{
return new self($this->paise, $currency);
}
}
$price = new Money(49900);
$total = $price->add(new Money(8982)); // $price is unchangedHow you ‘change’ an immutable object: you do not. Methods such as add() and withCurrency(), often called withers, return a new instance with the changed value. This is the pattern used by PSR-7 request objects and DateTimeImmutable.
Subtleties interviewers probe:
- Readonly is shallow. A readonly property holding an object stops you swapping in a different object, but the object itself can still be mutated unless it is immutable too.
- Cloning: in PHP 8.1 and 8.2 you could not modify a readonly property on a clone at all. PHP 8.3 allows readonly properties to be reinitialised inside
__clone(), which makes deep cloning possible. - Why bother? Immutable objects are safe to share and cache, cannot be changed by code far away, and make bugs easier to reason about. They suit money, dates, addresses and configuration.
Note: Storing money as integer paise, as in the example, also avoids floating-point rounding errors, which is a good point to add.
33. What are attributes in PHP 8, and how does a framework read them at runtime?
Attributes are structured, native metadata you attach to classes, methods, properties, parameters, functions and constants, using the #[...] syntax. They replace the old convention of parsing docblock comments such as @Route, which was fragile and invisible to the engine.
Attributes do nothing by themselves. They are simply recorded. Some code, usually a framework, reads them with the Reflection API and decides what to do.
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
final class Route
{
public function __construct(
public string $path,
public string $method = 'GET',
) {}
}
final class OrderController
{
#[Route('/orders')]
#[Route('/orders', method: 'POST')]
public function handle(): void {}
}
// A tiny router reading the metadata
$ref = new ReflectionClass(OrderController::class);
foreach ($ref->getMethods() as $method) {
foreach ($method->getAttributes(Route::class) as $attr) {
$route = $attr->newInstance(); // runs the attribute constructor
echo $route->method . ' ' . $route->path . ' -> ' . $method->getName() . PHP_EOL;
}
}Key points:
- An attribute class is declared with
#[Attribute]. Flags restrict where it can be used and whether it may repeat. getAttributes()returns lightweightReflectionAttributeobjects. The class is only validated and instantiated when you callnewInstance().- Frameworks cache what they read, because reflecting on every request is slow. Symfony compiles routes into its cache, for instance.
Real uses: Symfony routing and validation constraints, Doctrine ORM mapping (#[ORM\Column]), PHPUnit 10+ test metadata (#[DataProvider]), and Laravel features such as #[ObservedBy].
Built-in attributes worth knowing:
#[SensitiveParameter](8.2) hides a parameter’s value, such as a password, in stack traces.#[Override](8.3) makes PHP error if the method does not actually override a parent method, which catches renaming mistakes.#[AllowDynamicProperties](8.2) opts a class out of the dynamic property deprecation.#[Deprecated](8.4) marks your own functions as deprecated.
Note: A good one-line summary: attributes are data, not behaviour. The behaviour always lives in whatever code reads them.
34. What is the JIT compiler in PHP 8, and when does it actually make an application faster?
PHP normally compiles source files to opcodes, which the Zend virtual machine interprets. OPcache keeps those opcodes in shared memory so files are not recompiled on every request. The JIT (just-in-time compiler), added in PHP 8.0 as part of OPcache, goes one step further: it compiles frequently executed opcodes into native machine code for the CPU.
Configuration. The JIT is not active out of the box. You need OPcache enabled, a non-zero JIT buffer, and a mode:
opcache.enable=1
opcache.jit_buffer_size=128M
opcache.jit=tracing ; or 'function'- Tracing JIT watches for hot code paths at runtime and compiles those traces. It is generally the faster mode.
- Function JIT compiles whole functions, which is simpler and more predictable.
When it helps a lot: CPU-bound code that spends its time in PHP itself. Examples are mathematical and scientific computation, image processing written in PHP, parsers, compression, simulations and long-running daemons. Synthetic benchmarks such as fractal generation run several times faster.
When it barely helps: typical web applications. A Laravel or WordPress request spends most of its time waiting on MySQL, Redis, the filesystem and HTTP APIs, and running framework code with many function calls and array operations, where the JIT gains little. Real-world gains are usually small, in the low single-digit percentages, and sometimes zero.
Trade-offs:
- More memory for the JIT buffer.
- Some debugging and profiling extensions, such as Xdebug, do not work with it, and PHP disables the JIT when they are loaded.
- JIT bugs are rare but harder to diagnose than ordinary PHP errors.
The honest interview answer: make sure OPcache itself is enabled and tuned, because that is the large win for web apps. Then benchmark the JIT with realistic traffic, and keep it only if the numbers justify it. For a slow web app, database queries, caching and N+1 problems are almost always where the time goes.
Note: The JIT is one reason PHP has become viable for work outside the web request cycle, such as long-running workers and computational scripts.
35. How do named arguments work in PHP 8, and what should library authors watch out for?
Named arguments let you pass values by parameter name instead of position. You can skip optional parameters you do not care about, and the call explains itself.
// Before: what do false and 5 mean?
$s = htmlspecialchars($text, ENT_QUOTES, 'UTF-8', false);
// After: skip to the one you need
$s = htmlspecialchars($text, double_encode: false);
$pad = str_pad('42', 6, pad_type: STR_PAD_LEFT, pad_string: '0'); // '000042'
// Excellent with constructor promotion
$job = new JobPosting(
title: 'PHP Developer',
city: 'Pune',
minSalaryLakh: 8,
remote: true,
);Rules:
- Positional arguments must come before named ones. You cannot pass a positional argument after a named one.
- Order among named arguments does not matter.
- An unknown name, or the same parameter passed twice, throws an
Error. - Array unpacking with string keys works as named arguments from PHP 8.1:
new JobPosting(...$validatedData). - A variadic parameter (
...$options) collects unknown named arguments into an array with string keys.
The catch for library and framework authors: parameter names are now part of your public API.
- Renaming
$needleto$searchis a breaking change for anyone calling withneedle:. Treat renames like signature changes under semantic versioning. - If a child class renames a parameter from its parent, calling the child with the parent’s name throws an unknown-parameter error at runtime, so keep names consistent through the hierarchy.
- Some libraries document which parameters are intended for named use, and leave others free to be renamed.
When to use them: for boolean flags, for functions with several optional parameters, and for value objects with many fields. Do not use them for obvious one- or two-argument calls, such as count($items), where they only add noise.
Note: Named arguments often remove the need for an options array. That gives you type checking and IDE autocompletion on every option, which is a strong point to raise in an interview.
36. What are generators in PHP, and how do yield and yield from help when processing large datasets?
A generator is a function that contains yield. Calling it does not run the body. Instead it returns a Generator object, which implements Iterator. Each time the caller asks for the next value, the function runs until the next yield, hands that value back and pauses, keeping all its local state.
The benefit is constant memory. You process one item at a time instead of building a huge array first.
function readCsv(string $path): Generator
{
$fh = fopen($path, 'r');
try {
$header = fgetcsv($fh);
while (($row = fgetcsv($fh)) !== false) {
yield array_combine($header, $row);
}
} finally {
fclose($fh); // runs even if the caller stops early
}
}
function paidOnly(iterable $rows): Generator
{
foreach ($rows as $row) {
if ($row['status'] === 'paid') {
yield $row['order_id'] => $row;
}
}
}
foreach (paidOnly(readCsv('orders_2024.csv')) as $id => $order) {
// 2 million rows, a few MB of memory
}Features to mention:
- Keys:
yield $key => $value. yield fromdelegates to another generator, array or Traversable, which makes it easy to compose pipelines or flatten recursive structures such as directory trees.returninside a generator sets a final value, read withgetReturn()after iteration ends.send()passes a value back into the paused generator, which is the basis of older coroutine libraries.
Limitations: a generator can be iterated only once and cannot be rewound after it has started. It also has no count without consuming it.
A database gotcha: wrapping $stmt->fetch() in a generator does not save memory with MySQL on its own, because PDO buffers the whole result set on the client by default. For very large exports, turn off PDO::MYSQL_ATTR_USE_BUFFERED_QUERY for that query, or read in keyset-paginated chunks.
Note: Laravel’s LazyCollection and cursor() are built on generators. Mentioning them links the concept to everyday framework code.
37. How do closures and arrow functions capture variables in PHP, and how do you bind a closure to an object?
Anonymous functions (closures) do not see the surrounding scope automatically. You import variables explicitly with use, and by default they are copied by value, at the moment the closure is created.
$rate = 18;
$withGst = function (int $paise) use ($rate): int {
return intdiv($paise * (100 + $rate), 100);
};
$rate = 28;
echo $withGst(10000); // 11800: captured 18 when the closure was created
// By reference: changes inside affect the outer variable
$total = 0;
array_walk($cart, function (array $item) use (&$total): void {
$total += $item['paise'] * $item['qty'];
});
// Arrow function (PHP 7.4): one expression, captures automatically by value
$rate = 5;
$prices = array_map(fn (int $p) => intdiv($p * (100 + $rate), 100), $prices);Arrow functions (fn) capture any outer variable they use, automatically and by value. They must be a single expression and cannot modify outer variables. That makes them ideal for array_map, array_filter and usort callbacks.
$this and binding:
- A closure created inside a class method is automatically bound to
$thisand has access to private members. static functionorstatic fnprevents that binding. That is slightly cheaper and avoids keeping the object alive by accident, which matters in long-running processes.Closure::bind(),bindTo()andcall()attach a closure to a different object and scope, giving it access to that object’s private state. Frameworks use this for features such as Laravel’s macros.
$reveal = function () { return $this->secret; };
echo Closure::bind($reveal, $vault, Vault::class)();First-class callable syntax (PHP 8.1) turns any function or method into a Closure: $len = strlen(...); or $fmt = $formatter->format(...);. That is cleaner and safer for static analysis than string or array callables such as 'strlen'.
Note: A classic interview trap is a closure inside a loop using a by-reference import, which ends up seeing only the loop variable’s final value. Capturing by value avoids it.
38. How do union, intersection, nullable, mixed and never types work in PHP, and what does strict_types change?
PHP’s type system has grown a lot since 7.0. The main forms are:
| Type | Meaning | Since |
|---|---|---|
?int | int or null; short for int|null | 7.1 |
int|string | Union: any one of the listed types | 8.0 |
Countable&Traversable | Intersection: must satisfy every listed type (class and interface types only) | 8.1 |
(A&B)|null | DNF: unions of intersections | 8.2 |
mixed | Any value, including null. It is explicit, unlike having no type. | 8.0 |
void | Returns nothing | 7.1 |
never | Never returns normally: always throws or exits | 8.1 |
static | Return type meaning the called class | 8.0 |
null, false, true | Standalone types | 8.2 |
function findUser(int $id): ?User { /* ... */ }
function redirect(string $url): never
{
header('Location: ' . $url);
exit;
}
function parseId(int|string $raw): int { return (int) $raw; }declare(strict_types=1) controls how scalar parameters are checked:
- In the default coercive mode, PHP converts compatible values:
'5'passed to anintparameter becomes5. Only values that cannot be converted, such as'abc', throw aTypeError. - In strict mode, the value must already be the declared type, or a
TypeErroris thrown. The one exception is that anintis accepted where afloatis declared.
Two details people get wrong: strict mode is per file, and it applies based on the file where the call is made, not where the function is defined. For return types, the file that declares the function decides. Class and interface types are always checked strictly, whatever the mode.
Why use it: strict types plus precise declarations turn silent conversion bugs into immediate errors, and they let PHPStan or Psalm catch mistakes before the code runs. Most modern codebases put declare(strict_types=1); at the top of every file, and add it to code style rules.
Note: Data from forms and query strings always arrives as strings, so in strict mode you must validate and cast at the boundary, for example with filter_var() or a form request, before passing values into typed code.
39. Why can floating-point arithmetic, integer overflow and numeric strings cause bugs in PHP, and how do you avoid them?
Floating point. PHP floats are IEEE 754 doubles, which cannot represent most decimal fractions exactly.
var_dump(0.1 + 0.2 == 0.3); // bool(false)
echo floor((0.1 + 0.7) * 10); // 7, not 8
echo 19.99 * 100; // 1999, but stored as 1998.9999999999998
echo (int) (19.99 * 100); // 1998: truncation exposes itFor money, never use floats for calculations or storage:
- Store amounts as integer paise (or cents) and convert only for display.
- Or use arbitrary precision:
bcadd('0.10', '0.20', 2)with BCMath, or a library such as brick/money. In MySQL, use aDECIMALcolumn. - Compare floats with a tolerance suited to the domain, never with
==.
Integer overflow. On 64-bit systems PHP_INT_MAX is 9223372036854775807. Going past it silently turns the value into a float and loses precision instead of raising an error. That matters for large IDs from external systems, such as Twitter-style snowflake IDs, especially when decoding JSON:
$data = json_decode($json, true, 512, JSON_BIGINT_AS_STRING);Use intdiv() for integer division, because / always returns a float when the division is not exact. For numbers beyond 64 bits, use GMP or BCMath.
Numeric strings. PHP 8 made these stricter:
'10' + 5is 15, and trailing whitespace such as'10 'is now accepted.'10 apples' + 5gives 15 with a warning (‘A non-numeric value encountered’).'apples' + 5throws aTypeErrorin PHP 8. It used to give 5 quietly.(int) '1e3'is 1000, which surprises people.
Defences: validate input at the boundary with filter_var($x, FILTER_VALIDATE_INT) or ctype_digit(), which reject '10 apples' outright. Enable strict_types, and turn warnings into exceptions in development so these problems are seen early.
Note: If a GST or discount calculation is off by one paisa, floating-point arithmetic is the first suspect. Integers in the smallest currency unit fix it permanently.
40. How does PHP pass arrays and objects to functions, and what is copy-on-write?
By default PHP passes everything by value, but ‘value’ means something different for arrays and objects.
Arrays are values, copied lazily. Passing a 100,000-element array to a function does not copy it. Both variables point to the same underlying data, and a reference count tracks how many share it. The actual copy happens only when one side modifies the array. This is copy-on-write.
function total(array $items): int
{
return array_sum(array_column($items, 'paise')); // read only: no copy
}
function addFee(array $items): array
{
$items[] = ['paise' => 2000]; // write: now the array is copied
return $items;
}
$cart = loadCart();
$sum = total($cart); // cheap
$new = addFee($cart); // $cart is unchangedObjects are passed as handles. The variable holds an identifier pointing to the object, and that identifier is what gets copied. So a function can call methods and change properties on the same object the caller has. But reassigning the parameter to a new object does not affect the caller.
function rename(User $u): void
{
$u->name = 'Asha'; // caller sees this
$u = new User(); // caller does not see this
}This is often loosely described as ‘objects are passed by reference’. It is more accurate to say the handle is passed by value. To get an independent copy, use clone, which is shallow. Implement __clone() to deep-copy nested objects.
References (&) make two names point to the same variable. Use them sparingly:
- They rarely save memory, because copy-on-write already avoids copies, and they can even force a copy.
- They cause the classic
foreachbug: afterforeach ($arr as &$v), the variable$vis still a reference to the last element. A laterforeach ($arr as $v)overwrites that element. Alwaysunset($v)after a by-reference loop.
Note: If an interviewer asks whether passing a large array is slow, the answer is no, unless the function modifies it. That is copy-on-write in one sentence.
41. What are PHP's magic methods such as __get, __set, __call, __toString and __invoke, and when should you avoid them?
Magic methods are hooks that PHP calls automatically in particular situations. Their names start with a double underscore.
| Method | Called when |
|---|---|
__construct, __destruct | An object is created, or destroyed |
__get, __set, __isset, __unset | Reading or writing a property that is inaccessible or does not exist |
__call, __callStatic | Calling a method that is inaccessible or does not exist |
__toString | The object is used as a string. Classes with it implement Stringable automatically from PHP 8.0. |
__invoke | The object is called like a function |
__clone | After clone, to adjust the copy |
__serialize, __unserialize | serialize() and unserialize() |
__debugInfo | var_dump(), to hide or reshape output |
final class SendOtp
{
public function __invoke(string $mobile): void { /* ... */ }
}
$send = new SendOtp();
$send('9876543210'); // single-action class, used as a callable
final class Config
{
public function __construct(private array $data) {}
public function __get(string $key): mixed
{
return $this->data[$key] ?? throw new OutOfRangeException($key);
}
}Where frameworks use them: Eloquent uses __get and __set so $user->email reads model attributes. Laravel facades use __callStatic to forward calls to real objects. Invokable controllers and jobs rely on __invoke.
Why to avoid them in your own code:
- They hide the API. IDEs and static analysers cannot see magic properties or methods without
@propertyand@methoddocblocks. - Typos fail quietly.
$user->emialmay return null instead of raising an error. - They are slower than real properties and methods, and harder to debug.
Prefer explicit properties and methods, and keep magic for genuinely dynamic cases such as proxies, ORMs and DSLs. Note that PHP 8.2 deprecated creating undeclared (dynamic) properties, so code that relied on that must declare its properties, use __get and __set, or add #[AllowDynamicProperties].
Note: Never put important logic in __destruct. When it runs depends on reference counting and script shutdown, so it is not a reliable place for work such as saving data.
42. What is dependency injection, and how does a service container such as Laravel's resolve a class's dependencies?
Dependency injection (DI) means a class receives the objects it needs from outside, usually through its constructor, instead of creating them with new or reaching for globals and static calls. The class then depends on abstractions it is given, not on concrete classes it creates for itself.
interface PaymentGateway
{
public function charge(int $paise, string $token): string;
}
final class CheckoutService
{
public function __construct(
private PaymentGateway $gateway,
private OrderRepository $orders,
private LoggerInterface $logger,
) {}
public function pay(Order $order, string $token): void
{
$ref = $this->gateway->charge($order->totalPaise, $token);
$this->orders->markPaid($order, $ref);
}
}Why it matters:
- Testability. In tests you pass a fake gateway, and no real money moves.
- Flexibility. Switching payment providers means changing one binding, not every class that charges a card.
- Honesty. The constructor lists exactly what the class needs. A constructor with nine dependencies is a signal the class does too much.
A service container builds these object graphs for you. Laravel’s container uses reflection autowiring: when asked for CheckoutService, it reads the constructor’s parameter types, resolves each one in turn, recursively, and builds the object. Concrete classes need no configuration at all. Interfaces need a binding, because the container cannot guess which implementation you want:
// App\Providers\AppServiceProvider::register()
$this->app->singleton(PaymentGateway::class, RazorpayGateway::class);
// Contextual: one class gets a different implementation
$this->app->when(RefundService::class)
->needs(PaymentGateway::class)
->give(SandboxGateway::class);bind() creates a new instance each time. singleton() reuses one instance for the rest of the request. Controllers, jobs, listeners and commands are all resolved through the container, which is why type-hinting a dependency in their constructors ‘just works’.
An anti-pattern to mention: calling app(Something::class) deep inside business logic. That is a service locator, which hides dependencies again and loses most of the benefit. Keep container access at the edges and inject everywhere else.
Note: PSR-11 defines a common container interface with get() and has(), so libraries can work with Laravel’s, Symfony’s or PHP-DI’s container.
43. What happens in the Laravel request lifecycle, from public/index.php to the response being sent back?
Knowing the lifecycle tells you where to put code: bindings, middleware, or controller logic.
- Entry point. The web server sends every request that is not for a static file to
public/index.php. It loads Composer’s autoloader and creates the application frombootstrap/app.php. - The application is the service container. The
Applicationobject is the container that will build everything else. - The HTTP kernel handles the request. First it runs bootstrappers: load environment variables from
.env, load configuration, set up error handling, register facades, then register and boot the service providers. - Service providers. Every provider’s
register()method runs first, which only binds things into the container. Then everyboot()method runs, where it is safe to use other services: define routes, register event listeners, add view composers and so on. Most of the framework, including the database, queue and validation, is wired up here. - Global middleware. The request passes through the global middleware stack, for example maintenance mode checks, trimming strings, converting empty strings to null, CORS and trusted proxies.
- Routing. The router matches the URL and method to a route, then runs that route’s middleware group. The
webgroup adds cookie encryption, sessions, CSRF verification and route model binding. Theapigroup adds rate limiting. - Controller. The controller is resolved from the container, so constructor and method dependencies are injected. Form requests validate input before your method runs.
- Response. Whatever you return, whether a view, an array, an Eloquent model or a response object, is converted into a
Response. It travels back out through the same middleware in reverse order, which is how middleware can add headers or cookies. - Send and terminate. The response is sent to the client, then the kernel’s
terminate()runs any terminable middleware. With PHP-FPM, work done here happens after the user already has the response.
Version note. From Laravel 11 the application skeleton is slimmer. Middleware and exception handling are configured in bootstrap/app.php instead of a separate app/Http/Kernel.php, but the lifecycle underneath is the same.
Note: A good follow-up point: put bindings in register(), anything that uses other services in boot(), cross-cutting request logic in middleware, and business rules in services, not in controllers.
44. What is the N+1 query problem in Laravel Eloquent, and how do you detect and fix it?
The N+1 problem happens when you load a list of models with one query, then trigger one extra query per model while accessing a relationship in a loop.
// 1 query for posts + 1 query per post for its author
$posts = Post::latest()->take(50)->get();
foreach ($posts as $post) {
echo $post->author->name; // lazy load: SELECT * FROM users WHERE id = ?
}
// 51 queriesEach query is fast, but 51 round trips to the database, or 501 on a bigger page, adds up to hundreds of milliseconds. It gets worse as data grows.
The fix is eager loading, which fetches related models for the whole list in one extra query using WHERE id IN (...):
$posts = Post::with('author')->latest()->take(50)->get(); // 2 queries
// Nested and multiple relations
$posts = Post::with(['author', 'comments.user', 'tags'])->get();
// Constrain what is eager loaded
$posts = Post::with(['comments' => fn ($q) => $q->where('approved', true)])->get();
// Only need a count? Do not load the rows at all
$posts = Post::withCount('comments')->get(); // $post->comments_count
// Already have the collection? Lazy eager load
$posts->load('author');Detecting it:
- Laravel Debugbar or Telescope show the query count per request. Dozens of near-identical queries are the giveaway.
- Prevent lazy loading in development. In
AppServiceProvider::boot(),Model::preventLazyLoading(! app()->isProduction());makes any lazy load throw an exception locally, so N+1 problems are caught before they ship. - The slow query log or APM tools in production, where one query fingerprint with a very high call count points the same way.
Related points:
- API resources and Blade views are common hiding places, because the loop is far from the query.
- Select only the columns you need, for example
with('author:id,name'), which must include the key used to match the relation. - The same problem exists in plain PHP. A query inside any
foreachis a warning sign, and the fix is the same: collect the IDs and fetch them in one query.
Note: Quantify the fix in your answer, for example ‘the orders page went from 240 queries and 1.8 seconds to 4 queries and 180 milliseconds’. Interviewers remember numbers.
45. What are service providers and facades in Laravel, and how does a facade call reach the real object?
Service providers are where a Laravel application is configured. Each provider has two methods:
register()binds things into the container, and nothing else. Other services may not be ready yet, so do not use them here.boot()runs after all providers have registered. Use it for anything that needs other services: event listeners, route model bindings, view composers, macros, gates and policies.
Packages ship their own providers, which Laravel discovers automatically. Deferred providers are only loaded when one of the services they provide is actually requested.
Facades give a static-looking interface to objects in the container, such as Cache::get('key') or Mail::to($user)->send(...). They are not really static. Here is the mechanism:
- Every facade extends
Illuminate\Support\Facades\Facadeand implementsgetFacadeAccessor(), which returns a container key such as'cache'. - A static call to a method that does not exist on the facade triggers PHP’s
__callStatic()magic method. - The base class resolves the real instance from the container using that key, caches it, and forwards the call with its arguments.
// A custom facade
final class Fx extends \Illuminate\Support\Facades\Facade
{
protected static function getFacadeAccessor(): string
{
return 'fx';
}
}
// In a provider's register()
$this->app->singleton('fx', fn ($app) => new CurrencyConverter(
config('services.fx.key')
));
// Anywhere
$inr = Fx::convert(100, 'USD', 'INR');Testing. Because the call goes through the container, facades can be swapped in tests: Cache::shouldReceive('get')->andReturn('x'), or fakes such as Mail::fake() and Queue::fake().
The trade-off. Facades are convenient in controllers, routes and views. In domain classes they hide dependencies, because the constructor no longer shows that the class needs the cache or the mailer. Many teams prefer constructor injection there, and use facades at the edges.
Note: Laravel also has real-time facades. Prefixing an import with Facades\, as in use Facades\App\Services\Fx, turns any class into a facade without writing one.
46. How do you configure OPcache and preloading for a production PHP server, and what must happen on each deploy?
OPcache stores compiled opcodes in shared memory so PHP does not re-parse files on every request. Enabling it is the single biggest performance setting for PHP, and the defaults are too small for a modern framework application.
opcache.enable=1
opcache.memory_consumption=256 ; MB for compiled code
opcache.interned_strings_buffer=32 ; MB for deduplicated strings
opcache.max_accelerated_files=50000 ; above the number of PHP files, vendor included
opcache.validate_timestamps=0 ; do not check files for changes
opcache.save_comments=1 ; needed by tools that read docblocksWhy validate_timestamps=0: with checking on, PHP calls stat() on files to see whether they changed, at most every revalidate_freq seconds. Turning it off removes that overhead and guarantees every request runs the same code version. The price is that PHP will never notice new code on its own.
So each deploy must reset the cache:
- Reload PHP-FPM gracefully, for example with
systemctl reload php8.3-fpm. That clears OPcache without dropping requests in flight. - Or call
opcache_reset()through the web server. Running it from the CLI does nothing, because the CLI has its own separate cache. - With symlink-swap deploys, where
currentpoints to a new release folder, also make sure the web server passes the resolved real path. In nginx that means$realpath_rootinSCRIPT_FILENAME. Otherwise cached paths can keep serving the old release.
Monitor it. opcache_get_status() shows memory use, hit rate and wasted memory. When the cache fills up, OPcache stops caching new files or restarts itself, and performance becomes erratic. A hit rate below about 99% or full memory means the sizes need raising.
Preloading (PHP 7.4+) loads chosen files into shared memory once, when the server starts, so their classes are available to every request without autoloading:
opcache.preload=/var/www/app/preload.php
opcache.preload_user=www-dataGains are usually modest, a few percent, and any change to preloaded code needs a full FPM restart, not just a reload. Symfony generates a preload file for you. Measure before and after.
Note: A stale OPcache after deploy is a classic ‘some servers run old code’ incident. Adding the FPM reload to the deploy script, and checking it, prevents it.
47. How does PHP manage memory and garbage collection, and how would you track down a memory leak in a long-running worker?
Reference counting. Every value knows how many variables point to it. When the count drops to zero, for example when a function returns or you call unset(), the memory is freed immediately.
The cycle collector. Reference counting cannot free circular references, such as a parent object holding a child that points back to the parent. PHP’s garbage collector periodically looks for such cycles. It runs automatically when its root buffer fills (10,000 possible roots by default), or on demand with gc_collect_cycles().
Why leaks rarely matter in normal web requests: under PHP-FPM all memory is released at the end of each request. The same code running as a queue worker, a daemon, or on Swoole, RoadRunner or Laravel Octane never gets that reset, so small leaks add up over thousands of jobs.
Common causes in long-running processes:
- Static properties or arrays used as caches that only ever grow.
- Event listeners or callbacks registered on every job and never removed.
- ORM identity maps. Doctrine keeps every loaded entity until
EntityManager::clear()is called. - Framework query logging left switched on, such as Laravel’s
DB::enableQueryLog(), or buffered log handlers. - Cycles involving large objects, and closures that capture
$this.
Tracking it down:
- Measure per job. Log
memory_get_usage()after each job, andmemory_get_peak_usage(true). A steady climb that never falls back confirms a leak. - Bisect. Run a single job type in a loop to find which one leaks.
- Profile. Use Blackfire, or Xdebug’s profiler, or dump the heap with a tool such as php-meminfo to see which objects accumulate and who holds references to them.
- Fix it. Clear caches between jobs, use
WeakMap(PHP 8.0) for caches keyed by objects so they do not keep them alive, and remove listeners.
Always add a safety net as well, because third-party code can leak too:
php artisan queue:work --max-jobs=1000 --max-time=3600 --memory=256With a process manager such as Supervisor restarting the worker, it exits cleanly and starts fresh before memory becomes a problem.
Note: Also remember that code changes are not picked up by a running worker. Restarting workers on every deploy, for example with queue:restart, is part of the same discipline.
48. How do you write unit tests in PHP with PHPUnit, and when do you use mocks, stubs and data providers?
A PHPUnit test is a class extending PHPUnit\Framework\TestCase. Each public test method follows arrange, act, assert. PHPUnit 10 and later prefer attributes such as #[Test] and #[DataProvider] over docblock annotations.
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
final class GstCalculatorTest extends TestCase
{
#[DataProvider('rates')]
public function test_it_adds_gst(int $paise, int $rate, int $expected): void
{
$calc = new GstCalculator();
$this->assertSame($expected, $calc->withGst($paise, $rate));
}
public static function rates(): array
{
return [
'five percent' => [10000, 5, 10500],
'eighteen percent' => [10000, 18, 11800],
'zero rated' => [10000, 0, 10000],
];
}
}Data providers run the same test with many inputs. Named keys make failures readable (‘eighteen percent failed’). The provider method must be public static in modern PHPUnit.
Test doubles replace real collaborators:
- A stub (
createStub()) just returns canned values. Use it when the test only needs the collaborator to answer. - A mock (
createMock()with expectations) also verifies that a method was called in a particular way. Use it when the interaction itself is the behaviour, such as ‘the gateway was charged exactly once with this amount’.
$gateway = $this->createMock(PaymentGateway::class);
$gateway->expects($this->once())
->method('charge')
->with(11800, 'tok_abc')
->willReturn('pay_123');
(new CheckoutService($gateway))->pay($order, 'tok_abc');Good practice interviewers listen for:
- Mock at boundaries you own: payment gateways, HTTP clients, the clock and mailers. Do not mock value objects or the class under test.
- Too many mocks make tests brittle, because they break on refactoring even when behaviour is unchanged.
- Keep unit tests fast and isolated. Put database and HTTP behaviour in feature or integration tests. Laravel’s
RefreshDatabasetrait and HTTP testing helpers suit that layer. - Run the suite in CI on every pull request, and use coverage from Xdebug or PCOV as a guide to untested code, not as a target to game.
Note: Pest is a popular testing layer built on PHPUnit with a more concise syntax. Knowing that both exist, and that Pest runs on PHPUnit underneath, is a useful detail.
49. What is PHP-FPM, how does it differ from running PHP as an Apache module, and how do you size its worker pool?
mod_php embeds the PHP interpreter inside every Apache process. It is simple, but every Apache child carries PHP’s memory even when it is serving an image or a CSS file. It also requires the older prefork MPM, because PHP extensions were not all thread-safe.
PHP-FPM (FastCGI Process Manager) runs PHP as a separate service with its own pool of worker processes. The web server, nginx or Apache with the event MPM and mod_proxy_fcgi, serves static files itself and passes only PHP requests to FPM over a socket.
Benefits of FPM:
- The web server stays lightweight and handles thousands of connections, including slow clients, cheaply.
- Multiple pools, each with its own Unix user,
php.inioverrides and limits, which is useful for isolating sites on one server. - A slow log that records a stack trace for requests over a threshold, and a status page with active and idle worker counts.
- Graceful reloads, which also clear OPcache on deploy.
Process manager modes: static keeps a fixed number of workers, dynamic scales between minimum and maximum spare workers, and ondemand starts workers only when requests arrive.
pm = dynamic
pm.max_children = 60
pm.start_servers = 15
pm.min_spare_servers = 10
pm.max_spare_servers = 25
pm.max_requests = 1000 ; recycle workers to contain leaks
request_slowlog_timeout = 5s
request_terminate_timeout = 60sSizing pm.max_children: each worker handles one request at a time, so this is your concurrency limit. Measure the average resident memory of a worker under real load, for example 70 MB, and work out how much RAM PHP can have after the OS, MySQL, Redis and other services. With 5 GB available: 5,000 ÷ 70 is about 70, so set it near 60 for headroom. Setting it too high risks swapping and the out-of-memory killer. Too low, and you see ‘server reached pm.max_children’ in the log, with requests queueing into 502 and 504 errors.
Remember the real bottleneck: a worker waiting 3 seconds on a slow payment API is a worker not serving anyone else. Timeouts on outbound calls, and moving slow work to queues, often matter more than adding workers.
Note: If pages are slow but CPU is idle, check the FPM status page. All workers busy with low CPU usually means they are waiting on I/O, not short of processing power.
50. What are Fibers in PHP 8.1, and how do they relate to asynchronous libraries such as ReactPHP and AMPHP?
A Fiber is a block of code with its own call stack that can be paused and resumed. Inside a fiber, Fiber::suspend() stops execution at any depth of nested function calls and hands control back to whoever started or resumed it. Later, resume() continues exactly where it left off.
$fiber = new Fiber(function (string $job): string {
echo "Started $job" . PHP_EOL;
$reply = Fiber::suspend('waiting for data');
return "Finished $job with $reply";
});
$status = $fiber->start('report'); // prints 'Started report'
echo $status . PHP_EOL; // 'waiting for data'
$fiber->resume('42 rows');
echo $fiber->getReturn(); // 'Finished report with 42 rows'How they differ from generators. A generator can only pause at a yield in its own body, so every function in the chain must itself be a generator. That is why older async PHP code was full of yield. A fiber can suspend from deep inside ordinary functions, so the calling code does not have to change shape.
What fibers are not:
- Not threads or parallelism. Only one fiber runs at a time, on one CPU core. Fibers give cooperative concurrency: code voluntarily yields while it waits.
- Not useful on their own. Something has to decide which fiber to resume and when. That is an event loop watching non-blocking sockets and timers.
Where they are used. Fibers are a low-level building block for library authors. AMPHP v3 and ReactPHP’s async package use them so application code can look synchronous. A call such as $response = $client->request($url); suspends the current fiber while the network request is in progress, and the event loop runs other fibers meanwhile. One process can then handle many concurrent HTTP calls or socket connections.
Caveats:
- Blocking functions still block everything. PDO,
file_get_contents()on a URL andsleep()stop the whole process, so you need async-aware clients. - For ordinary PHP-FPM web requests, fibers add little. They matter for long-running services such as websocket servers, crawlers and API aggregators.
Note: A crisp summary for interviews: fibers make async PHP code look synchronous. They do not make synchronous PHP code asynchronous.