These 26 PHP interview questions are for hiring developers who build and maintain real PHP applications: Laravel and Symfony products, APIs and the long-lived codebases many companies still depend on. They test modern PHP 8.4, the request lifecycle, framework internals, performance and the upgrade work that separates a senior PHP engineer from someone who has only started new projects. At Ryz, PHP candidates are sourced by recruiters who verify what they shipped, then take structured NTRVSTA AI interviews on topics like these, with recruiters reviewing them before and after. AI scores are advisory, and every decision is made by people.
PHP spans a huge range, from WordPress plugins to large Symfony platforms, so start by deciding which PHP you are hiring for and pick questions to match. Then:
0 == "a" return in PHP 8, and why does it matter?It returns false. Before PHP 8, comparing a number with a non-numeric string cast the string to a number, so it was true. PHP 8 compares them as strings instead. Loose comparison still has surprises, such as "1e3" == "1000" being true, which has caused authentication bugs when comparing hashes. Use === by default and hash_equals for secrets.
What a strong answer shows: They know type juggling changed in PHP 8 and still default to strict comparison.
declare(strict_types=1) actually do?It makes scalar type declarations strict for function calls made from that file, so passing "5" to an int parameter throws a TypeError instead of coercing. For parameters, the calling file's mode decides; for return values, the mode of the file that declares the function decides. It does not change arithmetic or comparisons. Most modern codebases put it at the top of every file.
What a strong answer shows: The precise per-file, caller-side rule, which most candidates get wrong.
$items = [1, 2, 3];
foreach ($items as &$item) {}
foreach ($items as $item) {}
print_r($items); // [1, 2, 2]
After the first loop, $item is still a reference to the last element. The second loop assigns each value into that reference, overwriting the last element on every pass. Call unset($item) after a by-reference loop, or avoid references and use array_map.
What a strong answer shows: They understand references, and that arrays are otherwise copy-on-write values.
PHP-FPM is shared-nothing: each request boots the app, handles the request and tears down all objects, statics and globals. What survives is the compiled bytecode in OPcache, persistent connections if enabled, and anything in external stores like Redis or the database. That model is why PHP apps scale horizontally easily and why in-memory state cannot be used for sessions or caches across requests.
What a strong answer shows: A runtime model that explains both PHP's strengths and its constraints.
With password_hash($plain, PASSWORD_DEFAULT) and password_verify, which handle salts and algorithm choice. Use password_needs_rehash on login to upgrade old hashes when cost or algorithm changes. Never MD5 or SHA-1, and never your own salt scheme.
What a strong answer shows: Standard library first, plus a migration path for existing hashes.
Use PDO prepared statements with bound parameters for every value. Identifiers like column names in an ORDER BY cannot be bound, so check them against an allowlist. Set PDO::ATTR_ERRMODE to ERRMODE_EXCEPTION, which is the default since PHP 8, and consider turning off emulated prepares for MySQL so the server handles parameters natively.
What a strong answer shows: They know where binding does not help.
Backed enums (PHP 8.1) replace string constants with a closed set of values that can carry methods. Readonly properties and classes (8.1 and 8.2) make value objects immutable without boilerplate.
enum OrderStatus: string {
case Pending = 'pending';
case Paid = 'paid';
case Refunded = 'refunded';
public function isFinal(): bool {
return match ($this) {
self::Paid, self::Refunded => true,
self::Pending => false,
};
}
}
$status = OrderStatus::tryFrom($row['status']) ?? throw new UnexpectedValueException();
What a strong answer shows: Comfort with match, from versus tryFrom, and typed value objects.
Do not load the file into an array. Stream it with a generator and process rows in batches, committing every few thousand rows.
function csvRows(string $path): Generator {
$handle = fopen($path, 'r') ?: throw new RuntimeException("Cannot open $path");
try {
while (($row = fgetcsv($handle, null, ',', '"', '')) !== false) {
yield $row;
}
} finally {
fclose($handle);
}
}
Also watch the ORM: Eloquent's lazy() or chunkById(), and clearing Doctrine's identity map with $em->clear(), keep memory flat.
What a strong answer shows: Streaming as the default for large data, and awareness that ORMs hold references.
OPcache stores compiled bytecode in shared memory so scripts are not recompiled per request; it is essential. Preloading loads chosen classes at server start for a small extra gain. The JIT compiles hot code to machine code and helps CPU-heavy work like image processing or math, but typical web requests spend their time on I/O, so the benefit is usually small.
What a strong answer shows: They know where time goes in a PHP request.
Many internal functions now throw TypeError or ValueError instead of emitting warnings and returning false. Both Exception and Error implement Throwable, so a top-level handler should catch Throwable. Warnings and notices still exist and can be converted to exceptions with set_error_handler, which frameworks do for you.
What a strong answer shows: They know the exception hierarchy and do not rely on @ suppression.
Sessions are stored on local disk, so each server only knows its own sessions. Move sessions, cache and locks to Redis or the database, put uploaded files on shared object storage, and make sure every server has the same APP_KEY so encrypted cookies decrypt everywhere.
What a strong answer shows: They spot all the local state, not just sessions.
The app boots once and serves many requests from the same process, which removes bootstrap cost. The trade-off is that state now leaks between requests: static properties, singletons holding request data and growing arrays persist. Code must reset per-request state, and workers should restart after a set number of requests to contain leaks.
What a strong answer shows: They see that the shared-nothing guarantee is gone and know what to audit.
That is a cache stampede. Use a lock so one request rebuilds while others serve stale data or wait, for example Laravel's Cache::lock or Symfony Cache's built-in stampede protection. Add jitter to expiry times, and for expensive reports, refresh in a scheduled job rather than on read.
What a strong answer shows: They recognize the pattern and know several mitigations.
They remove most getter and setter boilerplate. Hooks run logic on read or write of a property, and asymmetric visibility lets a property be public to read but private to write.
final class Customer {
public string $email {
set(string $value) {
$this->email = strtolower(trim($value));
}
}
public private(set) DateTimeImmutable $createdAt;
public function __construct(string $email) {
$this->email = $email;
$this->createdAt = new DateTimeImmutable();
}
}
What a strong answer shows: Current PHP knowledge and judgment about when hidden logic in a property hook hurts readability.
Laravel Debugbar or Telescope shows the queries. The cause is usually lazy-loading relations in a loop or a Blade view. Eager load what the page needs and make lazy loading fail loudly outside production.
// AppServiceProvider::boot()
Model::preventLazyLoading(! app()->isProduction());
$orders = Order::with(['customer', 'lines.product'])
->withCount('refunds')
->latest()
->paginate(50);
What a strong answer shows: They fix the cause and add a guard so it does not come back.
Assume any job can run more than once. Make the work idempotent, for example by checking a processed flag or using a unique constraint. Set $tries, backoff and a failed() handler, use ShouldBeUnique or the WithoutOverlapping middleware to prevent duplicates, and dispatch after the database transaction commits. Horizon gives visibility into Redis queues.
What a strong answer shows: Idempotency first, then framework features.
Services are defined in configuration and autowired by type. At build time the container is compiled to plain PHP, with unused private services removed, so runtime resolution is fast and many wiring errors appear at build time instead of in production. Compiler passes let bundles modify definitions, for example collecting tagged services.
What a strong answer shows: They understand the container as compiled code, not runtime reflection.
Attributes (PHP 8.0) attach structured metadata that frameworks read through reflection: Symfony's #[Route] and #[AsCommand], Doctrine ORM mappings, validation constraints. The engine also understands a few itself, such as #[\Override] (8.3), which fails if the parent method disappears, and #[\Deprecated] (8.4). They go too far when business rules hide in attribute configuration that nobody can test or trace.
What a strong answer shows: They know attributes are inert metadata until something reads them, and use the engine-level ones as safety checks.
Composer with a committed composer.lock and PSR-4 autoloading, deployed with composer install --no-dev --optimize-autoloader. PHPStan or Psalm at a high level, with a baseline for legacy code. A formatter like PHP-CS-Fixer or Laravel Pint, Rector for automated refactors and upgrades, and Pest or PHPUnit in CI.
What a strong answer shows: Static analysis as standard practice.
is_admin=1 to a profile form. What went wrong?Mass assignment: the controller passed $request->all() to update() on a model without a restrictive $fillable. Pass only validated data, such as $request->validated() from a Form Request, and keep privileged fields out of fillable attributes. Authorization belongs in policies, not controllers.
What a strong answer shows: Security habits built into framework usage.
Add PHPStan with a baseline and enough tests to trust changes. Run Rector sets for each version to automate most edits, then deal with behavior changes: string-to-number comparisons, internal functions that now throw, dynamic properties deprecated in 8.2 and implicitly nullable parameters deprecated in 8.4. Upgrade the framework and dependencies in step, deploy one version jump at a time and watch deprecation logs.
What a strong answer shows: An incremental, tool-assisted plan with knowledge of what actually breaks.
Do the math: six servers with pm.max_children = 100 can open 600 connections. Cap FPM workers to what the database can serve, move slow work to queues, add a proxy such as ProxySQL, send reads to replicas with Laravel's read and write connections, and fix the slow queries that hold connections. More workers often makes it worse.
What a strong answer shows: Capacity math across tiers rather than raising limits.
Group code by domain module rather than one huge app/Models and app/Http, with clear public entry points per module. Communicate between modules through events or service interfaces, keep Eloquent models from leaking across boundaries, and enforce rules with Deptrac or PHPStan rules. Extract a service only when a module has a real reason to deploy separately.
What a strong answer shows: Boundaries you can enforce, not just folders.
Expand and contract. Add new columns or tables in a backward-compatible migration, deploy code that writes to both, backfill in batches, switch reads, then remove the old column in a later release. Avoid long table locks on large MySQL tables by using online DDL or tools like gh-ost, and make deploys atomic with a symlink switch and an OPcache reset.
What a strong answer shows: Releases where old and new code run at the same time without errors.
unserialize on user input, which enables object injection; file uploads stored in the web root or trusted by extension; SSRF in URL fetchers; missing CSRF protection; raw SQL with string concatenation; outdated packages, checked with composer audit; and secrets committed to the repo. Then authorization: can user A read user B's records by changing an ID?
What a strong answer shows: A prioritized checklist drawn from real PHP vulnerabilities.
Verify the signature with hash_hmac and hash_equals against the raw body, reject old timestamps to block replays, store the event ID with a unique constraint and return 200 quickly. Process the event in a queued job that is idempotent, because providers retry and may deliver out of order.
What a strong answer shows: Verification, idempotency and fast acknowledgement as one design.
== everywhere and cannot explain type juggling.addslashes.vendor/ without reason.$request->all() straight into models.Provide a small Laravel 12 or Symfony 7 app with a MySQL or Postgres database in Docker. It has an endpoint that lists invoices and a command that imports payments from a large CSV. Plant issues: N+1 queries on the listing, the importer loading the whole file into memory, a mass assignment hole, and a queued job that double-charges when retried. Give the candidate a take-home of about three hours, or work through it together in 90 minutes.
Ryz introduces senior PHP developers who have been assessed on modern PHP, Laravel or Symfony and legacy upgrade work. They are the top 1% of the candidates we interview, they join your team, repos and standups, and they work within ±1h of US time zones. See how our vetting process runs, or use the PHP developer job description to scope the role.
Hire for PHP depth first. A strong engineer moves between the two in weeks, since both share Composer, PSR standards and similar patterns. Framework experience matters more when the role starts with a large upgrade or deep framework customization.
Show them a messy, real-looking file and ask how they would make a safe change to it. Look for characterization tests, small refactors and static analysis, rather than an immediate rewrite.
Some are, especially those who build custom plugins with tests and Composer. Ask the same questions about types, queries, security and architecture rather than judging by the platform on their resume.
Questions we didn't answer? Email info@ryzlabs.com.